차량 관리 (신규): - VehicleList/VehicleDetail: 차량 목록/상세 - ForkliftList/ForkliftDetail: 지게차 목록/상세 - VehicleLogList/VehicleLogDetail: 운행일지 목록/상세 - 관련 페이지 라우트 추가 (/vehicle-management/*) CEO 대시보드: - Enhanced 섹션 컴포넌트 적용 (아이콘 + 컬러 테마) - EnhancedStatusBoardSection, EnhancedDailyReportSection, EnhancedMonthlyExpenseSection - TodayIssueSection 개선 IntegratedDetailTemplate: - FieldInput, FieldRenderer 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
Check,
|
|
AlertTriangle,
|
|
Info,
|
|
AlertCircle,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import type { CheckPoint, CheckPointType, AmountCard, HighlightColor } from './types';
|
|
|
|
// 섹션별 컬러 테마 타입
|
|
export type SectionColorTheme = 'blue' | 'purple' | 'orange' | 'green' | 'red' | 'amber' | 'cyan' | 'pink' | 'emerald' | 'indigo';
|
|
|
|
// 컬러 테마별 스타일
|
|
export const SECTION_THEME_STYLES: Record<SectionColorTheme, { bg: string; border: string; iconBg: string; labelColor: string; accentColor: string }> = {
|
|
blue: { bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', accentColor: '#3b82f6' },
|
|
purple: { bg: '#faf5ff', border: '#e9d5ff', iconBg: '#a855f7', labelColor: '#7c3aed', accentColor: '#a855f7' },
|
|
orange: { bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#ea580c', accentColor: '#f97316' },
|
|
green: { bg: '#f0fdf4', border: '#bbf7d0', iconBg: '#22c55e', labelColor: '#16a34a', accentColor: '#22c55e' },
|
|
red: { bg: '#fef2f2', border: '#fecaca', iconBg: '#ef4444', labelColor: '#dc2626', accentColor: '#ef4444' },
|
|
amber: { bg: '#fffbeb', border: '#fde68a', iconBg: '#f59e0b', labelColor: '#d97706', accentColor: '#f59e0b' },
|
|
cyan: { bg: '#ecfeff', border: '#a5f3fc', iconBg: '#06b6d4', labelColor: '#0891b2', accentColor: '#06b6d4' },
|
|
pink: { bg: '#fdf2f8', border: '#fbcfe8', iconBg: '#ec4899', labelColor: '#db2777', accentColor: '#ec4899' },
|
|
emerald: { bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#059669', accentColor: '#10b981' },
|
|
indigo: { bg: '#eef2ff', border: '#c7d2fe', iconBg: '#6366f1', labelColor: '#4f46e5', accentColor: '#6366f1' },
|
|
};
|
|
|
|
/**
|
|
* 금액 포맷 함수
|
|
*/
|
|
export const formatAmount = (amount: number, showUnit = true): string => {
|
|
const formatted = new Intl.NumberFormat('ko-KR').format(amount);
|
|
return showUnit ? formatted + '원' : formatted;
|
|
};
|
|
|
|
/**
|
|
* 억 단위 포맷 함수
|
|
*/
|
|
export const formatBillion = (amount: number): string => {
|
|
const billion = amount / 100000000;
|
|
if (billion >= 1) {
|
|
return billion.toFixed(1) + '억원';
|
|
}
|
|
return formatAmount(amount);
|
|
};
|
|
|
|
/**
|
|
* USD 달러 포맷 함수
|
|
*/
|
|
export const formatUSD = (amount: number): string => {
|
|
return '$ ' + new Intl.NumberFormat('en-US').format(amount);
|
|
};
|
|
|
|
/**
|
|
* 하이라이트 색상 클래스 반환
|
|
*/
|
|
export const getHighlightColorClass = (color: HighlightColor): string => {
|
|
switch (color) {
|
|
case 'red':
|
|
return 'text-red-600';
|
|
case 'green':
|
|
return 'text-green-600';
|
|
case 'blue':
|
|
return 'text-blue-600';
|
|
default:
|
|
return 'text-red-600';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 체크포인트 아이콘
|
|
*/
|
|
export const CheckPointIcon = ({ type }: { type: CheckPointType }) => {
|
|
switch (type) {
|
|
case 'success':
|
|
return <Check className="h-4 w-4 text-green-600 flex-shrink-0" />;
|
|
case 'warning':
|
|
return <AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0" />;
|
|
case 'error':
|
|
return <AlertCircle className="h-4 w-4 text-red-600 flex-shrink-0" />;
|
|
case 'info':
|
|
default:
|
|
return <Info className="h-4 w-4 text-blue-600 flex-shrink-0" />;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 메시지 내 하이라이트 텍스트를 찾아서 색상 적용하는 함수
|
|
*/
|
|
const renderMessageWithHighlights = (
|
|
message: string,
|
|
highlights?: { text: string; color: HighlightColor }[]
|
|
): React.ReactNode => {
|
|
if (!highlights || highlights.length === 0) {
|
|
return message;
|
|
}
|
|
|
|
// 하이라이트를 적용할 패턴들을 정규식으로 만듦
|
|
const parts: React.ReactNode[] = [];
|
|
let remainingText = message;
|
|
let keyIndex = 0;
|
|
|
|
// 메시지에서 하이라이트 텍스트를 찾아 분리
|
|
highlights.forEach((highlight) => {
|
|
const index = remainingText.indexOf(highlight.text);
|
|
if (index !== -1) {
|
|
// 하이라이트 앞 텍스트
|
|
if (index > 0) {
|
|
parts.push(remainingText.substring(0, index));
|
|
}
|
|
// 하이라이트 텍스트 (색상 적용)
|
|
parts.push(
|
|
<span key={keyIndex++} className={cn('font-medium', getHighlightColorClass(highlight.color))}>
|
|
{highlight.text}
|
|
</span>
|
|
);
|
|
remainingText = remainingText.substring(index + highlight.text.length);
|
|
}
|
|
});
|
|
|
|
// 남은 텍스트 추가
|
|
if (remainingText) {
|
|
parts.push(remainingText);
|
|
}
|
|
|
|
return parts.length > 0 ? parts : message;
|
|
};
|
|
|
|
/**
|
|
* 체크포인트 아이템 컴포넌트
|
|
*/
|
|
export const CheckPointItem = ({ checkpoint }: { checkpoint: CheckPoint }) => {
|
|
return (
|
|
<div className="flex items-start gap-2 py-1">
|
|
<div className="mt-0.5">
|
|
<CheckPointIcon type={checkpoint.type} />
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{checkpoint.highlights && checkpoint.highlights.length > 0 ? (
|
|
renderMessageWithHighlights(checkpoint.message, checkpoint.highlights)
|
|
) : (
|
|
<>
|
|
{checkpoint.message}
|
|
{checkpoint.highlight && (
|
|
<span className="text-red-600 font-medium"> {checkpoint.highlight}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 섹션 타이틀 컴포넌트 (아이콘 지원 버전)
|
|
*/
|
|
export const SectionTitle = ({
|
|
title,
|
|
badge,
|
|
actionButton,
|
|
icon: Icon,
|
|
colorTheme,
|
|
}: {
|
|
title: string;
|
|
badge?: 'warning' | 'success' | 'info' | 'error';
|
|
actionButton?: { label: string; onClick: () => void };
|
|
icon?: LucideIcon;
|
|
colorTheme?: SectionColorTheme;
|
|
}) => {
|
|
const themeStyle = colorTheme ? SECTION_THEME_STYLES[colorTheme] : null;
|
|
|
|
return (
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
{Icon && themeStyle ? (
|
|
<div
|
|
className="p-1.5 rounded-lg"
|
|
style={{ backgroundColor: themeStyle.iconBg }}
|
|
>
|
|
<Icon className="h-4 w-4 text-white" />
|
|
</div>
|
|
) : (
|
|
<div className={cn(
|
|
"w-1.5 h-6 rounded-full",
|
|
badge === 'warning' ? 'bg-amber-500' :
|
|
badge === 'success' ? 'bg-green-500' :
|
|
badge === 'info' ? 'bg-blue-500' : 'bg-red-500'
|
|
)} />
|
|
)}
|
|
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
|
</div>
|
|
{actionButton && (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
onClick={actionButton.onClick}
|
|
>
|
|
{actionButton.label}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 금액 카드 컴포넌트 (아이콘/컬러 테마 지원)
|
|
*/
|
|
export const AmountCardItem = ({
|
|
card,
|
|
onClick,
|
|
className,
|
|
icon: Icon,
|
|
colorTheme,
|
|
showTrend,
|
|
trendValue,
|
|
trendDirection,
|
|
showCountBadge,
|
|
}: {
|
|
card: AmountCard;
|
|
onClick?: () => void;
|
|
className?: string;
|
|
icon?: LucideIcon;
|
|
colorTheme?: SectionColorTheme;
|
|
showTrend?: boolean;
|
|
trendValue?: string;
|
|
trendDirection?: 'up' | 'down';
|
|
showCountBadge?: boolean;
|
|
}) => {
|
|
const themeStyle = colorTheme ? SECTION_THEME_STYLES[colorTheme] : null;
|
|
|
|
// 금액 포맷 함수 (통화에 따라 분기)
|
|
const formatCardAmount = (amount: number): string => {
|
|
if (card.unit === '건') {
|
|
return `${amount}건`;
|
|
}
|
|
if (card.currency === 'USD') {
|
|
return formatUSD(amount);
|
|
}
|
|
return formatBillion(amount);
|
|
};
|
|
|
|
// 테마 적용 시 스타일
|
|
const cardStyle = themeStyle && !card.isHighlighted ? {
|
|
backgroundColor: themeStyle.bg,
|
|
borderColor: themeStyle.border,
|
|
} : undefined;
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
'relative',
|
|
onClick && 'cursor-pointer hover:shadow-lg transition-all hover:scale-[1.02]',
|
|
card.isHighlighted && 'border-red-300 bg-red-50',
|
|
!themeStyle && !card.isHighlighted && 'border',
|
|
className
|
|
)}
|
|
style={cardStyle}
|
|
>
|
|
{/* 건수 뱃지 (오른쪽 상단) */}
|
|
{showCountBadge && card.subLabel && (
|
|
<div
|
|
className="absolute top-3 right-3 px-2 py-0.5 rounded-full text-xs font-bold"
|
|
style={{
|
|
backgroundColor: themeStyle ? themeStyle.iconBg : '#ef4444',
|
|
color: '#ffffff'
|
|
}}
|
|
>
|
|
{card.subLabel}
|
|
</div>
|
|
)}
|
|
|
|
<CardContent
|
|
className={cn(
|
|
"p-4 flex flex-col",
|
|
card.subItems && card.subItems.length > 0 ? "min-h-[140px]" : showTrend && trendValue ? "min-h-[130px]" : "min-h-[110px]"
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
{/* 아이콘 + 라벨 */}
|
|
<div className="flex items-center gap-2 mb-3">
|
|
{Icon && themeStyle && !card.isHighlighted && (
|
|
<div
|
|
className="p-1.5 rounded-lg shrink-0"
|
|
style={{ backgroundColor: themeStyle.iconBg }}
|
|
>
|
|
<Icon className="h-4 w-4 text-white" />
|
|
</div>
|
|
)}
|
|
<p
|
|
className={cn(
|
|
"text-sm font-medium truncate",
|
|
card.isHighlighted ? 'text-red-600' : 'text-muted-foreground'
|
|
)}
|
|
style={themeStyle && !card.isHighlighted ? { color: themeStyle.labelColor } : undefined}
|
|
>
|
|
{card.label}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 금액 */}
|
|
<p className={cn(
|
|
"text-2xl font-bold",
|
|
card.isHighlighted && 'text-red-600'
|
|
)}>
|
|
{formatCardAmount(card.amount)}
|
|
</p>
|
|
|
|
{/* 트렌드 표시 (pill 형태, 금액 아래에 배치) */}
|
|
{showTrend && trendValue && (
|
|
<div
|
|
className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit"
|
|
style={{
|
|
backgroundColor: trendDirection === 'up' ? '#dcfce7' : '#fee2e2',
|
|
color: trendDirection === 'up' ? '#16a34a' : '#dc2626'
|
|
}}
|
|
>
|
|
{trendDirection === 'up' ? (
|
|
<TrendingUp className="h-3 w-3 shrink-0" />
|
|
) : (
|
|
<TrendingDown className="h-3 w-3 shrink-0" />
|
|
)}
|
|
<span>{trendValue}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* subItems 배열이 있는 경우 (매출, 입금 등 다중 서브 정보) */}
|
|
{!showTrend && card.subItems && card.subItems.length > 0 && (
|
|
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
|
|
{card.subItems.map((item, idx) => (
|
|
<div key={idx} className="flex justify-between gap-2">
|
|
<span className="shrink-0">{item.label}</span>
|
|
<span className="text-right">{typeof item.value === 'number' ? formatAmount(item.value, false) : item.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 기존 단일 서브 정보 */}
|
|
{!showTrend && !card.subItems && (card.subAmount !== undefined || card.previousAmount !== undefined || card.subLabel || card.previousLabel) && (
|
|
<div className="flex gap-4 mt-auto text-xs text-muted-foreground">
|
|
{card.subAmount !== undefined && card.subLabel && (
|
|
<span>{card.subLabel}: {card.unit === '건' ? `${card.subAmount}건` : formatAmount(card.subAmount)}</span>
|
|
)}
|
|
{card.previousLabel && (
|
|
<span className="flex items-center gap-1">
|
|
<TrendingUp className="h-3 w-3 text-green-600" />
|
|
{card.previousLabel}
|
|
</span>
|
|
)}
|
|
{card.subLabel && card.subAmount === undefined && !card.previousLabel && (
|
|
<span>{card.subLabel}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 이슈 카드 그리드 아이템 (StatCards 스타일)
|
|
*/
|
|
export const IssueCardItem = ({
|
|
label,
|
|
count,
|
|
subLabel,
|
|
isHighlighted,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
count: number | string;
|
|
subLabel?: string;
|
|
isHighlighted?: boolean;
|
|
onClick?: () => void;
|
|
}) => {
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
'cursor-pointer hover:shadow-md transition-shadow',
|
|
isHighlighted && 'bg-red-500 border-red-500 hover:bg-red-600'
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<CardContent className="p-4 md:p-6">
|
|
<p className={cn(
|
|
"text-sm font-medium",
|
|
isHighlighted ? 'text-white' : 'text-muted-foreground'
|
|
)}>
|
|
{label}
|
|
</p>
|
|
<p className={cn(
|
|
"mt-2",
|
|
typeof count === 'number'
|
|
? "text-lg xs:text-xl md:text-2xl lg:text-3xl font-bold"
|
|
: "text-base xs:text-lg md:text-xl lg:text-2xl font-medium",
|
|
isHighlighted ? 'text-white' : 'text-foreground'
|
|
)}>
|
|
{typeof count === 'number' ? `${count}건` : count}
|
|
</p>
|
|
{subLabel && (
|
|
<p className={cn(
|
|
"text-sm font-medium mt-1",
|
|
isHighlighted ? 'text-white/90' : 'text-muted-foreground'
|
|
)}>
|
|
{subLabel}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}; |