feat(WEB): 입력 컴포넌트 공통화 및 UI 개선
- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가 - MobileCard 컴포넌트 통합 (ListMobileCard 제거) - IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈) - IntegratedDetailTemplate 타이틀 중복 수정 - 문서 시스템 컴포넌트 추가 - 헤더 벨 아이콘 포커스 스타일 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
/**
|
||||
* 목록 모바일 카드 컴포넌트
|
||||
*
|
||||
* 모바일 환경에서 사용하는 공통 목록 카드 컴포넌트입니다.
|
||||
* 체크박스, 헤더, 뱃지, 정보 그리드, 액션 버튼을 포함합니다.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ListMobileCard
|
||||
* id="item-1"
|
||||
* isSelected={false}
|
||||
* onToggleSelection={() => handleToggle("item-1")}
|
||||
* onCardClick={() => handleView("item-1")}
|
||||
* headerBadges={[
|
||||
* <Badge key="num">#{1}</Badge>,
|
||||
* <Badge key="code">Q2024-001</Badge>
|
||||
* ]}
|
||||
* title="ABC건설"
|
||||
* statusBadge={<StatusBadge variant="success" label="완료" />}
|
||||
* infoGrid={
|
||||
* <div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
* <InfoField label="현장명" value="강남 현장" />
|
||||
* <InfoField label="담당자" value="김철수" />
|
||||
* </div>
|
||||
* }
|
||||
* actions={isSelected && <ActionButtonGroup actions={actions} isMobile />}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface ListMobileCardProps {
|
||||
/** 아이템 고유 ID */
|
||||
id: string;
|
||||
|
||||
/** 선택 상태 */
|
||||
isSelected: boolean;
|
||||
|
||||
/** 체크박스 토글 핸들러 */
|
||||
onToggleSelection: () => void;
|
||||
|
||||
/** 카드 클릭 핸들러 */
|
||||
onCardClick?: () => void;
|
||||
|
||||
/** 카드 클릭 핸들러 (onCardClick 별칭) */
|
||||
onClick?: () => void;
|
||||
|
||||
/** 체크박스 표시 여부 */
|
||||
showCheckbox?: boolean;
|
||||
|
||||
/** 헤더 영역 뱃지들 (번호, 코드 등) */
|
||||
headerBadges?: ReactNode;
|
||||
|
||||
/** 카드 제목 (주요 정보) */
|
||||
title: string | ReactNode;
|
||||
|
||||
/** 상태 뱃지 (우측 상단) */
|
||||
statusBadge?: ReactNode;
|
||||
|
||||
/** 정보 그리드 영역 */
|
||||
infoGrid: ReactNode;
|
||||
|
||||
/** 액션 버튼 영역 */
|
||||
actions?: ReactNode;
|
||||
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
|
||||
/** 카드 상단 추가 콘텐츠 */
|
||||
topContent?: ReactNode;
|
||||
|
||||
/** 카드 하단 추가 콘텐츠 */
|
||||
bottomContent?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정보 필드 컴포넌트
|
||||
*
|
||||
* 카드 내부의 레이블-값 쌍을 표시합니다.
|
||||
*/
|
||||
export interface InfoFieldProps {
|
||||
label: string;
|
||||
value: string | number | ReactNode;
|
||||
valueClassName?: string;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InfoField({ label, value, valueClassName = "", className = "" }: InfoFieldProps) {
|
||||
return (
|
||||
<div className={`space-y-0.5 ${className}`}>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className={`text-sm font-medium ${valueClassName}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListMobileCard({
|
||||
id,
|
||||
isSelected,
|
||||
onToggleSelection,
|
||||
onCardClick,
|
||||
onClick,
|
||||
showCheckbox = true,
|
||||
headerBadges,
|
||||
title,
|
||||
statusBadge,
|
||||
infoGrid,
|
||||
actions,
|
||||
className = "",
|
||||
topContent,
|
||||
bottomContent
|
||||
}: ListMobileCardProps) {
|
||||
const handleCardClick = onClick || onCardClick;
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg p-5 space-y-4 bg-white dark:bg-card transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50/50'
|
||||
: 'border-gray-200 hover:border-primary/50'
|
||||
} ${className}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 상단 추가 콘텐츠 */}
|
||||
{topContent}
|
||||
|
||||
{/* 헤더: 체크박스 + 뱃지 + 제목 / 우측에 상태 뱃지 */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 헤더 뱃지들 (번호, 코드 등) */}
|
||||
{headerBadges && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
{headerBadges}
|
||||
</div>
|
||||
)}
|
||||
{/* 제목 */}
|
||||
<h3 className="font-semibold text-gray-900 font-bold whitespace-nowrap">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 상단: 상태 뱃지 */}
|
||||
{statusBadge && (
|
||||
<div className="shrink-0">
|
||||
{statusBadge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<Separator className="bg-gray-100" />
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
{infoGrid}
|
||||
|
||||
{/* 액션 버튼 - 선택된 경우만 표시 */}
|
||||
{actions && (
|
||||
<>
|
||||
<Separator className="bg-gray-100" />
|
||||
{actions}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 하단 추가 콘텐츠 */}
|
||||
{bottomContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +1,346 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReactNode, ComponentType } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { ReactNode, ComponentType } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface MobileCardProps {
|
||||
title: string;
|
||||
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
|
||||
/**
|
||||
* 정보 필드 컴포넌트
|
||||
* 카드 내부의 레이블-값 쌍을 표시합니다.
|
||||
*/
|
||||
export interface InfoFieldProps {
|
||||
label: string;
|
||||
value: string | number | ReactNode;
|
||||
valueClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InfoField({
|
||||
label,
|
||||
value,
|
||||
valueClassName = '',
|
||||
className = '',
|
||||
}: InfoFieldProps) {
|
||||
return (
|
||||
<div className={cn('space-y-0.5', className)}>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className={cn('text-sm font-medium', valueClassName)}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 MobileCard Props
|
||||
*
|
||||
* molecules/MobileCard + organisms/ListMobileCard 기능 통합
|
||||
* - 두 가지 사용 방식 모두 지원
|
||||
* - 하위 호환성 유지
|
||||
*/
|
||||
export interface MobileCardProps {
|
||||
// === 공통 (필수) ===
|
||||
title: string | ReactNode;
|
||||
|
||||
// === 공통 (선택) ===
|
||||
id?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
badge?: {
|
||||
className?: string;
|
||||
|
||||
// === 클릭 핸들러 (별칭 지원) ===
|
||||
onClick?: () => void;
|
||||
onCardClick?: () => void; // onClick 별칭
|
||||
|
||||
// === Badge - 두 가지 형식 지원 ===
|
||||
// 방식 1: 단순 (molecules 스타일)
|
||||
badge?: string | { label: string; variant?: BadgeVariant };
|
||||
badgeVariant?: BadgeVariant;
|
||||
badgeClassName?: string;
|
||||
// 방식 2: ReactNode (ListMobileCard 스타일)
|
||||
statusBadge?: ReactNode;
|
||||
headerBadges?: ReactNode;
|
||||
|
||||
// === Checkbox Selection (별칭 지원) ===
|
||||
isSelected?: boolean;
|
||||
onToggle?: () => void; // molecules 스타일
|
||||
onToggleSelection?: () => void; // ListMobileCard 스타일
|
||||
showCheckbox?: boolean; // 기본값: onToggle/onToggleSelection 있으면 true
|
||||
|
||||
// === 정보 표시 - 두 가지 방식 지원 ===
|
||||
// 방식 1: 배열 (molecules 스타일) - 자동 렌더링
|
||||
details?: Array<{
|
||||
label: string;
|
||||
variant?: "default" | "secondary" | "destructive" | "outline";
|
||||
};
|
||||
fields: Array<{
|
||||
value: string | ReactNode;
|
||||
badge?: boolean;
|
||||
badgeVariant?: BadgeVariant;
|
||||
colSpan?: number;
|
||||
}>;
|
||||
fields?: Array<{
|
||||
// details 별칭
|
||||
label: string;
|
||||
value: string;
|
||||
badge?: boolean;
|
||||
badgeVariant?: string;
|
||||
colSpan?: number;
|
||||
}>;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon | ComponentType<any>;
|
||||
variant?: "default" | "outline" | "destructive";
|
||||
}>;
|
||||
onCardClick?: () => void;
|
||||
// 방식 2: ReactNode (ListMobileCard 스타일) - 완전 커스텀
|
||||
infoGrid?: ReactNode;
|
||||
|
||||
// === Actions - 두 가지 방식 지원 ===
|
||||
// 방식 1: ReactNode (권장)
|
||||
// 방식 2: 배열 (자동 버튼 생성)
|
||||
actions?:
|
||||
| ReactNode
|
||||
| Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon | ComponentType<{ className?: string }>;
|
||||
variant?: 'default' | 'outline' | 'destructive';
|
||||
}>;
|
||||
|
||||
// === Layout ===
|
||||
detailsColumns?: 1 | 2 | 3; // details 그리드 컬럼 수 (기본: 2)
|
||||
showSeparator?: boolean; // 구분선 표시 여부 (기본: infoGrid 사용시 true)
|
||||
|
||||
// === 추가 콘텐츠 ===
|
||||
topContent?: ReactNode;
|
||||
bottomContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function MobileCard({
|
||||
// 공통
|
||||
title,
|
||||
id,
|
||||
subtitle,
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
// 클릭
|
||||
onClick,
|
||||
onCardClick,
|
||||
// Badge
|
||||
badge,
|
||||
badgeVariant = 'default',
|
||||
badgeClassName,
|
||||
statusBadge,
|
||||
headerBadges,
|
||||
// Selection
|
||||
isSelected = false,
|
||||
onToggle,
|
||||
onToggleSelection,
|
||||
showCheckbox,
|
||||
// 정보 표시
|
||||
details,
|
||||
fields,
|
||||
infoGrid,
|
||||
// Actions
|
||||
actions,
|
||||
onCardClick
|
||||
// Layout
|
||||
detailsColumns = 2,
|
||||
showSeparator,
|
||||
// 추가 콘텐츠
|
||||
topContent,
|
||||
bottomContent,
|
||||
}: MobileCardProps) {
|
||||
return (
|
||||
<Card className={onCardClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""}>
|
||||
<CardContent className="p-4 cursor-pointer active:bg-muted/50 transition-colors" onClick={onCardClick}>
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2 flex-1">
|
||||
{icon && <div className="mt-1">{icon}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold truncate">{title}</h4>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{badge && (
|
||||
<Badge variant={badge.variant || "default"} className="ml-2">
|
||||
{badge.label}
|
||||
// === 별칭 통합 ===
|
||||
const handleClick = onClick || onCardClick;
|
||||
const handleToggle = onToggle || onToggleSelection;
|
||||
const itemDetails = details || fields || [];
|
||||
const shouldShowCheckbox = showCheckbox ?? !!handleToggle;
|
||||
const shouldShowSeparator = showSeparator ?? (!!infoGrid || !!headerBadges);
|
||||
|
||||
// === Badge 렌더링 ===
|
||||
const renderBadge = () => {
|
||||
// statusBadge 우선 (ReactNode)
|
||||
if (statusBadge) return statusBadge;
|
||||
if (!badge) return null;
|
||||
|
||||
if (typeof badge === 'string') {
|
||||
return (
|
||||
<Badge variant={badgeVariant} className={badgeClassName}>
|
||||
{badge}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={badge.variant || 'default'} className={badgeClassName}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// === Details 자동 렌더링 ===
|
||||
const renderDetails = () => {
|
||||
if (itemDetails.length === 0) return null;
|
||||
|
||||
const gridColsClass =
|
||||
detailsColumns === 1
|
||||
? 'grid-cols-1'
|
||||
: detailsColumns === 3
|
||||
? 'grid-cols-3'
|
||||
: 'grid-cols-2';
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-2 text-sm', gridColsClass)}>
|
||||
{itemDetails.map((detail, index) => (
|
||||
<div
|
||||
key={`${detail.label}-${index}`}
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
detail.colSpan === 2 && 'col-span-2'
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{detail.label}:
|
||||
</span>
|
||||
{detail.badge ? (
|
||||
<Badge variant="outline" className="font-medium">
|
||||
{detail.value}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="font-medium truncate">{detail.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={`${field.label}-${index}`} className="flex justify-between items-center text-sm">
|
||||
<span className="text-muted-foreground">{field.label}</span>
|
||||
{field.badge ? (
|
||||
<Badge variant="outline">{field.value}</Badge>
|
||||
) : (
|
||||
<span className="font-medium">{field.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
// === Actions 렌더링 ===
|
||||
const renderActions = () => {
|
||||
if (!actions) return null;
|
||||
|
||||
{/* Actions */}
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t">
|
||||
{actions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={`${action.label}-${index}`}
|
||||
variant={action.variant || "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
}}
|
||||
className="flex-1 min-w-[calc(50%-0.25rem)]"
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-1" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
// ReactNode인 경우 그대로 렌더링
|
||||
if (!Array.isArray(actions)) {
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{shouldShowSeparator && <Separator className="bg-gray-100 my-3" />}
|
||||
{actions}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Array인 경우 버튼 자동 생성
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{shouldShowSeparator && <Separator className="bg-gray-100 my-3" />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={`${action.label}-${index}`}
|
||||
variant={action.variant || 'outline'}
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
className="flex-1 min-w-[calc(50%-0.25rem)]"
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-1" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// === 정보 영역 렌더링 (infoGrid 우선) ===
|
||||
const renderInfoArea = () => {
|
||||
if (infoGrid) return infoGrid;
|
||||
return renderDetails();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border rounded-lg p-5 space-y-4 bg-white dark:bg-card transition-all',
|
||||
handleClick && 'cursor-pointer',
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50/50'
|
||||
: 'border-gray-200 hover:border-primary/50',
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 상단 추가 콘텐츠 */}
|
||||
{topContent}
|
||||
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
{/* Checkbox */}
|
||||
{shouldShowCheckbox && handleToggle && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={handleToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{icon && <div className="mt-0.5 shrink-0">{icon}</div>}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 헤더 뱃지들 */}
|
||||
{headerBadges && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
{headerBadges}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* 부제목 */}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 상단: 뱃지 */}
|
||||
<div className="shrink-0">{renderBadge()}</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 구분선 (infoGrid/headerBadges 사용시) */}
|
||||
{shouldShowSeparator && (infoGrid || itemDetails.length > 0) && (
|
||||
<Separator className="bg-gray-100" />
|
||||
)}
|
||||
|
||||
{/* 정보 영역 */}
|
||||
{renderInfoArea()}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{renderActions()}
|
||||
|
||||
{/* 하단 추가 콘텐츠 */}
|
||||
{bottomContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 하위 호환성을 위한 별칭 export
|
||||
export { MobileCard as ListMobileCard };
|
||||
|
||||
@@ -4,8 +4,7 @@ export { StatCards } from "./StatCards";
|
||||
export { SearchFilter } from "./SearchFilter";
|
||||
export { DataTable } from "./DataTable";
|
||||
export type { Column, CellType } from "./DataTable";
|
||||
export { MobileCard } from "./MobileCard";
|
||||
export { ListMobileCard, InfoField } from "./ListMobileCard";
|
||||
export type { ListMobileCardProps, InfoFieldProps } from "./ListMobileCard";
|
||||
export { MobileCard, ListMobileCard, InfoField } from "./MobileCard";
|
||||
export type { MobileCardProps, InfoFieldProps } from "./MobileCard";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export { ScreenVersionHistory } from "./ScreenVersionHistory";
|
||||
|
||||
Reference in New Issue
Block a user