- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 (104 files) - 생산대시보드/작업지시 모바일 호환성 강화 - 견적서/주문관리 반응형 그리드 적용 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { ReactNode, ComponentType, memo, useState } 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, ChevronDown } from 'lucide-react';
|
|
|
|
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
|
|
|
/**
|
|
* 정보 필드 컴포넌트
|
|
* 카드 내부의 레이블-값 쌍을 표시합니다.
|
|
*/
|
|
export interface InfoFieldProps {
|
|
label: string;
|
|
value: string | number | ReactNode;
|
|
valueClassName?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export const InfoField = memo(function InfoField({
|
|
label,
|
|
value,
|
|
valueClassName = '',
|
|
className = '',
|
|
}: InfoFieldProps) {
|
|
return (
|
|
<div className={cn('space-y-0.5 min-w-0', className)}>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<div className={cn('text-sm font-medium break-words', valueClassName)}>{value}</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 통합 MobileCard Props
|
|
*
|
|
* molecules/MobileCard + organisms/ListMobileCard 기능 통합
|
|
* - 두 가지 사용 방식 모두 지원
|
|
* - 하위 호환성 유지
|
|
*/
|
|
export interface MobileCardProps {
|
|
// === 공통 (필수) ===
|
|
title: string | ReactNode;
|
|
|
|
// === 공통 (선택) ===
|
|
id?: string;
|
|
subtitle?: string;
|
|
description?: string;
|
|
icon?: ReactNode;
|
|
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;
|
|
value: string | ReactNode;
|
|
badge?: boolean;
|
|
badgeVariant?: BadgeVariant;
|
|
colSpan?: number;
|
|
}>;
|
|
fields?: Array<{
|
|
// details 별칭
|
|
label: string;
|
|
value: string;
|
|
badge?: boolean;
|
|
badgeVariant?: string;
|
|
colSpan?: number;
|
|
}>;
|
|
// 방식 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 그리드 컬럼 수 (기본: 1)
|
|
showSeparator?: boolean; // 구분선 표시 여부 (기본: infoGrid 사용시 true)
|
|
|
|
// === 추가 콘텐츠 ===
|
|
topContent?: ReactNode;
|
|
bottomContent?: ReactNode;
|
|
|
|
// === Collapsible ===
|
|
collapsible?: boolean; // 기본값: true (접기/펼치기 자동 적용)
|
|
defaultExpanded?: boolean; // 기본값: false (접힌 상태로 시작)
|
|
}
|
|
|
|
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,
|
|
// Layout
|
|
detailsColumns = 1,
|
|
showSeparator,
|
|
// 추가 콘텐츠
|
|
topContent,
|
|
bottomContent,
|
|
// Collapsible
|
|
collapsible: collapsibleProp = true,
|
|
defaultExpanded = false,
|
|
}: MobileCardProps) {
|
|
// === 별칭 통합 ===
|
|
const handleClick = onClick || onCardClick;
|
|
const handleToggle = onToggle || onToggleSelection;
|
|
const itemDetails = details || fields || [];
|
|
const shouldShowCheckbox = showCheckbox ?? !!handleToggle;
|
|
const shouldShowSeparator = showSeparator ?? (!!infoGrid || !!headerBadges);
|
|
|
|
// === Collapsible 로직 ===
|
|
const hasDetailContent =
|
|
itemDetails.length > 0 || !!infoGrid || !!actions || !!bottomContent || !!description;
|
|
const isCollapsible = collapsibleProp && hasDetailContent;
|
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
|
|
// === 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 min-w-0',
|
|
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>
|
|
);
|
|
};
|
|
|
|
// === Actions 렌더링 ===
|
|
const renderActions = () => {
|
|
if (!actions) return null;
|
|
|
|
// ReactNode인 경우 그대로 렌더링
|
|
if (!Array.isArray(actions)) {
|
|
return (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
{shouldShowSeparator && <Separator className="bg-gray-100 my-3" />}
|
|
{actions}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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();
|
|
};
|
|
|
|
// === 헤더 클릭 핸들러 ===
|
|
const handleHeaderClick = () => {
|
|
if (isCollapsible) {
|
|
setExpanded((prev) => !prev);
|
|
} else {
|
|
handleClick?.();
|
|
}
|
|
};
|
|
|
|
// === 세부 영역 클릭 핸들러 ===
|
|
const handleDetailClick = () => {
|
|
handleClick?.();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'border rounded-lg bg-white dark:bg-card transition-all overflow-hidden',
|
|
isSelected
|
|
? 'border-blue-500 bg-blue-50/50'
|
|
: 'border-gray-200 hover:border-primary/50',
|
|
className
|
|
)}
|
|
>
|
|
{/* 상단 추가 콘텐츠 */}
|
|
{topContent && <div className="px-5 pt-5">{topContent}</div>}
|
|
|
|
{/* 헤더 영역 - 항상 보임 */}
|
|
<div
|
|
className={cn(
|
|
'p-5 space-y-2',
|
|
isCollapsible ? 'cursor-pointer select-none' : handleClick && 'cursor-pointer',
|
|
)}
|
|
onClick={handleHeaderClick}
|
|
>
|
|
{/* 1행: 체크박스 + 뱃지들 + 상태뱃지 (flex-wrap으로 자연 줄바꿈) */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{shouldShowCheckbox && handleToggle && (
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={handleToggle}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="h-5 w-5 shrink-0"
|
|
/>
|
|
)}
|
|
{icon && <div className="shrink-0">{icon}</div>}
|
|
{headerBadges}
|
|
{renderBadge()}
|
|
</div>
|
|
|
|
{/* 3행: 제목 + 쉐브론 */}
|
|
<div className="flex items-start gap-2">
|
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 break-words min-w-0 flex-1">
|
|
{title}
|
|
</h3>
|
|
{isCollapsible && (
|
|
<ChevronDown
|
|
className={cn(
|
|
'w-5 h-5 text-muted-foreground transition-transform duration-200 shrink-0 mt-0.5',
|
|
expanded && 'rotate-180'
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 4행: 부제목 */}
|
|
{subtitle && (
|
|
<p className="text-sm text-muted-foreground break-words">
|
|
{subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 세부 영역 - collapsible 시 접기/펼치기 */}
|
|
{isCollapsible ? (
|
|
<div
|
|
className={cn(
|
|
'grid transition-[grid-template-rows] duration-200 ease-out',
|
|
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
|
)}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'px-5 pb-5 space-y-4',
|
|
handleClick && 'cursor-pointer'
|
|
)}
|
|
onClick={handleDetailClick}
|
|
>
|
|
{/* 설명 */}
|
|
{description && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{description}
|
|
</p>
|
|
)}
|
|
|
|
{/* 구분선 */}
|
|
{shouldShowSeparator && (infoGrid || itemDetails.length > 0) && (
|
|
<Separator className="bg-gray-100" />
|
|
)}
|
|
|
|
{/* 정보 영역 */}
|
|
{renderInfoArea()}
|
|
|
|
{/* 액션 버튼 */}
|
|
{renderActions()}
|
|
|
|
{/* 하단 추가 콘텐츠 */}
|
|
{bottomContent}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* 비-collapsible: 기존과 동일 */
|
|
hasDetailContent && (
|
|
<div
|
|
className={cn(
|
|
'px-5 pb-5 space-y-4',
|
|
handleClick && 'cursor-pointer'
|
|
)}
|
|
onClick={handleClick}
|
|
>
|
|
{/* 설명 */}
|
|
{description && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{description}
|
|
</p>
|
|
)}
|
|
|
|
{/* 구분선 */}
|
|
{shouldShowSeparator && (infoGrid || itemDetails.length > 0) && (
|
|
<Separator className="bg-gray-100" />
|
|
)}
|
|
|
|
{/* 정보 영역 */}
|
|
{renderInfoArea()}
|
|
|
|
{/* 액션 버튼 */}
|
|
{renderActions()}
|
|
|
|
{/* 하단 추가 콘텐츠 */}
|
|
{bottomContent}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 하위 호환성을 위한 별칭 export
|
|
export { MobileCard as ListMobileCard };
|