Files
sam-react-prod/src/components/business/CEODashboard/sections/EnhancedSections.tsx
유병철 3ef9570f3b feat(WEB): API 인프라 리팩토링, CEO 대시보드 현황판 개선 및 문서 시스템 강화
- API: fetch-wrapper/proxy/refresh-token 리팩토링, authenticated-fetch 신규 추가
- CEO 대시보드: EnhancedSections 현황판 기능 개선, dashboard transformers/types 확장
- 문서 시스템: ApprovalLine/DocumentHeader/DocumentToolbar/DocumentViewer 개선
- 작업지시서: 검사보고서/작업일지 문서 컴포넌트 개선 (벤딩/스크린/슬랫)
- 레이아웃: Sidebar/AuthenticatedLayout 수정
- 작업자화면: WorkerScreen 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:16:17 +09:00

539 lines
23 KiB
TypeScript

'use client';
import {
Wallet,
DollarSign,
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle2,
Clock,
Users,
FileText,
ShoppingCart,
Building2,
Calendar,
CreditCard,
Receipt,
Briefcase,
AlertCircle,
ArrowUpRight,
ArrowDownRight,
Banknote,
CircleDollarSign,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { DailyReportData, MonthlyExpenseData, TodayIssueItem, TodayIssueSettings } from '../types';
// ============================================================
// 유틸리티 함수
// ============================================================
const formatBillion = (amount: number): string => {
const billion = amount / 100000000;
if (billion >= 1) {
return billion.toFixed(1) + '억원';
}
const man = amount / 10000;
if (man >= 1) {
return Math.floor(man).toLocaleString() + '만원';
}
return amount.toLocaleString() + '원';
};
const formatUSD = (amount: number): string => {
return '$ ' + new Intl.NumberFormat('en-US').format(amount);
};
// ============================================================
// 강화된 일일 일보 섹션
// ============================================================
interface EnhancedDailyReportSectionProps {
data: DailyReportData;
onClick?: () => void;
}
export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) {
return (
<div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 - 인라인 스타일로 확실하게 적용 */}
<div
style={{ backgroundColor: '#1e293b' }}
className="px-6 py-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
style={{ backgroundColor: 'rgba(255,255,255,0.1)' }}
className="p-2 rounded-lg"
>
<FileText style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm">{data.date}</p>
</div>
</div>
<Badge
style={{ backgroundColor: '#10b981', color: '#ffffff', border: 'none' }}
className="hover:opacity-90"
>
</Badge>
</div>
</div>
{/* 카드 내용 - 흰색 배경 */}
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
{/* 카드 그리드 */}
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 카드 1: 현금성 자산 */}
<div
style={{ backgroundColor: '#ecfdf5', borderColor: '#a7f3d0' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
onClick={onClick}
>
<div className="flex items-center gap-2 mb-3">
<div style={{ backgroundColor: '#10b981' }} className="p-1.5 rounded-lg">
<Wallet style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#047857' }} className="text-sm font-medium">
{data.cards[0]?.label || '현금성 자산 합계'}
</span>
</div>
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[0]?.amount || 0)}
</span>
{data.cards[0]?.changeRate && (
<span
style={{ color: data.cards[0].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[0].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[0].changeRate}
</span>
)}
</div>
</div>
{/* 카드 2: 외국환 */}
<div
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
onClick={onClick}
>
<div className="flex items-center gap-2 mb-3">
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
<DollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#1d4ed8' }} className="text-sm font-medium">
{data.cards[1]?.label || '외국환(USD) 합계'}
</span>
</div>
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{data.cards[1]?.currency === 'USD'
? formatUSD(data.cards[1]?.amount || 0)
: formatBillion(data.cards[1]?.amount || 0)}
</span>
{data.cards[1]?.changeRate && (
<span
style={{ color: data.cards[1].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[1].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[1].changeRate}
</span>
)}
</div>
</div>
{/* 카드 3: 입금 */}
<div
style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
onClick={onClick}
>
<div className="flex items-center gap-2 mb-3">
<div style={{ backgroundColor: '#22c55e' }} className="p-1.5 rounded-lg">
<TrendingUp style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#15803d' }} className="text-sm font-medium">
{data.cards[2]?.label || '입금 합계'}
</span>
</div>
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[2]?.amount || 0)}
</span>
{data.cards[2]?.changeRate && (
<span
style={{ color: data.cards[2].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[2].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[2].changeRate}
</span>
)}
</div>
</div>
{/* 카드 4: 출금 */}
<div
style={{ backgroundColor: '#fff1f2', borderColor: '#fecdd3' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border h-[110px] flex flex-col"
onClick={onClick}
>
<div className="flex items-center gap-2 mb-3">
<div style={{ backgroundColor: '#f43f5e' }} className="p-1.5 rounded-lg">
<TrendingDown style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#be123c' }} className="text-sm font-medium">
{data.cards[3]?.label || '출금 합계'}
</span>
</div>
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[3]?.amount || 0)}
</span>
{data.cards[3]?.changeRate && (
<span
style={{ color: data.cards[3].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[3].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[3].changeRate}
</span>
)}
</div>
</div>
</div>
{/* 체크포인트 */}
{data.checkPoints.length > 0 && (
<div className="space-y-2">
<p style={{ color: '#64748b' }} className="text-xs font-medium uppercase tracking-wider mb-3"> </p>
{data.checkPoints.map((cp, idx) => (
<div
key={cp.id}
style={{
backgroundColor: idx === 0 ? '#fffbeb' : '#f8fafc',
borderColor: idx === 0 ? '#fde68a' : '#e2e8f0'
}}
className="flex items-start gap-3 p-3 rounded-lg border"
>
<div
style={{ backgroundColor: idx === 0 ? '#fef3c7' : '#f1f5f9' }}
className="p-1 rounded-full shrink-0"
>
{idx === 0 ? (
<AlertTriangle style={{ color: '#d97706' }} className="h-4 w-4" />
) : (
<CheckCircle2 style={{ color: '#16a34a' }} className="h-4 w-4" />
)}
</div>
<p style={{ color: '#475569' }} className="text-sm flex-1">{cp.message}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ============================================================
// 강화된 현황판 섹션
// ============================================================
// 라벨 → 설정키 매핑
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'수주': 'orders',
'채권 추심': 'debtCollection',
'안전 재고': 'safetyStock',
'세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor',
'연차': 'annualLeave',
'지각': 'lateness',
'결근': 'absence',
'발주': 'purchase',
'결재 요청': 'approvalRequest',
};
// 라벨별 스타일 매핑 (인라인 스타일용)
const ITEM_STYLES: Record<string, { bg: string; border: string; iconBg: string; labelColor: string; Icon: React.ComponentType<{ style?: React.CSSProperties; className?: string }> }> = {
'수주': { bg: '#eff6ff', border: '#bfdbfe', iconBg: '#3b82f6', labelColor: '#1d4ed8', Icon: ShoppingCart },
'채권 추심': { bg: '#fef2f2', border: '#fecaca', iconBg: '#ef4444', labelColor: '#dc2626', Icon: AlertCircle },
'안전 재고': { bg: '#fff7ed', border: '#fed7aa', iconBg: '#f97316', labelColor: '#ea580c', Icon: Receipt },
'세금 신고': { bg: '#faf5ff', border: '#e9d5ff', iconBg: '#a855f7', labelColor: '#9333ea', Icon: FileText },
'신규 업체 등록': { bg: '#ecfdf5', border: '#a7f3d0', iconBg: '#10b981', labelColor: '#059669', Icon: Building2 },
'연차': { bg: '#ecfeff', border: '#a5f3fc', iconBg: '#06b6d4', labelColor: '#0891b2', Icon: Calendar },
'지각': { bg: '#fffbeb', border: '#fde68a', iconBg: '#f59e0b', labelColor: '#d97706', Icon: Clock },
'결근': { bg: '#fff1f2', border: '#fecdd3', iconBg: '#f43f5e', labelColor: '#e11d48', Icon: Users },
'발주': { bg: '#eef2ff', border: '#c7d2fe', iconBg: '#6366f1', labelColor: '#4f46e5', Icon: Briefcase },
'결재 요청': { bg: '#fdf2f8', border: '#fbcfe8', iconBg: '#ec4899', labelColor: '#db2777', Icon: CheckCircle2 },
};
const DEFAULT_STYLE = { bg: '#f8fafc', border: '#e2e8f0', iconBg: '#64748b', labelColor: '#475569', Icon: FileText };
interface EnhancedStatusBoardSectionProps {
items: TodayIssueItem[];
itemSettings?: TodayIssueSettings;
}
export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStatusBoardSectionProps) {
const router = useRouter();
const handleItemClick = (path: string) => {
router.push(path);
};
// 설정에 따라 항목 필터링
const filteredItems = itemSettings
? items.filter((item) => {
const settingKey = LABEL_TO_SETTING_KEY[item.label];
return settingKey ? itemSettings[settingKey] : true;
})
: items;
return (
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<div className="flex items-center gap-3 mb-6">
<div style={{ backgroundColor: '#f59e0b' }} className="w-1.5 h-6 rounded-full" />
<h3 style={{ color: '#0f172a' }} className="text-lg font-semibold"></h3>
<Badge
style={{ backgroundColor: '#fef3c7', color: '#b45309', borderColor: '#fde68a' }}
>
{filteredItems.length}
</Badge>
</div>
{/* 카드 그리드 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{filteredItems.map((item) => {
const isHighlighted = item.isHighlighted;
const style = ITEM_STYLES[item.label] || DEFAULT_STYLE;
const Icon = style.Icon;
// 긴급 항목은 빨간 배경
const bgColor = isHighlighted ? '#ef4444' : style.bg;
const borderColor = isHighlighted ? '#ef4444' : style.border;
const iconBgColor = isHighlighted ? 'rgba(255,255,255,0.2)' : style.iconBg;
const labelColor = isHighlighted ? '#ffffff' : style.labelColor;
const countColor = isHighlighted ? '#ffffff' : '#0f172a';
const subColor = isHighlighted ? '#fecaca' : '#64748b';
return (
<div
key={item.id}
style={{ backgroundColor: bgColor, borderColor: borderColor }}
className="relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md h-[110px] flex flex-col"
onClick={() => handleItemClick(item.path)}
>
{/* 아이콘 + 라벨 */}
<div className="flex items-center gap-2 mb-3 min-w-0">
<div style={{ backgroundColor: iconBgColor }} className="p-1.5 rounded-lg shrink-0">
<Icon style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: labelColor }} className="text-sm font-medium truncate flex-1 min-w-0">
{item.label}
</span>
</div>
{/* 숫자 */}
<div style={{ color: countColor }} className="text-2xl font-bold">
{typeof item.count === 'number' ? `${item.count}` : item.count}
</div>
{/* 부가 정보 */}
{item.subLabel && (
<span style={{ color: subColor }} className="text-xs mt-auto">
{item.subLabel}
</span>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
// ============================================================
// 강화된 당월 예상 지출 섹션
// ============================================================
interface EnhancedMonthlyExpenseSectionProps {
data: MonthlyExpenseData;
onCardClick?: (cardId: string) => void;
}
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
return (
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<div className="flex items-center gap-3 mb-6">
<div style={{ backgroundColor: '#f97316' }} className="w-1.5 h-6 rounded-full" />
<h3 style={{ color: '#0f172a' }} className="text-lg font-semibold"> </h3>
<Badge
style={{ backgroundColor: '#ffedd5', color: '#c2410c', borderColor: '#fed7aa' }}
>
+15%
</Badge>
</div>
{/* 카드 그리드 */}
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 카드 1: 매입 */}
<div
style={{ backgroundColor: '#f5f3ff', borderColor: '#ddd6fe' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
onClick={() => onCardClick?.(data.cards[0]?.id || 'me1')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#8b5cf6' }} className="p-1.5 rounded-lg">
<Receipt style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#6d28d9' }} className="text-sm font-medium">
{data.cards[0]?.label || '매입'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[0]?.amount || 0)}
</div>
{data.cards[0]?.previousLabel && (
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<TrendingUp className="h-3 w-3" />
{data.cards[0].previousLabel}
</div>
)}
</div>
{/* 카드 2: 카드 */}
<div
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
onClick={() => onCardClick?.(data.cards[1]?.id || 'me2')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
<CreditCard style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#1d4ed8' }} className="text-sm font-medium">
{data.cards[1]?.label || '카드'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[1]?.amount || 0)}
</div>
{data.cards[1]?.previousLabel && (
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<TrendingUp className="h-3 w-3" />
{data.cards[1].previousLabel}
</div>
)}
</div>
{/* 카드 3: 발행어음 */}
<div
style={{ backgroundColor: '#fffbeb', borderColor: '#fde68a' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
onClick={() => onCardClick?.(data.cards[2]?.id || 'me3')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
<Banknote style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#b45309' }} className="text-sm font-medium">
{data.cards[2]?.label || '발행어음'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[2]?.amount || 0)}
</div>
{data.cards[2]?.previousLabel && (
<div style={{ backgroundColor: '#dcfce7', color: '#16a34a' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<TrendingUp className="h-3 w-3" />
{data.cards[2].previousLabel}
</div>
)}
</div>
{/* 카드 4: 총 예상 지출 합계 (강조 - 인라인 스타일) */}
<div
style={{ backgroundColor: '#f43f5e', borderColor: '#f43f5e' }}
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border h-[130px] flex flex-col"
onClick={() => onCardClick?.(data.cards[3]?.id || 'me4')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: 'rgba(255,255,255,0.2)' }} className="p-1.5 rounded-lg">
<CircleDollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
</div>
<span style={{ color: '#ffe4e6' }} className="text-sm font-medium">
</span>
</div>
<div style={{ color: '#ffffff' }} className="text-2xl font-bold">
{formatBillion(totalAmount)}
</div>
<div style={{ backgroundColor: 'rgba(255,255,255,0.2)', color: '#ffffff' }} className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit">
<TrendingUp className="h-3 w-3" />
+10.5%
</div>
</div>
</div>
{/* 체크포인트 */}
{data.checkPoints.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{data.checkPoints.map((cp, idx) => {
const colors = [
{ bg: '#fef2f2', border: '#fecaca', iconColor: '#ef4444' },
{ bg: '#fffbeb', border: '#fde68a', iconColor: '#f59e0b' },
{ bg: '#f0fdf4', border: '#bbf7d0', iconColor: '#22c55e' },
];
const color = colors[idx] || colors[2];
return (
<div
key={cp.id}
style={{ backgroundColor: color.bg, borderColor: color.border }}
className="p-3 rounded-lg border flex items-start gap-2"
>
{idx === 0 ? (
<AlertTriangle style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
) : idx === 1 ? (
<AlertCircle style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
) : (
<CheckCircle2 style={{ color: color.iconColor }} className="h-4 w-4 mt-0.5 shrink-0" />
)}
<p style={{ color: '#475569' }} className="text-sm">{cp.message}</p>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}