feat(WEB): 입력 컴포넌트 공통화 및 UI 개선

- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가
- MobileCard 컴포넌트 통합 (ListMobileCard 제거)
- IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈)
- IntegratedDetailTemplate 타이틀 중복 수정
- 문서 시스템 컴포넌트 추가
- 헤더 벨 아이콘 포커스 스타일 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-21 20:56:17 +09:00
parent cfa72fe19b
commit 835c06ce94
190 changed files with 8575 additions and 2354 deletions

View File

@@ -19,7 +19,16 @@ import {
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { PhoneInput } from '@/components/ui/phone-input';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
import { CardNumberInput } from '@/components/ui/card-number-input';
import { AccountNumberInput } from '@/components/ui/account-number-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import { QuantityInput } from '@/components/ui/quantity-input';
import { NumberInput } from '@/components/ui/number-input';
import { cn } from '@/lib/utils';
import { formatPhoneNumber, formatBusinessNumber, formatCardNumber, formatAccountNumber, formatNumber } from '@/lib/formatters';
import type { FieldDefinition, DetailMode, FieldOption } from './types';
import type { ReactNode } from 'react';
@@ -107,6 +116,32 @@ function renderViewValue(
<div className="whitespace-pre-wrap">{String(value)}</div>
);
case 'phone':
return formatPhoneNumber(String(value));
case 'businessNumber':
return formatBusinessNumber(String(value));
case 'cardNumber':
return formatCardNumber(String(value));
case 'accountNumber':
return formatAccountNumber(String(value));
case 'personalNumber': {
const pn = String(value).replace(/\D/g, '');
if (pn.length === 13) {
return `${pn.slice(0, 6)}-${pn.slice(6, 7)}******`;
}
return pn;
}
case 'currency':
return `${formatNumber(Number(value) || 0, { useComma: true })}`;
case 'quantity':
return formatNumber(Number(value) || 0, { useComma: true });
default:
return String(value);
}
@@ -256,6 +291,86 @@ function renderFormField(
/>
);
case 'phone':
return (
<PhoneInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={hasError}
/>
);
case 'businessNumber':
return (
<BusinessNumberInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={hasError}
showValidation
/>
);
case 'personalNumber':
return (
<PersonalNumberInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={hasError}
maskBack
/>
);
case 'cardNumber':
return (
<CardNumberInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder || '0000-0000-0000-0000'}
disabled={disabled}
error={hasError}
/>
);
case 'accountNumber':
return (
<AccountNumberInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder || '0000-0000-0000-0000'}
disabled={disabled}
error={hasError}
/>
);
case 'currency':
return (
<CurrencyInput
value={value as number | undefined}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={hasError}
/>
);
case 'quantity':
return (
<QuantityInput
value={value as number | undefined}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={hasError}
min={0}
/>
);
case 'custom':
if (field.renderField) {
return field.renderField({

View File

@@ -16,7 +16,13 @@ import {
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { PhoneInput } from '@/components/ui/phone-input';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import { QuantityInput } from '@/components/ui/quantity-input';
import { cn } from '@/lib/utils';
import { formatPhoneNumber, formatBusinessNumber, formatNumber } from '@/lib/formatters';
import type { FieldDefinition, DetailMode, FieldOption } from './types';
import type { ReactNode } from 'react';
@@ -123,6 +129,26 @@ function renderViewValue(
<div className="whitespace-pre-wrap">{String(value)}</div>
);
case 'phone':
return formatPhoneNumber(String(value));
case 'businessNumber':
return formatBusinessNumber(String(value));
case 'personalNumber': {
const pn = String(value).replace(/\D/g, '');
if (pn.length === 13) {
return `${pn.slice(0, 6)}-${pn.slice(6, 7)}******`;
}
return pn;
}
case 'currency':
return `${formatNumber(Number(value) || 0, { useComma: true })}`;
case 'quantity':
return formatNumber(Number(value) || 0, { useComma: true });
default:
return String(value);
}
@@ -271,6 +297,64 @@ function renderFormField(
/>
);
case 'phone':
return (
<PhoneInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={!!error}
/>
);
case 'businessNumber':
return (
<BusinessNumberInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={!!error}
showValidation
/>
);
case 'personalNumber':
return (
<PersonalNumberInput
value={stringValue}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={!!error}
maskBack
/>
);
case 'currency':
return (
<CurrencyInput
value={value as number | undefined}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={!!error}
/>
);
case 'quantity':
return (
<QuantityInput
value={value as number | undefined}
onChange={(v) => onChange(v)}
placeholder={field.placeholder}
disabled={disabled}
error={!!error}
min={0}
/>
);
case 'custom':
if (field.renderField) {
return field.renderField({

View File

@@ -25,7 +25,15 @@ export type FieldType =
| 'dateRange'
| 'richtext'
| 'file'
| 'custom';
| 'custom'
// 포맷팅 입력 타입
| 'phone' // 전화번호 (자동 하이픈)
| 'businessNumber' // 사업자번호 (000-00-00000)
| 'personalNumber' // 주민번호 (000000-0000000)
| 'cardNumber' // 카드번호 (0000-0000-0000-0000)
| 'accountNumber' // 계좌번호 (4자리마다 하이픈)
| 'currency' // 금액 (₩, 천단위 콤마)
| 'quantity'; // 수량 (정수, 최소 0)
// ===== 옵션 (select, radio용) =====
export interface FieldOption {

View File

@@ -1,7 +1,7 @@
"use client";
import { ReactNode, Fragment, useState, RefObject } from "react";
import { LucideIcon, Trash2, Plus } from "lucide-react";
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
import { LucideIcon, Trash2, Plus, Loader2 } from "lucide-react";
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
@@ -185,10 +185,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
// 데이터
data: T[]; // 데스크톱용 페이지네이션된 데이터
totalCount?: number; // 전체 데이터 개수 (역순 번호 계산용)
allData?: T[]; // 모바일 인피니티 스크롤용 전체 필터된 데이터
mobileDisplayCount?: number; // 모바일에서 표시할 개수
onLoadMore?: () => void; // 더 불러오기 콜백
infinityScrollSentinelRef?: RefObject<HTMLDivElement | null>; // 인피니티 스크롤용 sentinel ref
allData?: T[]; // 클라이언트 사이드 필터링용 전체 데이터 (소량 데이터 페이지용)
mobileDisplayCount?: number; // 클라이언트 사이드 인피니티에서 표시할 개수
onLoadMore?: () => void; // 더 불러오기 콜백 (레거시)
// ===== 서버 사이드 모바일 인피니티 스크롤 =====
// 모바일에서 스크롤/버튼으로 다음 페이지 로드, 데이터 누적 표시
enableMobileInfinityScroll?: boolean; // 서버 사이드 인피니티 활성화 (기본: true)
isMobileLoading?: boolean; // 모바일 추가 로딩 중 상태
// 체크박스 선택
selectedItems: Set<string>;
@@ -252,7 +256,8 @@ export function IntegratedListTemplateV2<T = any>({
allData,
mobileDisplayCount,
onLoadMore,
infinityScrollSentinelRef,
enableMobileInfinityScroll = true, // 기본값: 활성화
isMobileLoading = false,
selectedItems,
onToggleSelection,
onToggleSelectAll,
@@ -269,9 +274,141 @@ export function IntegratedListTemplateV2<T = any>({
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 서버 사이드 모바일 인피니티 스크롤 =====
// 모바일에서 누적 데이터를 관리하여 스크롤 시 계속 추가
const [accumulatedMobileData, setAccumulatedMobileData] = useState<T[]>([]);
const [lastAccumulatedPage, setLastAccumulatedPage] = useState(0);
const mobileScrollSentinelRef = useRef<HTMLDivElement>(null);
// 클라이언트 사이드 인피니티용 (allData가 있는 경우)
const [clientDisplayCount, setClientDisplayCount] = useState(mobileDisplayCount || 20);
// 서버 페이지네이션: 데이터 누적 로직
// - 페이지 1이면 리셋 (필터/탭 변경)
// - 이전 페이지 + 1이면 누적 (스크롤로 다음 페이지 로드)
//
// 서버 사이드 판단: allData가 없거나, pagination.totalItems > allData.length
// (외부 훅으로 데이터 관리하면서 서버 페이지네이션 사용하는 경우)
useEffect(() => {
const isServerSide = !allData || pagination.totalItems > allData.length;
if (!isServerSide) {
// 순수 클라이언트 사이드 필터링 모드 - 누적 불필요
return;
}
if (!enableMobileInfinityScroll) {
// 서버 사이드 인피니티 비활성화 - data만 사용
setAccumulatedMobileData(data);
return;
}
if (pagination.currentPage === 1) {
// 페이지 1: 필터/탭 변경으로 리셋
setAccumulatedMobileData(data);
setLastAccumulatedPage(1);
} else if (pagination.currentPage === lastAccumulatedPage + 1) {
// 다음 페이지: 기존 데이터에 누적
setAccumulatedMobileData(prev => [...prev, ...data]);
setLastAccumulatedPage(pagination.currentPage);
} else if (pagination.currentPage !== lastAccumulatedPage) {
// 페이지 점프 (예: PC에서 페이지 변경 후 모바일로): 현재 데이터만 표시
setAccumulatedMobileData(data);
setLastAccumulatedPage(pagination.currentPage);
}
}, [data, pagination.currentPage, pagination.totalItems, allData, enableMobileInfinityScroll, lastAccumulatedPage]);
// 탭 변경 감지: activeTab 변경 시 누적 데이터 리셋
// 주의: allData를 dependency에 넣으면 페이지 변경 시마다 리셋됨 (외부 훅 사용 시)
useEffect(() => {
if (enableMobileInfinityScroll) {
setAccumulatedMobileData([]);
setLastAccumulatedPage(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]); // activeTab만 감지 - 탭 변경 시에만 리셋
// 클라이언트 사이드: allData가 변경되면 displayCount 리셋
useEffect(() => {
if (allData) {
setClientDisplayCount(mobileDisplayCount || 20);
}
}, [allData, mobileDisplayCount]);
// 서버 사이드: 스크롤/버튼으로 다음 페이지 로드
const handleLoadMoreMobile = useCallback(() => {
if (isMobileLoading) return;
if (pagination.currentPage >= pagination.totalPages) return;
// 다음 페이지 요청
pagination.onPageChange(pagination.currentPage + 1);
}, [isMobileLoading, pagination]);
// 클라이언트 사이드: 더 보기
const handleLoadMoreClient = useCallback(() => {
if (!allData) return;
setClientDisplayCount(prev => Math.min(prev + 20, allData.length));
onLoadMore?.();
}, [allData, onLoadMore]);
// 서버 사이드 페이지네이션 사용 여부 판단 (useEffect보다 먼저 정의)
// - allData가 없으면 서버 사이드
// - allData가 있어도 pagination.totalItems가 더 크면 서버 사이드 (외부 훅으로 데이터 관리하는 경우)
const isServerSidePagination = !allData || pagination.totalItems > allData.length;
// Intersection Observer - 서버 사이드 & 클라이언트 사이드 공통
useEffect(() => {
if (!mobileScrollSentinelRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) return;
if (isServerSidePagination) {
// 서버 사이드 인피니티
if (enableMobileInfinityScroll && !isMobileLoading && pagination.currentPage < pagination.totalPages) {
handleLoadMoreMobile();
}
} else {
// 클라이언트 사이드 인피니티
if (clientDisplayCount < allData!.length) {
handleLoadMoreClient();
}
}
},
{ threshold: 0.1 }
);
observer.observe(mobileScrollSentinelRef.current);
return () => observer.disconnect();
}, [isServerSidePagination, allData, clientDisplayCount, enableMobileInfinityScroll, isMobileLoading, pagination.currentPage, pagination.totalPages, handleLoadMoreClient, handleLoadMoreMobile]);
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
const allSelected = selectedItems.size === data.length && data.length > 0;
// 모바일용 데이터 결정
// 1. 서버 사이드: 누적 데이터 또는 현재 페이지 데이터
// 2. 클라이언트 사이드: allData에서 slice
// 참고: accumulatedMobileData가 비어있으면 data 또는 allData를 폴백으로 사용
const mobileData = isServerSidePagination
? (enableMobileInfinityScroll
? (accumulatedMobileData.length > 0 ? accumulatedMobileData : (allData || data))
: data)
: allData!.slice(0, clientDisplayCount);
// 더 로드 가능 여부
const hasMoreData = isServerSidePagination
? pagination.currentPage < pagination.totalPages
: clientDisplayCount < allData!.length;
// 현재 로드된 개수 / 전체 개수
const loadedCount = isServerSidePagination
? (accumulatedMobileData.length > 0 ? accumulatedMobileData.length : mobileData.length)
: clientDisplayCount;
const totalDataCount = isServerSidePagination
? pagination.totalItems
: allData!.length;
// ===== filterConfig 기반 자동 필터 렌더링 =====
// PC용 인라인 필터 (xl 이상에서 표시)
const renderAutoFilters = () => {
@@ -533,13 +670,13 @@ export function IntegratedListTemplateV2<T = any>({
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
<div className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
{(allData && allData.length > 0 ? allData : data).length === 0 ? (
{mobileData.length === 0 ? (
<div className="text-center py-6 text-muted-foreground border rounded-lg text-[14px]">
.
</div>
) : (
// 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순)
(allData || data).map((item, index) => {
// 인피니티 스크롤: mobileData는 allData?.slice(0, displayCount) || data
mobileData.map((item, index) => {
const itemId = getItemId(item);
const isSelected = selectedItems.has(itemId);
// 순차 번호: 1번부터 시작
@@ -558,13 +695,45 @@ export function IntegratedListTemplateV2<T = any>({
);
})
)}
{/* 인피니티 스크롤 Sentinel */}
{infinityScrollSentinelRef && (
<div
ref={infinityScrollSentinelRef}
className="h-10 w-full col-span-full"
aria-hidden="true"
/>
{/* 모바일 인피니티 스크롤 - 더 보기 버튼 & 로딩 표시 */}
{mobileData.length > 0 && (
<div className="col-span-full">
{hasMoreData ? (
<>
{/* 스크롤 감지용 Sentinel */}
<div
ref={mobileScrollSentinelRef}
className="h-4 w-full"
aria-hidden="true"
/>
{/* 더 보기 버튼 + 진행 상황 */}
<div className="flex flex-col items-center gap-2 py-4">
{isMobileLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={isServerSidePagination ? handleLoadMoreMobile : handleLoadMoreClient}
className="w-full max-w-xs"
>
</Button>
)}
<span className="text-xs text-muted-foreground">
{loadedCount.toLocaleString()} / {totalDataCount.toLocaleString()}
</span>
</div>
</>
) : (
<div className="text-center py-4 text-sm text-muted-foreground">
({totalDataCount.toLocaleString()})
</div>
)}
</div>
)}
</div>

View File

@@ -76,6 +76,9 @@ export function UniversalListPage<T>({
const itemsPerPage = config.itemsPerPage || 20;
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
const [isMobileLoading, setIsMobileLoading] = useState(false);
// ===== ID 추출 헬퍼 =====
const getItemId = useCallback(
(item: T): string => {
@@ -152,8 +155,15 @@ export function UniversalListPage<T>({
}, [config.clientSideFiltering, config.tabs, config.tabFilter, rawData, tabs]);
// ===== 데이터 로딩 =====
const fetchData = useCallback(async () => {
setIsLoading(true);
// isMobileAppend: 모바일 인피니티 스크롤로 추가 로드 시 true
const fetchData = useCallback(async (isMobileAppend = false) => {
// 모바일 추가 로드면 isMobileLoading, 그 외에는 isLoading
if (isMobileAppend) {
setIsMobileLoading(true);
} else {
setIsLoading(true);
}
try {
const result = await config.actions.getList(
config.clientSideFiltering
@@ -169,15 +179,15 @@ export function UniversalListPage<T>({
if (result.success && result.data) {
setRawData(result.data);
setIsLoading(false);
} else {
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
setIsLoading(false);
}
} catch (error) {
console.error('[UniversalListPage] Fetch error:', error);
toast.error('데이터를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
setIsMobileLoading(false);
}
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
@@ -208,9 +218,14 @@ export function UniversalListPage<T>({
}, [rawData, config.onDataChange]);
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
const [prevPage, setPrevPage] = useState(1);
useEffect(() => {
if (!config.clientSideFiltering && !isLoading) {
fetchData();
if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
const isMobileAppend = currentPage > prevPage && currentPage > 1;
fetchData(isMobileAppend);
setPrevPage(currentPage);
}
}, [currentPage, searchValue, filters, activeTab]);
@@ -571,6 +586,8 @@ export function UniversalListPage<T>({
data={displayData}
totalCount={totalCount}
allData={config.clientSideFiltering ? filteredData : undefined}
// 모바일 인피니티 스크롤 로딩 상태
isMobileLoading={isMobileLoading}
// 체크박스 선택
selectedItems={effectiveSelectedItems}
onToggleSelection={toggleSelection}