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

@@ -8,8 +8,27 @@ import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { Textarea } from "../ui/textarea";
import { AlertCircle } from "lucide-react";
import { PhoneInput } from "../ui/phone-input";
import { BusinessNumberInput } from "../ui/business-number-input";
import { PersonalNumberInput } from "../ui/personal-number-input";
import { NumberInput } from "../ui/number-input";
import { CurrencyInput } from "../ui/currency-input";
import { QuantityInput } from "../ui/quantity-input";
export type FormFieldType = 'text' | 'number' | 'date' | 'select' | 'textarea' | 'custom' | 'password';
export type FormFieldType =
| 'text'
| 'number'
| 'date'
| 'select'
| 'textarea'
| 'custom'
| 'password'
// 새 입력 타입
| 'phone' // 전화번호 (자동 하이픈)
| 'businessNumber' // 사업자번호 (000-00-00000)
| 'personalNumber' // 주민번호 (000000-0000000)
| 'currency' // 금액 (천단위 콤마, ₩)
| 'quantity'; // 수량 (정수, 최소 0)
export interface SelectOption {
value: string;
@@ -23,6 +42,8 @@ export interface FormFieldProps {
type?: FormFieldType;
value?: string | number;
onChange?: (value: string) => void;
/** number 타입 전용 onChange (currency, quantity 타입에서 사용) */
onChangeNumber?: (value: number | undefined) => void;
placeholder?: string;
disabled?: boolean;
error?: string;
@@ -37,6 +58,21 @@ export interface FormFieldProps {
max?: number;
step?: number;
htmlFor?: string;
// 새 입력 타입 전용 옵션
/** 사업자번호 유효성 검사 표시 */
showValidation?: boolean;
/** 주민번호 뒷자리 마스킹 */
maskBack?: boolean;
/** 소수점 허용 (NumberInput) */
allowDecimal?: boolean;
/** 소수점 자릿수 (NumberInput) */
decimalPlaces?: number;
/** 천단위 콤마 (NumberInput) */
useComma?: boolean;
/** 접미사 (원, 개, % 등) */
suffix?: string;
/** 수량 +/- 버튼 표시 */
showButtons?: boolean;
}
export function FormField({
@@ -45,6 +81,7 @@ export function FormField({
type = 'text',
value,
onChange,
onChangeNumber,
placeholder,
disabled = false,
error,
@@ -59,6 +96,14 @@ export function FormField({
max,
step,
htmlFor,
// 새 입력 타입 전용 옵션
showValidation,
maskBack,
allowDecimal,
decimalPlaces,
useComma,
suffix,
showButtons,
}: FormFieldProps) {
const renderInput = () => {
@@ -144,6 +189,73 @@ export function FormField({
/>
);
case 'phone':
return (
<PhoneInput
value={(value as string) || ''}
onChange={(v) => onChange?.(v)}
placeholder={placeholder}
disabled={disabled}
error={!!error}
className={inputClassName}
/>
);
case 'businessNumber':
return (
<BusinessNumberInput
value={(value as string) || ''}
onChange={(v) => onChange?.(v)}
placeholder={placeholder}
disabled={disabled}
error={!!error}
showValidation={showValidation}
className={inputClassName}
/>
);
case 'personalNumber':
return (
<PersonalNumberInput
value={(value as string) || ''}
onChange={(v) => onChange?.(v)}
placeholder={placeholder}
disabled={disabled}
error={!!error}
maskBack={maskBack}
className={inputClassName}
/>
);
case 'currency':
return (
<CurrencyInput
value={value}
onChange={(v) => onChangeNumber?.(v)}
placeholder={placeholder}
disabled={disabled}
error={!!error}
className={inputClassName}
/>
);
case 'quantity':
return (
<QuantityInput
value={value}
onChange={(v) => onChangeNumber?.(v)}
placeholder={placeholder}
disabled={disabled}
error={!!error}
min={min}
max={max}
step={step}
showButtons={showButtons}
suffix={suffix}
className={inputClassName}
/>
);
case 'text':
default:
return (

View File

@@ -1,101 +0,0 @@
'use client';
import { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
interface MobileCardDetail {
label: string;
value: string | ReactNode;
}
interface MobileCardProps {
title: string;
subtitle?: string;
description?: string;
badge?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
badgeClassName?: string;
isSelected?: boolean;
onToggle?: () => void;
onClick?: () => void;
details?: MobileCardDetail[];
actions?: ReactNode;
className?: string;
}
export function MobileCard({
title,
subtitle,
description,
badge,
badgeVariant = 'default',
badgeClassName,
isSelected = false,
onToggle,
onClick,
details = [],
actions,
className,
}: MobileCardProps) {
return (
<Card
className={cn(
'cursor-pointer transition-colors hover:bg-muted/50',
isSelected && 'ring-2 ring-primary',
className
)}
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{onToggle && (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</div>
)}
<div className="flex-1 min-w-0">
{/* 헤더 */}
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<div className="font-medium truncate">{title}</div>
{subtitle && (
<div className="text-sm text-muted-foreground">{subtitle}</div>
)}
</div>
{badge && <Badge variant={badgeVariant} className={badgeClassName}>{badge}</Badge>}
</div>
{/* 설명 */}
{description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{description}
</p>
)}
{/* 상세 정보 */}
{details.length > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
{details.map((detail, index) => (
<div key={index}>
<span className="text-muted-foreground">{detail.label}: </span>
<span className="font-medium">{detail.value}</span>
</div>
))}
</div>
)}
{/* 액션 */}
{actions && (
<div className="mt-3 pt-3 border-t" onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}