feat(WEB): 입력 컴포넌트 공통화 및 UI 개선
- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가 - MobileCard 컴포넌트 통합 (ListMobileCard 제거) - IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈) - IntegratedDetailTemplate 타이틀 중복 수정 - 문서 시스템 컴포넌트 추가 - 헤더 벨 아이콘 포커스 스타일 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user