반응형
/utils/themeConfig.ts
// Theme Types
export type ThemeType = 'government' | 'enterprise' | 'university' | 'global';
// Theme Configuration
export interface ThemeConfig {
id: ThemeType;
name: string;
colors: {
primary: string;
secondary: string;
accent: string;
background: string;
surface: string;
text: string;
textSecondary: string;
border: string;
success: string;
warning: string;
error: string;
info: string;
};
gradients: {
primary: string;
secondary: string;
header: string;
sidebar: string;
};
typography: {
fontFamily: string;
heading: string;
};
effects: {
shadow: string;
shadowHover: string;
blur: string;
};
branding: {
logo?: string;
favicon?: string;
headerStyle: 'professional' | 'modern' | 'gradient' | 'minimal';
};
}
// Theme Presets
export const themePresets: Record<ThemeType, ThemeConfig> = {
government: {
id: 'government',
name: 'Government',
colors: {
primary: '#1e40af', // Deep Blue
secondary: '#0369a1', // Sky Blue
accent: '#dc2626', // Red accent
background: '#f8fafc',
surface: '#ffffff',
text: '#1e293b',
textSecondary: '#64748b',
border: '#cbd5e1',
success: '#059669',
warning: '#d97706',
error: '#dc2626',
info: '#0284c7',
},
gradients: {
primary: 'linear-gradient(135deg, #1e40af 0%, #0369a1 100%)',
secondary: 'linear-gradient(135deg, #0369a1 0%, #0ea5e9 100%)',
header: 'linear-gradient(90deg, #1e3a8a 0%, #1e40af 50%, #0369a1 100%)',
sidebar: 'linear-gradient(180deg, #1e40af 0%, #1e3a8a 100%)',
},
typography: {
fontFamily: "'Inter', 'Noto Sans KR', sans-serif",
heading: "'Roboto', 'Noto Sans KR', sans-serif",
},
effects: {
shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
shadowHover: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
blur: 'backdrop-filter: blur(8px)',
},
branding: {
headerStyle: 'professional',
},
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
colors: {
primary: '#0f172a', // Slate Dark
secondary: '#334155', // Slate
accent: '#f59e0b', // Amber
background: '#f1f5f9',
surface: '#ffffff',
text: '#0f172a',
textSecondary: '#475569',
border: '#e2e8f0',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6',
},
gradients: {
primary: 'linear-gradient(135deg, #0f172a 0%, #334155 100%)',
secondary: 'linear-gradient(135deg, #334155 0%, #475569 100%)',
header: 'linear-gradient(90deg, #020617 0%, #0f172a 50%, #1e293b 100%)',
sidebar: 'linear-gradient(180deg, #0f172a 0%, #020617 100%)',
},
typography: {
fontFamily: "'Poppins', 'Noto Sans KR', sans-serif",
heading: "'Montserrat', 'Noto Sans KR', sans-serif",
},
effects: {
shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.15), 0 2px 4px -1px rgba(0, 0, 0, 0.1)',
shadowHover: '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1)',
blur: 'backdrop-filter: blur(10px)',
},
branding: {
headerStyle: 'modern',
},
},
university: {
id: 'university',
name: 'University',
colors: {
primary: '#7c3aed', // Violet
secondary: '#a855f7', // Purple
accent: '#ec4899', // Pink
background: '#faf5ff',
surface: '#ffffff',
text: '#3b0764',
textSecondary: '#6b21a8',
border: '#e9d5ff',
success: '#22c55e',
warning: '#eab308',
error: '#f43f5e',
info: '#8b5cf6',
},
gradients: {
primary: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
secondary: 'linear-gradient(135deg, #a855f7 0%, #ec4899 100%)',
header: 'linear-gradient(90deg, #5b21b6 0%, #7c3aed 50%, #a855f7 100%)',
sidebar: 'linear-gradient(180deg, #7c3aed 0%, #6d28d9 100%)',
},
typography: {
fontFamily: "'Nunito', 'Noto Sans KR', sans-serif",
heading: "'Quicksand', 'Noto Sans KR', sans-serif",
},
effects: {
shadow: '0 4px 6px -1px rgba(124, 58, 237, 0.1), 0 2px 4px -1px rgba(124, 58, 237, 0.06)',
shadowHover: '0 10px 15px -3px rgba(124, 58, 237, 0.2), 0 4px 6px -2px rgba(124, 58, 237, 0.1)',
blur: 'backdrop-filter: blur(12px)',
},
branding: {
headerStyle: 'gradient',
},
},
global: {
id: 'global',
name: 'Global',
colors: {
primary: '#0891b2', // Cyan
secondary: '#06b6d4', // Cyan Light
accent: '#f97316', // Orange
background: '#f0fdfa',
surface: '#ffffff',
text: '#134e4a',
textSecondary: '#0f766e',
border: '#99f6e4',
success: '#14b8a6',
warning: '#fb923c',
error: '#f43f5e',
info: '#0ea5e9',
},
gradients: {
primary: 'linear-gradient(135deg, #0891b2 0%, #06b6d4 100%)',
secondary: 'linear-gradient(135deg, #06b6d4 0%, #22d3ee 100%)',
header: 'linear-gradient(90deg, #155e75 0%, #0891b2 50%, #06b6d4 100%)',
sidebar: 'linear-gradient(180deg, #0891b2 0%, #0e7490 100%)',
},
typography: {
fontFamily: "'Open Sans', 'Noto Sans KR', sans-serif",
heading: "'Raleway', 'Noto Sans KR', sans-serif",
},
effects: {
shadow: '0 4px 6px -1px rgba(8, 145, 178, 0.1), 0 2px 4px -1px rgba(8, 145, 178, 0.06)',
shadowHover: '0 10px 15px -3px rgba(8, 145, 178, 0.2), 0 4px 6px -2px rgba(8, 145, 178, 0.1)',
blur: 'backdrop-filter: blur(10px)',
},
branding: {
headerStyle: 'minimal',
},
},
};
// Helper function to get theme
export const getTheme = (themeType: ThemeType): ThemeConfig => {
return themePresets[themeType];
};
// Helper function to apply theme to CSS variables
export const applyTheme = (theme: ThemeConfig) => {
const root = document.documentElement;
// Apply colors
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value);
});
// Apply font family
root.style.setProperty('--font-family', theme.typography.fontFamily);
root.style.setProperty('--font-heading', theme.typography.heading);
};
/features/theme/themeSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { ThemeType, ThemeConfig } from '../../utils/themeConfig';
import { themePresets } from '../../utils/themeConfig';
interface ThemeState {
currentTheme: ThemeType;
config: ThemeConfig;
}
// Load theme from localStorage
const loadTheme = (): ThemeType => {
const saved = localStorage.getItem('appTheme');
return (saved as ThemeType) || 'enterprise';
};
const initialState: ThemeState = {
currentTheme: loadTheme(),
config: themePresets[loadTheme()],
};
const themeSlice = createSlice({
name: 'theme',
initialState,
reducers: {
setTheme: (state, action: PayloadAction<ThemeType>) => {
state.currentTheme = action.payload;
state.config = themePresets[action.payload];
localStorage.setItem('appTheme', action.payload);
},
},
});
export const { setTheme } = themeSlice.actions;
export default themeSlice.reducer;
/components/Header/Header.tsx
import { useState } from 'react';
import { FaBars, FaBuilding, FaUser, FaPalette } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { toggleSidebar } from '../../features/ui/uiSlice';
import AffiliationModal from '../Modal/AffiliationModal';
import UserSettingsModal from '../Modal/UserSettingsModal';
import ThemeSelector from '../ThemeSelector/ThemeSelector';
const Header = () => {
const dispatch = useAppDispatch();
const { user, affiliation } = useAppSelector((state) => state.auth);
const { config } = useAppSelector((state) => state.theme);
const [showAffiliationModal, setShowAffiliationModal] = useState(false);
const [showUserSettingsModal, setShowUserSettingsModal] = useState(false);
const [showThemeSelector, setShowThemeSelector] = useState(false);
const handleToggleSidebar = () => {
dispatch(toggleSidebar());
};
return (
<>
<header
className="h-16 text-white shadow-2xl flex items-center justify-between px-6 fixed top-0 left-0 right-0 z-50 backdrop-blur-lg"
style={{
background: config.gradients.header,
boxShadow: config.effects.shadowHover,
}}
>
{/* Left: Hamburger Menu & Logo */}
<div className="flex items-center gap-4">
<button
onClick={handleToggleSidebar}
className="p-2.5 hover:bg-white/20 rounded-xl transition-all duration-300 transform hover:scale-110"
aria-label="Toggle Sidebar"
>
<FaBars className="text-xl" />
</button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-md">
<span className="text-2xl font-bold">W</span>
</div>
<div className="hidden md:block">
<h1 className="text-lg font-bold tracking-tight">
Warehouse Management
</h1>
<p className="text-xs opacity-80">Enterprise System</p>
</div>
</div>
</div>
{/* Right: Theme, Affiliation & User Info */}
<div className="flex items-center gap-2">
{/* Theme Selector Button */}
<button
onClick={() => setShowThemeSelector(true)}
className="flex items-center gap-2 px-3 py-2 hover:bg-white/20 rounded-xl transition-all duration-300 backdrop-blur-sm"
title="Change Theme"
>
<FaPalette className="text-lg" />
<span className="text-sm hidden lg:block">Theme</span>
</button>
{/* Affiliation Info */}
<button
onClick={() => setShowAffiliationModal(true)}
className="flex items-center gap-2 px-4 py-2 hover:bg-white/20 rounded-xl transition-all duration-300 backdrop-blur-sm border border-white/20"
>
<FaBuilding className="text-lg" />
<div className="text-left hidden md:block">
<div className="text-xs opacity-80">Company</div>
<div className="text-sm font-semibold truncate max-w-[120px]">
{affiliation?.companyName || 'N/A'}
</div>
</div>
<div className="text-left hidden lg:block ml-2 pl-2 border-l border-white/30">
<div className="text-xs opacity-80">Department</div>
<div className="text-sm font-semibold truncate max-w-[120px]">
{affiliation?.departmentName || 'N/A'}
</div>
</div>
</button>
{/* User Info */}
<button
onClick={() => setShowUserSettingsModal(true)}
className="flex items-center gap-3 px-4 py-2 hover:bg-white/20 rounded-xl transition-all duration-300 backdrop-blur-sm border border-white/20"
>
<div className="w-9 h-9 bg-white/30 rounded-full flex items-center justify-center backdrop-blur-md">
<FaUser className="text-sm" />
</div>
<div className="text-left hidden md:block">
<div className="text-sm font-semibold">
{user?.fullName || user?.username || 'User'}
</div>
<div className="text-xs opacity-80 truncate max-w-[150px]">
{user?.email || ''}
</div>
</div>
</button>
</div>
</header>
{/* Modals */}
<AffiliationModal
isOpen={showAffiliationModal}
onClose={() => setShowAffiliationModal(false)}
/>
<UserSettingsModal
isOpen={showUserSettingsModal}
onClose={() => setShowUserSettingsModal(false)}
/>
<ThemeSelector
isOpen={showThemeSelector}
onClose={() => setShowThemeSelector(false)}
/>
</>
);
};
export default Header;
/components/ThemeSelector/ThemeSelector.tsx
import Modal from '../Modal/Modal';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { setTheme } from '../../features/theme/themeSlice';
import { themePresets, type ThemeType } from '../../utils/themeConfig';
import {
FaLandmark,
FaBuilding,
FaUniversity,
FaGlobe,
FaCheck,
} from 'react-icons/fa';
interface ThemeSelectorProps {
isOpen: boolean;
onClose: () => void;
}
const ThemeSelector = ({ isOpen, onClose }: ThemeSelectorProps) => {
const dispatch = useAppDispatch();
const { currentTheme } = useAppSelector((state) => state.theme);
const themeIcons: Record<ThemeType, React.ReactNode> = {
government: <FaLandmark className="text-3xl" />,
enterprise: <FaBuilding className="text-3xl" />,
university: <FaUniversity className="text-3xl" />,
global: <FaGlobe className="text-3xl" />,
};
const themeDescriptions: Record<ThemeType, string> = {
government: 'Professional design for government organizations with formal blue tones',
enterprise: 'Modern corporate design with sleek dark theme for businesses',
university: 'Vibrant and creative design with purple gradients for educational institutions',
global: 'Fresh and dynamic design with cyan colors for international services',
};
const handleSelectTheme = (themeType: ThemeType) => {
dispatch(setTheme(themeType));
setTimeout(() => {
onClose();
}, 500);
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Select Theme" size="xl">
<div className="space-y-6">
<p className="text-gray-600 text-center">
Choose a theme that best represents your organization
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Object.entries(themePresets).map(([key, theme]) => {
const themeType = key as ThemeType;
const isSelected = currentTheme === themeType;
return (
<button
key={key}
onClick={() => handleSelectTheme(themeType)}
className={`relative p-6 rounded-2xl border-2 transition-all duration-300 transform hover:scale-105 ${
isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:shadow-lg'
}`}
>
{/* Selected Badge */}
{isSelected && (
<div className="absolute top-4 right-4 w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center">
<FaCheck />
</div>
)}
{/* Theme Preview */}
<div
className="h-32 rounded-xl mb-4 flex items-center justify-center text-white"
style={{
background: theme.gradients.primary,
boxShadow: theme.effects.shadow,
}}
>
{themeIcons[themeType]}
</div>
{/* Theme Info */}
<div className="text-left">
<h3 className="text-xl font-bold text-gray-800 mb-2">
{theme.name}
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
{themeDescriptions[themeType]}
</p>
</div>
{/* Color Palette */}
<div className="mt-4 flex gap-2">
<div
className="w-8 h-8 rounded-full border-2 border-white shadow-md"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-8 h-8 rounded-full border-2 border-white shadow-md"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-8 h-8 rounded-full border-2 border-white shadow-md"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
);
})}
</div>
{/* Preview Info */}
<div className="bg-gray-50 p-4 rounded-xl">
<p className="text-sm text-gray-600 text-center">
💡 The theme will be applied immediately across the entire application
</p>
</div>
</div>
</Modal>
);
};
export default ThemeSelector;
/components/Sidebar/Sidebar.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { addTab } from '../../features/ui/uiSlice';
import { menuConfig, getIconComponent } from '../../utils/menuConfig';
import type { MenuItem } from '../../types';
const Sidebar = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { sidebarCollapsed } = useAppSelector((state) => state.ui);
const { config } = useAppSelector((state) => state.theme);
const [expandedMenus, setExpandedMenus] = useState<string[]>(['basic']);
const toggleMenu = (menuId: string) => {
setExpandedMenus((prev) =>
prev.includes(menuId)
? prev.filter((id) => id !== menuId)
: [...prev, menuId]
);
};
const handleMenuItemClick = (item: MenuItem, _parentTitle: string) => {
if (item.path) {
// Add tab to Redux store
dispatch(
addTab({
id: item.id,
title: item.title,
path: item.path,
})
);
// Navigate to the route
navigate(item.path);
}
};
const renderMenuItem = (item: MenuItem, level: number = 0, parentTitle?: string) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMenus.includes(item.id);
const IconComponent = item.icon ? getIconComponent(item.icon) : null;
if (level === 0) {
// Parent menu item
return (
<div key={item.id} className="mb-2">
<button
onClick={() => toggleMenu(item.id)}
className={`w-full flex items-center justify-between px-4 py-3.5 rounded-xl transition-all duration-300 transform hover:scale-[1.02] ${
sidebarCollapsed
? 'justify-center'
: 'hover:bg-white/20 hover:shadow-lg'
} ${isExpanded ? 'bg-white/15' : ''}`}
>
<div className="flex items-center gap-3">
{IconComponent && (
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
<IconComponent className="text-lg flex-shrink-0" />
</div>
)}
{!sidebarCollapsed && (
<span className="font-semibold text-sm">{item.title}</span>
)}
</div>
{!sidebarCollapsed && (
<span className={`text-sm transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}>
<FaChevronDown />
</span>
)}
</button>
{/* Children */}
{hasChildren && isExpanded && !sidebarCollapsed && (
<div className="mt-2 ml-4 space-y-1 pl-4 border-l-2 border-white/20">
{item.children!.map((child) =>
renderMenuItem(child, level + 1, item.title)
)}
</div>
)}
</div>
);
} else {
// Child menu item
return (
<button
key={item.id}
onClick={() => handleMenuItemClick(item, parentTitle || '')}
className="w-full text-left px-4 py-2.5 rounded-lg hover:bg-white/15 transition-all duration-300 text-sm flex items-center gap-2 group"
>
<span className="w-1.5 h-1.5 rounded-full bg-white/40 group-hover:bg-white group-hover:scale-150 transition-all duration-300" />
{item.title}
</button>
);
}
};
return (
<aside
className={`text-white h-full transition-all duration-300 shadow-2xl overflow-y-auto ${
sidebarCollapsed ? 'w-20' : 'w-72'
}`}
style={{
background: config.gradients.sidebar,
boxShadow: config.effects.shadowHover,
}}
>
{/* Sidebar Header */}
{!sidebarCollapsed && (
<div className="p-6 border-b border-white/20">
<h2 className="text-xs font-semibold uppercase tracking-wider opacity-70">
Navigation Menu
</h2>
</div>
)}
<nav className="p-4 space-y-2">
{menuConfig.map((item) => renderMenuItem(item))}
</nav>
{/* Sidebar Footer */}
{!sidebarCollapsed && (
<div className="p-6 mt-auto border-t border-white/20">
<div className="text-xs text-center opacity-60">
<p>v1.0.0</p>
<p className="mt-1">© 2024 WMS</p>
</div>
</div>
)}
</aside>
);
};
export default Sidebar;
/components/Tabs/Tabs.tsx
import { useNavigate } from 'react-router-dom';
import { FaTimes } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { removeTab, setActiveTab } from '../../features/ui/uiSlice';
const Tabs = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { tabs, activeTabId } = useAppSelector((state) => state.ui);
const { config } = useAppSelector((state) => state.theme);
const handleTabClick = (tabId: string, path: string) => {
dispatch(setActiveTab(tabId));
navigate(path);
};
const handleCloseTab = (e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
dispatch(removeTab(tabId));
};
if (tabs.length === 0) {
return null;
}
return (
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 flex items-center overflow-x-auto shadow-sm">
{tabs.map((tab, index) => {
const isActive = activeTabId === tab.id;
return (
<div
key={tab.id}
onClick={() => handleTabClick(tab.id, tab.path)}
className={`
relative flex items-center gap-2 px-5 py-3 cursor-pointer
transition-all duration-300 whitespace-nowrap
${
isActive
? 'bg-white text-gray-900 font-semibold shadow-md'
: 'text-gray-600 hover:bg-white/50 hover:text-gray-900'
}
${index === 0 ? 'rounded-tl-lg' : ''}
`}
style={{
borderTop: isActive ? `3px solid ${config.colors.primary}` : '3px solid transparent',
}}
>
{/* Tab Number Badge */}
<span
className={`
w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold
transition-all duration-300
${
isActive
? 'text-white'
: 'bg-gray-200 text-gray-600'
}
`}
style={{
backgroundColor: isActive ? config.colors.primary : undefined,
}}
>
{index + 1}
</span>
{/* Tab Title */}
<span className="text-sm max-w-[150px] truncate">{tab.title}</span>
{/* Close Button */}
{tabs.length > 1 && (
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className={`
ml-1 p-1.5 rounded-full transition-all duration-300
${
isActive
? 'hover:bg-gray-100'
: 'hover:bg-gray-200'
}
`}
aria-label={`Close ${tab.title}`}
>
<FaTimes className="text-xs" />
</button>
)}
{/* Active Indicator Line */}
{isActive && (
<div
className="absolute bottom-0 left-0 right-0 h-0.5"
style={{ backgroundColor: config.colors.primary }}
/>
)}
</div>
);
})}
</div>
);
};
export default Tabs;
/pages/Dashboard/DashboardPage.tsx
// import { useEffect } from 'react';
import {
FaBell,
FaExclamationCircle,
FaBoxes,
FaTruck,
FaChartLine,
FaStar
} from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { markAsRead } from '../../features/notifications/notificationsSlice';
import { announcementsAPI } from '../../api';
const DashboardPage = () => {
const dispatch = useAppDispatch();
const { announcements, unreadCount } = useAppSelector(
(state) => state.notifications
);
const { user, affiliation } = useAppSelector((state) => state.auth);
const { config } = useAppSelector((state) => state.theme);
const handleMarkAsRead = async (id: string) => {
try {
await announcementsAPI.markAsRead(id);
dispatch(markAsRead(id));
} catch (error) {
console.error('Failed to mark announcement as read:', error);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const stats = [
{
title: 'Total Items',
value: '1,234',
icon: <FaBoxes className="text-3xl" />,
color: config.colors.primary,
bgColor: `${config.colors.primary}15`,
change: '+12%',
},
{
title: 'Pending Orders',
value: '56',
icon: <FaExclamationCircle className="text-3xl" />,
color: config.colors.warning,
bgColor: '#f59e0b15',
change: '+5%',
},
{
title: 'Active Tasks',
value: '23',
icon: <FaTruck className="text-3xl" />,
color: config.colors.success,
bgColor: '#10b98115',
change: '-3%',
},
{
title: 'Unread Notices',
value: String(unreadCount),
icon: <FaBell className="text-3xl" />,
color: config.colors.error,
bgColor: '#ef444415',
change: 'New',
},
];
return (
<div className="p-6 space-y-6">
{/* Welcome Section */}
<div
className="rounded-2xl shadow-xl p-8 text-white relative overflow-hidden"
style={{
background: config.gradients.primary,
}}
>
{/* Decorative Elements */}
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-32 -mt-32 blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-white/10 rounded-full -ml-32 -mb-32 blur-3xl" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-3">
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
<FaStar className="text-3xl" />
</div>
<div>
<h1 className="text-3xl font-bold">
Welcome back, {user?.fullName || user?.username}!
</h1>
<p className="text-white/80 mt-1">
{affiliation?.companyName} · {affiliation?.departmentName}
</p>
</div>
</div>
<p className="text-white/70 text-sm mt-4">
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, index) => (
<div
key={index}
className="bg-white rounded-2xl shadow-lg p-6 transform hover:scale-105 transition-all duration-300 cursor-pointer"
style={{
boxShadow: config.effects.shadow,
}}
>
<div className="flex items-center justify-between mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{
backgroundColor: stat.bgColor,
color: stat.color,
}}
>
{stat.icon}
</div>
<span
className={`text-sm font-semibold px-3 py-1 rounded-full ${
stat.change.includes('+')
? 'bg-green-100 text-green-700'
: stat.change.includes('-')
? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}`}
>
{stat.change}
</span>
</div>
<p className="text-sm text-gray-600 mb-1">{stat.title}</p>
<p className="text-3xl font-bold text-gray-800">{stat.value}</p>
</div>
))}
</div>
{/* Performance Chart */}
<div className="bg-white rounded-2xl shadow-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<FaChartLine style={{ color: config.colors.primary }} />
Performance Overview
</h2>
<select className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent">
<option>Last 7 days</option>
<option>Last 30 days</option>
<option>Last 3 months</option>
</select>
</div>
<div className="h-64 flex items-center justify-center bg-gray-50 rounded-xl">
<p className="text-gray-500">Chart placeholder - Connect to backend for real data</p>
</div>
</div>
{/* Announcements */}
<div className="bg-white rounded-2xl shadow-lg">
<div className="px-6 py-5 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<FaBell style={{ color: config.colors.primary }} />
Announcements & Notices
{unreadCount > 0 && (
<span
className="ml-2 px-2.5 py-0.5 rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: config.colors.error }}
>
{unreadCount} New
</span>
)}
</h2>
</div>
<div className="divide-y divide-gray-200">
{announcements.length === 0 ? (
<div className="px-6 py-12 text-center text-gray-500">
<FaBell className="text-4xl mx-auto mb-3 opacity-30" />
<p>No announcements at this time</p>
</div>
) : (
announcements.map((announcement) => (
<div
key={announcement.id}
className={`px-6 py-5 hover:bg-gray-50 transition-all duration-300 cursor-pointer ${
!announcement.isRead ? 'bg-blue-50/50 border-l-4' : ''
}`}
style={{
borderLeftColor: !announcement.isRead ? config.colors.primary : 'transparent',
}}
onClick={() => !announcement.isRead && handleMarkAsRead(announcement.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3
className={`font-semibold text-lg ${
announcement.isImportant
? 'text-red-600'
: 'text-gray-800'
}`}
>
{announcement.title}
</h3>
{announcement.isImportant && (
<span className="bg-red-100 text-red-600 text-xs font-semibold px-2.5 py-1 rounded-full">
Important
</span>
)}
{!announcement.isRead && (
<span
className="text-white text-xs font-semibold px-2.5 py-1 rounded-full"
style={{ backgroundColor: config.colors.primary }}
>
New
</span>
)}
</div>
<p className="text-gray-600 mb-3 leading-relaxed">
{announcement.content}
</p>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span>{formatDate(announcement.date)}</span>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
);
};
export default DashboardPage;
npm run build 2>&1 | head -50
반응형
'교육' 카테고리의 다른 글
| intellij idea community edition 2025.2 버전 이하 Community 버전 다운로드 방법 (0) | 2025.12.26 |
|---|---|
| Java Spring Boot 백엔드 구조 (0) | 2025.12.24 |
| 디자인 화면 분석 (0) | 2025.12.24 |
| React 프로젝트 생성 흐름 확인 (0) | 2025.12.24 |
| Windows 환경 MariaDB 설치 및 기본 설정 가이드 (0) | 2025.12.23 |