Vite + React + TypeScript 프로젝트 설정
npm create -y vite@latest webapp -- --template react-ts
npm install
npm run dev
필요한 의존성을 설치하고 프로젝트 구조 구축, package.json 업데이트
package.json
{
"name": "webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@reduxjs/toolkit": "^2.5.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.1.1",
"ag-grid-react": "^33.2.1",
"ag-grid-community": "^33.2.1",
"axios": "^1.7.9",
"react-icons": "^5.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
npm install
Tailwind CSS 설정
npx tailwindcss init -p
tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [],
theme: {
extend: {},
},
plugins: [],
}
프로젝트 폴더 구조 생성
mkdir -p app features/{auth,ui,userSettings,notifications} components/{Header,Sidebar,Tabs,Modal,SearchConditions,DataArea} pages/{Login,Dashboard,BasicInfo,Receiving,Issue,Inquiry} api hooks types utils
주요 설정 파일
index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'ag-grid-community/styles/ag-grid.css';
@import 'ag-grid-community/styles/ag-theme-alpine.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/types/index.ts
// Auth Types
export interface User {
id: string;
username: string;
email: string;
fullName: string;
}
export interface Affiliation {
companyId: string;
companyName: string;
departmentId: string;
departmentName: string;
}
export interface AuthState {
isLoggedIn: boolean;
user: User | null;
token: string | null;
affiliation: Affiliation | null;
}
// UI Types
export interface Tab {
id: string;
title: string;
path: string;
}
export interface UIState {
sidebarCollapsed: boolean;
tabs: Tab[];
activeTabId: string | null;
}
// User Settings Types
export type Language = 'en' | 'ko' | 'ja' | 'zh';
export type NumberFormat = 'comma' | 'period' | 'space';
export type DateFormat = 'YYYY-MM-DD' | 'MM/DD/YYYY' | 'DD.MM.YYYY';
export type Theme = 'light' | 'dark' | 'auto';
export interface UserSettings {
language: Language;
numberFormat: NumberFormat;
dateFormat: DateFormat;
theme: Theme;
}
// Notification Types
export interface Announcement {
id: string;
title: string;
content: string;
date: string;
isImportant: boolean;
isRead: boolean;
}
export interface NotificationsState {
announcements: Announcement[];
unreadCount: number;
}
// Menu Types
export interface MenuItem {
id: string;
title: string;
path?: string;
icon?: string;
children?: MenuItem[];
}
// API Response Types
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
errors?: string[];
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
user: User;
token: string;
affiliation: Affiliation;
}
// Grid Data Types
export interface GridRowData {
[key: string]: string | number | boolean | Date | null | undefined;
}
// Modal Types
export interface ModalState {
affiliationModalOpen: boolean;
userSettingsModalOpen: boolean;
}
// Company and Department for Affiliation Modal
export interface Company {
id: string;
name: string;
}
export interface Department {
id: string;
name: string;
companyId: string;
}
Redux store와 slices 생성
/features/auth/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, User, Affiliation } from '../../types';
const initialState: AuthState = {
isLoggedIn: false,
user: null,
token: null,
affiliation: null,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess: (
state,
action: PayloadAction<{ user: User; token: string; affiliation: Affiliation }>
) => {
state.isLoggedIn = true;
state.user = action.payload.user;
state.token = action.payload.token;
state.affiliation = action.payload.affiliation;
},
logout: (state) => {
state.isLoggedIn = false;
state.user = null;
state.token = null;
state.affiliation = null;
},
updateAffiliation: (state, action: PayloadAction<Affiliation>) => {
state.affiliation = action.payload;
},
},
});
export const { loginSuccess, logout, updateAffiliation } = authSlice.actions;
export default authSlice.reducer;
/features/ui/uiSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { UIState, Tab } from '../../types';
const MAX_TABS = 6;
// Load sidebar state from localStorage
const loadSidebarState = (): boolean => {
const saved = localStorage.getItem('sidebarCollapsed');
return saved ? JSON.parse(saved) : false;
};
const initialState: UIState = {
sidebarCollapsed: loadSidebarState(),
tabs: [],
activeTabId: null,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
},
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload;
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
},
addTab: (state, action: PayloadAction<Tab>) => {
const existingTab = state.tabs.find((tab) => tab.id === action.payload.id);
if (existingTab) {
// If tab exists, just activate it
state.activeTabId = action.payload.id;
} else {
// If we've reached max tabs, remove the oldest one (FIFO)
if (state.tabs.length >= MAX_TABS) {
state.tabs.shift();
}
// Add new tab
state.tabs.push(action.payload);
state.activeTabId = action.payload.id;
}
},
removeTab: (state, action: PayloadAction<string>) => {
const tabIndex = state.tabs.findIndex((tab) => tab.id === action.payload);
if (tabIndex !== -1) {
state.tabs.splice(tabIndex, 1);
// If we removed the active tab, activate another one
if (state.activeTabId === action.payload) {
if (state.tabs.length > 0) {
// Activate the tab at the same index, or the last tab if removed the last one
const newIndex = tabIndex >= state.tabs.length ? state.tabs.length - 1 : tabIndex;
state.activeTabId = state.tabs[newIndex].id;
} else {
state.activeTabId = null;
}
}
}
},
setActiveTab: (state, action: PayloadAction<string>) => {
state.activeTabId = action.payload;
},
clearAllTabs: (state) => {
state.tabs = [];
state.activeTabId = null;
},
},
});
export const {
toggleSidebar,
setSidebarCollapsed,
addTab,
removeTab,
setActiveTab,
clearAllTabs,
} = uiSlice.actions;
export default uiSlice.reducer;
/features/userSettings/userSettingsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { UIState, Tab } from '../../types';
const MAX_TABS = 6;
// Load sidebar state from localStorage
const loadSidebarState = (): boolean => {
const saved = localStorage.getItem('sidebarCollapsed');
return saved ? JSON.parse(saved) : false;
};
const initialState: UIState = {
sidebarCollapsed: loadSidebarState(),
tabs: [],
activeTabId: null,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
},
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload;
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
},
addTab: (state, action: PayloadAction<Tab>) => {
const existingTab = state.tabs.find((tab) => tab.id === action.payload.id);
if (existingTab) {
// If tab exists, just activate it
state.activeTabId = action.payload.id;
} else {
// If we've reached max tabs, remove the oldest one (FIFO)
if (state.tabs.length >= MAX_TABS) {
state.tabs.shift();
}
// Add new tab
state.tabs.push(action.payload);
state.activeTabId = action.payload.id;
}
},
removeTab: (state, action: PayloadAction<string>) => {
const tabIndex = state.tabs.findIndex((tab) => tab.id === action.payload);
if (tabIndex !== -1) {
state.tabs.splice(tabIndex, 1);
// If we removed the active tab, activate another one
if (state.activeTabId === action.payload) {
if (state.tabs.length > 0) {
// Activate the tab at the same index, or the last tab if removed the last one
const newIndex = tabIndex >= state.tabs.length ? state.tabs.length - 1 : tabIndex;
state.activeTabId = state.tabs[newIndex].id;
} else {
state.activeTabId = null;
}
}
}
},
setActiveTab: (state, action: PayloadAction<string>) => {
state.activeTabId = action.payload;
},
clearAllTabs: (state) => {
state.tabs = [];
state.activeTabId = null;
},
},
});
export const {
toggleSidebar,
setSidebarCollapsed,
addTab,
removeTab,
setActiveTab,
clearAllTabs,
} = uiSlice.actions;
export default uiSlice.reducer;
/features/notifications/notificationsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { UIState, Tab } from '../../types';
const MAX_TABS = 6;
// Load sidebar state from localStorage
const loadSidebarState = (): boolean => {
const saved = localStorage.getItem('sidebarCollapsed');
return saved ? JSON.parse(saved) : false;
};
const initialState: UIState = {
sidebarCollapsed: loadSidebarState(),
tabs: [],
activeTabId: null,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
},
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload;
localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed));
},
addTab: (state, action: PayloadAction<Tab>) => {
const existingTab = state.tabs.find((tab) => tab.id === action.payload.id);
if (existingTab) {
// If tab exists, just activate it
state.activeTabId = action.payload.id;
} else {
// If we've reached max tabs, remove the oldest one (FIFO)
if (state.tabs.length >= MAX_TABS) {
state.tabs.shift();
}
// Add new tab
state.tabs.push(action.payload);
state.activeTabId = action.payload.id;
}
},
removeTab: (state, action: PayloadAction<string>) => {
const tabIndex = state.tabs.findIndex((tab) => tab.id === action.payload);
if (tabIndex !== -1) {
state.tabs.splice(tabIndex, 1);
// If we removed the active tab, activate another one
if (state.activeTabId === action.payload) {
if (state.tabs.length > 0) {
// Activate the tab at the same index, or the last tab if removed the last one
const newIndex = tabIndex >= state.tabs.length ? state.tabs.length - 1 : tabIndex;
state.activeTabId = state.tabs[newIndex].id;
} else {
state.activeTabId = null;
}
}
}
},
setActiveTab: (state, action: PayloadAction<string>) => {
state.activeTabId = action.payload;
},
clearAllTabs: (state) => {
state.tabs = [];
state.activeTabId = null;
},
},
});
export const {
toggleSidebar,
setSidebarCollapsed,
addTab,
removeTab,
setActiveTab,
clearAllTabs,
} = uiSlice.actions;
export default uiSlice.reducer;
/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';
import uiReducer from '../features/ui/uiSlice';
import userSettingsReducer from '../features/userSettings/userSettingsSlice';
import notificationsReducer from '../features/notifications/notificationsSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
ui: uiReducer,
userSettings: userSettingsReducer,
notifications: notificationsReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
custom hooks 생성
/hooks/index.ts
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from '../app/store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
API 관련 파일 생성
/api/axiosInstance.ts
import axios from 'axios';
// Create axios instance
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for adding auth token
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for handling errors
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid, redirect to login
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default axiosInstance;
/utils/menuConfig.ts
import type { MenuItem } from '../types';
import {
FaBoxes,
FaCog,
FaWarehouse,
FaTruck,
FaClipboardList,
FaExchangeAlt,
FaSearch,
FaUser,
FaCalendarWeek,
FaCalendarDay,
} from 'react-icons/fa';
export const menuConfig: MenuItem[] = [
{
id: 'basic',
title: 'Basic Information',
icon: 'FaCog',
children: [
{
id: 'option',
title: 'Option Management',
path: '/basic/option',
},
{
id: 'material',
title: 'Material Management',
path: '/basic/material',
},
{
id: 'bins',
title: 'Warehouse Bins Management',
path: '/basic/bins',
},
],
},
{
id: 'receiving',
title: 'Receiving',
icon: 'FaTruck',
children: [
{
id: 'arrival',
title: 'Arrival Management',
path: '/receiving/arrival',
},
{
id: 'putaway',
title: 'Putaway Management',
path: '/receiving/putaway',
},
],
},
{
id: 'issue',
title: 'Issue',
icon: 'FaBoxes',
children: [
{
id: 'picking',
title: 'Picking Management',
path: '/issue/picking',
},
{
id: 'takeover',
title: 'Takeover Management',
path: '/issue/takeover',
},
{
id: 'transfer',
title: 'Transfer Management',
path: '/issue/transfer',
},
],
},
{
id: 'inquiry',
title: 'Inquiry',
icon: 'FaSearch',
children: [
{
id: 'onhand',
title: 'Onhand Management',
path: '/inquiry/onhand',
},
{
id: 'personal',
title: 'Personal Performance Management',
path: '/inquiry/personal',
},
{
id: 'weekly',
title: 'Personal Weekly Performance Management',
path: '/inquiry/weekly',
},
{
id: 'daily',
title: 'Personal Daily Performance Management',
path: '/inquiry/daily',
},
],
},
];
// Helper to get icon component by name
export const getIconComponent = (iconName: string) => {
const icons: Record<string, React.ComponentType> = {
FaCog,
FaTruck,
FaBoxes,
FaSearch,
FaWarehouse,
FaClipboardList,
FaExchangeAlt,
FaUser,
FaCalendarWeek,
FaCalendarDay,
};
return icons[iconName] || FaCog;
};
Header 컴포넌트 생성
/components/Header/Header.tsx
import { useState } from 'react';
import { FaBars, FaBuilding, FaUser } 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';
const Header = () => {
const dispatch = useAppDispatch();
const { user, affiliation } = useAppSelector((state) => state.auth);
const [showAffiliationModal, setShowAffiliationModal] = useState(false);
const [showUserSettingsModal, setShowUserSettingsModal] = useState(false);
const handleToggleSidebar = () => {
dispatch(toggleSidebar());
};
return (
<>
<header className="h-16 bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg flex items-center justify-between px-4 fixed top-0 left-0 right-0 z-50">
{/* Left: Hamburger Menu */}
<div className="flex items-center">
<button
onClick={handleToggleSidebar}
className="p-2 hover:bg-blue-500 rounded-lg transition-colors duration-200"
aria-label="Toggle Sidebar"
>
<FaBars className="text-xl" />
</button>
<h1 className="ml-4 text-xl font-bold hidden md:block">
Warehouse Management System
</h1>
</div>
{/* Right: Affiliation & User Info */}
<div className="flex items-center gap-4">
{/* Affiliation Info */}
<button
onClick={() => setShowAffiliationModal(true)}
className="flex items-center gap-2 px-4 py-2 hover:bg-blue-500 rounded-lg transition-colors duration-200"
>
<FaBuilding />
<div className="text-left hidden md:block">
<div className="text-xs opacity-80">Company</div>
<div className="text-sm font-semibold">
{affiliation?.companyName || 'N/A'}
</div>
</div>
<div className="text-left hidden lg:block ml-2">
<div className="text-xs opacity-80">Department</div>
<div className="text-sm font-semibold">
{affiliation?.departmentName || 'N/A'}
</div>
</div>
</button>
{/* User Info */}
<button
onClick={() => setShowUserSettingsModal(true)}
className="flex items-center gap-2 px-4 py-2 hover:bg-blue-500 rounded-lg transition-colors duration-200"
>
<FaUser />
<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">{user?.email || ''}</div>
</div>
</button>
</div>
</header>
{/* Modals */}
<AffiliationModal
isOpen={showAffiliationModal}
onClose={() => setShowAffiliationModal(false)}
/>
<UserSettingsModal
isOpen={showUserSettingsModal}
onClose={() => setShowUserSettingsModal(false)}
/>
</>
);
};
export default Header;
Sidebar 컴포넌트 생성
/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 [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-1">
<button
onClick={() => toggleMenu(item.id)}
className={`w-full flex items-center justify-between px-4 py-3 hover:bg-blue-600 transition-colors duration-200 ${
sidebarCollapsed ? 'justify-center' : ''
}`}
>
<div className="flex items-center gap-3">
{IconComponent && <IconComponent className="text-lg flex-shrink-0" />}
{!sidebarCollapsed && (
<span className="font-medium">{item.title}</span>
)}
</div>
{!sidebarCollapsed && (
<span className="text-sm">
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
)}
</button>
{/* Children */}
{hasChildren && isExpanded && !sidebarCollapsed && (
<div className="bg-blue-700">
{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-8 py-2 hover:bg-blue-600 transition-colors duration-200 text-sm"
>
{item.title}
</button>
);
}
};
return (
<aside
className={`bg-blue-700 text-white h-full transition-all duration-300 shadow-lg overflow-y-auto ${
sidebarCollapsed ? 'w-16' : 'w-64'
}`}
>
<nav className="py-4">
{menuConfig.map((item) => renderMenuItem(item))}
</nav>
</aside>
);
};
export default Sidebar;
Tabs 컴포넌트 생성
/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 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-gray-100 border-b border-gray-300 flex items-center overflow-x-auto">
{tabs.map((tab) => (
<div
key={tab.id}
onClick={() => handleTabClick(tab.id, tab.path)}
className={`
flex items-center gap-2 px-4 py-2 cursor-pointer border-r border-gray-300
transition-colors duration-200 whitespace-nowrap
${
activeTabId === tab.id
? 'bg-white text-blue-600 font-semibold'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}
`}
>
<span className="text-sm">{tab.title}</span>
{tabs.length > 1 && (
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="ml-2 p-1 hover:bg-gray-300 rounded-full transition-colors duration-200"
aria-label={`Close ${tab.title}`}
>
<FaTimes className="text-xs" />
</button>
)}
</div>
))}
</div>
);
};
export default Tabs;
Modal 컴포넌트들 생성
/components/Modal/Modal.tsx
import { useEffect, ReactNode } from 'react';
import { FaTimes } from 'react-icons/fa';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const Modal = ({ isOpen, onClose, title, children, size = 'md' }: ModalProps) => {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal Content */}
<div
className={`relative bg-white rounded-lg shadow-2xl w-full ${sizeClasses[size]} mx-4 max-h-[90vh] flex flex-col`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">{title}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors duration-200"
aria-label="Close modal"
>
<FaTimes className="text-gray-600" />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1">{children}</div>
</div>
</div>
);
};
export default Modal;
/components/Modal/AffiliationModal.tsx
import { useState, useEffect } from 'react';
import Modal from './Modal';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { updateAffiliation } from '../../features/auth/authSlice';
import { affiliationAPI } from '../../api';
import type { Company, Department } from '../../types';
interface AffiliationModalProps {
isOpen: boolean;
onClose: () => void;
}
const AffiliationModal = ({ isOpen, onClose }: AffiliationModalProps) => {
const dispatch = useAppDispatch();
const { affiliation } = useAppSelector((state) => state.auth);
const [companies, setCompanies] = useState<Company[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [selectedDepartmentId, setSelectedDepartmentId] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen) {
loadCompanies();
if (affiliation) {
setSelectedCompanyId(affiliation.companyId);
setSelectedDepartmentId(affiliation.departmentId);
}
}
}, [isOpen, affiliation]);
useEffect(() => {
if (selectedCompanyId) {
loadDepartments(selectedCompanyId);
} else {
setDepartments([]);
setSelectedDepartmentId('');
}
}, [selectedCompanyId]);
const loadCompanies = async () => {
try {
setLoading(true);
const data = await affiliationAPI.getCompanies();
setCompanies(data);
} catch (error) {
console.error('Failed to load companies:', error);
} finally {
setLoading(false);
}
};
const loadDepartments = async (companyId: string) => {
try {
setLoading(true);
const data = await affiliationAPI.getDepartments(companyId);
setDepartments(data);
} catch (error) {
console.error('Failed to load departments:', error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
const selectedCompany = companies.find((c) => c.id === selectedCompanyId);
const selectedDepartment = departments.find((d) => d.id === selectedDepartmentId);
if (!selectedCompany || !selectedDepartment) {
alert('Please select both company and department');
return;
}
try {
setLoading(true);
const newAffiliation = {
companyId: selectedCompany.id,
companyName: selectedCompany.name,
departmentId: selectedDepartment.id,
departmentName: selectedDepartment.name,
};
await affiliationAPI.updateAffiliation(newAffiliation);
dispatch(updateAffiliation(newAffiliation));
onClose();
} catch (error) {
console.error('Failed to update affiliation:', error);
alert('Failed to update affiliation. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Change Affiliation" size="md">
<div className="space-y-4">
{/* Company Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Company
</label>
<select
value={selectedCompanyId}
onChange={(e) => setSelectedCompanyId(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={loading}
>
<option value="">Select Company</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
{/* Department Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Department
</label>
<select
value={selectedDepartmentId}
onChange={(e) => setSelectedDepartmentId(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={loading || !selectedCompanyId}
>
<option value="">Select Department</option>
{departments.map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4">
<button
onClick={onClose}
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSave}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || !selectedCompanyId || !selectedDepartmentId}
>
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</Modal>
);
};
export default AffiliationModal;
/components/Modal/UserSettingsModal.tsx
import { useState, useEffect } from 'react';
import Modal from './Modal';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { updateSettings } from '../../features/userSettings/userSettingsSlice';
import { userSettingsAPI } from '../../api';
import type { Language, NumberFormat, DateFormat, Theme } from '../../types';
interface UserSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
const UserSettingsModal = ({ isOpen, onClose }: UserSettingsModalProps) => {
const dispatch = useAppDispatch();
const { user } = useAppSelector((state) => state.auth);
const userSettings = useAppSelector((state) => state.userSettings);
const [language, setLanguage] = useState<Language>(userSettings.language);
const [numberFormat, setNumberFormat] = useState<NumberFormat>(userSettings.numberFormat);
const [dateFormat, setDateFormat] = useState<DateFormat>(userSettings.dateFormat);
const [theme, setTheme] = useState<Theme>(userSettings.theme);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen) {
setLanguage(userSettings.language);
setNumberFormat(userSettings.numberFormat);
setDateFormat(userSettings.dateFormat);
setTheme(userSettings.theme);
}
}, [isOpen, userSettings]);
const handleSave = async () => {
try {
setLoading(true);
const newSettings = {
language,
numberFormat,
dateFormat,
theme,
};
await userSettingsAPI.updateSettings(newSettings);
dispatch(updateSettings(newSettings));
onClose();
} catch (error) {
console.error('Failed to update settings:', error);
alert('Failed to update settings. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="User Settings" size="md">
<div className="space-y-6">
{/* User Information (Read-only) */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-sm font-semibold text-gray-700 mb-3">
User Information
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Username:</span>
<span className="font-medium">{user?.username}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Full Name:</span>
<span className="font-medium">{user?.fullName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Email:</span>
<span className="font-medium">{user?.email}</span>
</div>
</div>
</div>
{/* Language Setting */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value as Language)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="en">English</option>
<option value="ko">한국어 (Korean)</option>
<option value="ja">日本語 (Japanese)</option>
<option value="zh">中文 (Chinese)</option>
</select>
</div>
{/* Number Format Setting */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number Format
</label>
<select
value={numberFormat}
onChange={(e) => setNumberFormat(e.target.value as NumberFormat)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="comma">1,234,567.89 (Comma)</option>
<option value="period">1.234.567,89 (Period)</option>
<option value="space">1 234 567.89 (Space)</option>
</select>
</div>
{/* Date Format Setting */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date Format
</label>
<select
value={dateFormat}
onChange={(e) => setDateFormat(e.target.value as DateFormat)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="YYYY-MM-DD">YYYY-MM-DD (2024-12-17)</option>
<option value="MM/DD/YYYY">MM/DD/YYYY (12/17/2024)</option>
<option value="DD.MM.YYYY">DD.MM.YYYY (17.12.2024)</option>
</select>
</div>
{/* Theme Setting */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Theme
</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto (System)</option>
</select>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={onClose}
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSave}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading}
>
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</Modal>
);
};
export default UserSettingsModal;
SearchConditions와 DataArea 컴포넌트 생성
/components/SearchConditions/SearchConditions.tsx
import { useState } from 'react';
import { FaSearch, FaUndo } from 'react-icons/fa';
interface SearchConditionsProps {
onSearch: (filters: Record<string, string>) => void;
fields?: Array<{
name: string;
label: string;
type: 'text' | 'select' | 'date' | 'daterange';
options?: Array<{ value: string; label: string }>;
}>;
}
const SearchConditions = ({ onSearch, fields }: SearchConditionsProps) => {
const defaultFields = [
{ name: 'code', label: 'Code', type: 'text' as const },
{ name: 'name', label: 'Name', type: 'text' as const },
{
name: 'status',
label: 'Status',
type: 'select' as const,
options: [
{ value: '', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'pending', label: 'Pending' },
],
},
{ name: 'dateFrom', label: 'Date From', type: 'date' as const },
{ name: 'dateTo', label: 'Date To', type: 'date' as const },
];
const searchFields = fields || defaultFields;
const [filters, setFilters] = useState<Record<string, string>>(
searchFields.reduce((acc, field) => ({ ...acc, [field.name]: '' }), {})
);
const handleChange = (name: string, value: string) => {
setFilters((prev) => ({ ...prev, [name]: value }));
};
const handleSearch = () => {
onSearch(filters);
};
const handleReset = () => {
const resetFilters = searchFields.reduce(
(acc, field) => ({ ...acc, [field.name]: '' }),
{}
);
setFilters(resetFilters);
onSearch(resetFilters);
};
const renderField = (field: typeof searchFields[0]) => {
switch (field.type) {
case 'select':
return (
<select
value={filters[field.name] || ''}
onChange={(e) => handleChange(field.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
case 'date':
return (
<input
type="date"
value={filters[field.name] || ''}
onChange={(e) => handleChange(field.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
);
case 'text':
default:
return (
<input
type="text"
value={filters[field.name] || ''}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={`Enter ${field.label.toLowerCase()}`}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
);
}
};
return (
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{searchFields.map((field) => (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
{renderField(field)}
</div>
))}
</div>
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-gray-200">
<button
onClick={handleReset}
className="flex items-center gap-2 px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
>
<FaUndo />
Reset
</button>
<button
onClick={handleSearch}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200"
>
<FaSearch />
Search
</button>
</div>
</div>
);
};
export default SearchConditions;
/components/DataArea/DataArea.tsx
import { useState, useMemo, useCallback, useRef } from 'react';
import { AgGridReact } from 'ag-grid-react';
import type { ColDef } from 'ag-grid-community';
import {
FaCog,
FaFileUpload,
FaFileDownload,
FaSave,
FaTrash,
FaFilter,
FaPrint,
} from 'react-icons/fa';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
interface DataAreaProps {
rowData: unknown[];
columnDefs: ColDef[];
onSave?: (data: unknown[]) => void;
onDelete?: (selectedRows: unknown[]) => void;
onExcelUpload?: (file: File) => void;
onExcelSampleDownload?: () => void;
loading?: boolean;
}
const DataArea = ({
rowData,
columnDefs,
onSave,
onDelete,
onExcelUpload,
onExcelSampleDownload,
loading = false,
}: DataAreaProps) => {
const gridRef = useRef<AgGridReact>(null);
const [autoPrint, setAutoPrint] = useState(false);
const [showPrinterModal, setShowPrinterModal] = useState(false);
// Calculate counts
const totalCount = rowData.length;
const activeCount = rowData.filter(
(row: any) => row.status === 'Active' || row.status === 'active'
).length;
const inactiveCount = rowData.filter(
(row: any) => row.status === 'Inactive' || row.status === 'inactive'
).length;
const pendingCount = rowData.filter(
(row: any) => row.status === 'Pending' || row.status === 'pending'
).length;
const defaultColDef = useMemo<ColDef>(
() => ({
sortable: true,
filter: true,
resizable: true,
editable: false,
}),
[]
);
const handleSave = () => {
if (onSave) {
const allData: unknown[] = [];
gridRef.current?.api.forEachNode((node) => {
allData.push(node.data);
});
onSave(allData);
}
};
const handleDelete = () => {
const selectedRows = gridRef.current?.api.getSelectedRows();
if (selectedRows && selectedRows.length > 0) {
if (
window.confirm(
`Are you sure you want to delete ${selectedRows.length} item(s)?`
)
) {
if (onDelete) {
onDelete(selectedRows);
}
}
} else {
alert('Please select rows to delete');
}
};
const handleExcelUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && onExcelUpload) {
onExcelUpload(file);
}
e.target.value = ''; // Reset input
};
const handleGridExport = () => {
gridRef.current?.api.exportDataAsCsv({
fileName: `export_${new Date().toISOString().split('T')[0]}.csv`,
});
};
const handleGridFilter = () => {
const filterInstance = gridRef.current?.api.getFilterInstance('status');
if (filterInstance) {
// Toggle filter display
gridRef.current?.api.setFilterModel(null); // Clear filters
}
};
const handlePrint = () => {
setShowPrinterModal(true);
};
const handlePrinterSelect = (printer: string) => {
console.log('Printing to:', printer);
alert(`Print job sent to ${printer}`);
setShowPrinterModal(false);
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
{/* Top Bar */}
<div className="flex items-center justify-between mb-4">
{/* Left Side: Grid Settings & Counts */}
<div className="flex items-center gap-4">
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors duration-200"
title="Grid Settings"
>
<FaCog className="text-gray-600" />
</button>
<div className="flex items-center gap-4 text-sm">
<span className="font-semibold">Total: {totalCount}</span>
<span className="text-green-600">Active: {activeCount}</span>
<span className="text-red-600">Inactive: {inactiveCount}</span>
<span className="text-yellow-600">Pending: {pendingCount}</span>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoPrint}
onChange={(e) => setAutoPrint(e.target.checked)}
className="rounded"
/>
Auto Print
</label>
</div>
{/* Right Side: Action Buttons */}
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleExcelUpload}
className="hidden"
/>
<span className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200 text-sm">
<FaFileUpload />
Excel Upload
</span>
</label>
<button
onClick={onExcelSampleDownload}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200 text-sm"
>
<FaFileDownload />
Sample
</button>
{onSave && (
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors duration-200 text-sm"
>
<FaSave />
Save
</button>
)}
{onDelete && (
<button
onClick={handleDelete}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors duration-200 text-sm"
>
<FaTrash />
Delete
</button>
)}
<button
onClick={handleGridExport}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200 text-sm"
>
<FaFileDownload />
Export
</button>
<button
onClick={handleGridFilter}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200 text-sm"
>
<FaFilter />
Filter
</button>
</div>
</div>
{/* AG-Grid */}
<div className="ag-theme-alpine" style={{ height: 500, width: '100%' }}>
<AgGridReact
ref={gridRef}
rowData={rowData}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection="multiple"
pagination={true}
paginationPageSize={20}
paginationPageSizeSelector={[10, 20, 50, 100]}
loading={loading}
animateRows={true}
suppressRowClickSelection={true}
/>
</div>
{/* Bottom: Print Section */}
<div className="mt-4 pt-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-600">
Select rows and use the action buttons above
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Print:</span>
<button
onClick={handlePrint}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200 text-sm"
>
<FaPrint />
Select Printer
</button>
</div>
</div>
{/* Printer Selection Modal */}
{showPrinterModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={() => setShowPrinterModal(false)}
/>
<div className="relative bg-white rounded-lg shadow-2xl p-6 w-96">
<h3 className="text-lg font-semibold mb-4">Select Printer</h3>
<div className="space-y-2">
{['Default Printer', 'HP LaserJet Pro', 'Canon PIXMA', 'Brother HL-L2350'].map(
(printer) => (
<button
key={printer}
onClick={() => handlePrinterSelect(printer)}
className="w-full text-left px-4 py-3 border border-gray-300 rounded-lg hover:bg-blue-50 hover:border-blue-500 transition-colors duration-200"
>
{printer}
</button>
)
)}
</div>
<button
onClick={() => setShowPrinterModal(false)}
className="mt-4 w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
};
export default DataArea;
Login 페이지
/pages/Login/LoginPage.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FaUser, FaLock, FaWarehouse } from 'react-icons/fa';
import { useAppDispatch } from '../../hooks';
import { loginSuccess } from '../../features/auth/authSlice';
import { setAnnouncements } from '../../features/notifications/notificationsSlice';
import { authAPI, announcementsAPI } from '../../api';
const LoginPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError('Please enter both username and password');
return;
}
try {
setLoading(true);
// Login API call
const loginResponse = await authAPI.login({ username, password });
// Store token in localStorage
localStorage.setItem('token', loginResponse.token);
// Update Redux state
dispatch(loginSuccess(loginResponse));
// Fetch announcements
const announcements = await announcementsAPI.getAnnouncements();
dispatch(setAnnouncements(announcements));
// Navigate to dashboard
navigate('/dashboard');
} catch (err) {
console.error('Login failed:', err);
setError('Login failed. Please check your credentials and try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
{/* Logo and Title */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-4">
<FaWarehouse className="text-3xl text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Warehouse Management
</h1>
<p className="text-gray-600">Sign in to continue</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Username Field */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaUser className="text-gray-400" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
disabled={loading}
/>
</div>
</div>
{/* Password Field */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<FaLock className="text-gray-400" />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<label className="flex items-center">
<input
type="checkbox"
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<a
href="#"
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Forgot password?
</a>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
{/* Additional Info */}
<div className="mt-6 text-center text-sm text-gray-600">
<p>Demo credentials: admin / password</p>
</div>
</div>
</div>
);
};
export default LoginPage;
/pages/Dashboard/DashboardPage.tsx
import { useEffect } from 'react';
import { FaBell, FaExclamationCircle } 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 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',
});
};
return (
<div className="p-6 space-y-6">
{/* Welcome Section */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg shadow-lg p-6">
<h1 className="text-3xl font-bold mb-2">
Welcome back, {user?.fullName || user?.username}!
</h1>
<p className="text-blue-100">
{affiliation?.companyName} - {affiliation?.departmentName}
</p>
<p className="text-blue-100 text-sm mt-1">
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Items</p>
<p className="text-2xl font-bold text-gray-800">1,234</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<FaBell className="text-blue-600 text-xl" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Pending Orders</p>
<p className="text-2xl font-bold text-gray-800">56</p>
</div>
<div className="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center">
<FaExclamationCircle className="text-yellow-600 text-xl" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active Tasks</p>
<p className="text-2xl font-bold text-gray-800">23</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<FaBell className="text-green-600 text-xl" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Unread Notices</p>
<p className="text-2xl font-bold text-gray-800">{unreadCount}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<FaBell className="text-red-600 text-xl" />
</div>
</div>
</div>
</div>
{/* Announcements */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<FaBell className="text-blue-600" />
Announcements & Notices
</h2>
</div>
<div className="divide-y divide-gray-200">
{announcements.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500">
No announcements at this time
</div>
) : (
announcements.map((announcement) => (
<div
key={announcement.id}
className={`px-6 py-4 hover:bg-gray-50 transition-colors duration-200 cursor-pointer ${
!announcement.isRead ? 'bg-blue-50' : ''
}`}
onClick={() => !announcement.isRead && handleMarkAsRead(announcement.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3
className={`font-semibold ${
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 py-1 rounded">
Important
</span>
)}
{!announcement.isRead && (
<span className="bg-blue-100 text-blue-600 text-xs font-semibold px-2 py-1 rounded">
New
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-2">
{announcement.content}
</p>
<p className="text-gray-400 text-xs">
{formatDate(announcement.date)}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
);
};
export default DashboardPage;
/pages/TaskPageTemplate.tsx
import { useState, useEffect } from 'react';
import type { ColDef } from 'ag-grid-community';
import SearchConditions from '../components/SearchConditions/SearchConditions';
import DataArea from '../components/DataArea/DataArea';
import { gridDataAPI } from '../api';
interface TaskPageTemplateProps {
moduleName: string;
pageTitle: string;
categoryTitle: string;
columnDefs: ColDef[];
searchFields?: Array<{
name: string;
label: string;
type: 'text' | 'select' | 'date' | 'daterange';
options?: Array<{ value: string; label: string }>;
}>;
}
const TaskPageTemplate = ({
moduleName,
pageTitle,
categoryTitle,
columnDefs,
searchFields,
}: TaskPageTemplateProps) => {
const [rowData, setRowData] = useState<unknown[]>([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<Record<string, string>>({});
useEffect(() => {
loadData();
}, []);
const loadData = async (searchFilters?: Record<string, string>) => {
try {
setLoading(true);
const data = await gridDataAPI.fetchData(
moduleName,
searchFilters || filters
);
setRowData(data);
} catch (error) {
console.error('Failed to load data:', error);
alert('Failed to load data. Please try again.');
} finally {
setLoading(false);
}
};
const handleSearch = (searchFilters: Record<string, string>) => {
setFilters(searchFilters);
loadData(searchFilters);
};
const handleSave = async (data: unknown[]) => {
try {
setLoading(true);
await gridDataAPI.saveData(moduleName, data);
alert('Data saved successfully!');
loadData();
} catch (error) {
console.error('Failed to save data:', error);
alert('Failed to save data. Please try again.');
} finally {
setLoading(false);
}
};
const handleDelete = async (selectedRows: unknown[]) => {
try {
setLoading(true);
const ids = selectedRows.map((row: any) => row.id);
await gridDataAPI.deleteData(moduleName, ids);
alert('Data deleted successfully!');
loadData();
} catch (error) {
console.error('Failed to delete data:', error);
alert('Failed to delete data. Please try again.');
} finally {
setLoading(false);
}
};
const handleExcelUpload = (file: File) => {
console.log('Excel upload:', file.name);
alert(`Excel file "${file.name}" uploaded. Processing...`);
// Implement Excel parsing and data import logic
};
const handleExcelSampleDownload = () => {
console.log('Downloading Excel sample');
alert('Excel sample file download started');
// Implement sample file generation and download
};
return (
<div className="p-6 space-y-4">
{/* Breadcrumb */}
<div className="mb-4">
<h2 className="text-2xl font-bold text-gray-800">{categoryTitle}</h2>
<p className="text-gray-600 mt-1">{pageTitle}</p>
</div>
{/* Search Conditions */}
<SearchConditions onSearch={handleSearch} fields={searchFields} />
{/* Data Area with AG-Grid */}
<DataArea
rowData={rowData}
columnDefs={columnDefs}
onSave={handleSave}
onDelete={handleDelete}
onExcelUpload={handleExcelUpload}
onExcelSampleDownload={handleExcelSampleDownload}
loading={loading}
/>
</div>
);
};
export default TaskPageTemplate;
업무페이지
/pages/BasicInfo/MaterialManagement.tsx
import type { ColDef } from 'ag-grid-community';
import TaskPageTemplate from '../TaskPageTemplate';
const MaterialManagement = () => {
const columnDefs: ColDef[] = [
{
headerName: '',
checkboxSelection: true,
headerCheckboxSelection: true,
width: 50,
pinned: 'left',
},
{
headerName: 'Material Code',
field: 'code',
width: 150,
pinned: 'left',
},
{
headerName: 'Material Name',
field: 'name',
width: 250,
},
{
headerName: 'Category',
field: 'category',
width: 150,
},
{
headerName: 'Unit',
field: 'unit',
width: 100,
},
{
headerName: 'Quantity',
field: 'quantity',
width: 120,
type: 'numericColumn',
},
{
headerName: 'Unit Price',
field: 'unitPrice',
width: 120,
type: 'numericColumn',
valueFormatter: (params) => {
if (params.value) {
return `$${params.value.toLocaleString()}`;
}
return '';
},
},
{
headerName: 'Status',
field: 'status',
width: 120,
cellStyle: (params) => {
if (params.value === 'Active' || params.value === 'active') {
return { color: 'green', fontWeight: 'bold' };
}
if (params.value === 'Inactive' || params.value === 'inactive') {
return { color: 'red', fontWeight: 'bold' };
}
return { color: 'orange', fontWeight: 'bold' };
},
},
{
headerName: 'Created Date',
field: 'date',
width: 150,
},
];
const searchFields = [
{ name: 'code', label: 'Material Code', type: 'text' as const },
{ name: 'name', label: 'Material Name', type: 'text' as const },
{
name: 'category',
label: 'Category',
type: 'select' as const,
options: [
{ value: '', label: 'All Categories' },
{ value: 'raw', label: 'Raw Materials' },
{ value: 'finished', label: 'Finished Goods' },
{ value: 'spare', label: 'Spare Parts' },
],
},
{
name: 'status',
label: 'Status',
type: 'select' as const,
options: [
{ value: '', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'pending', label: 'Pending' },
],
},
{ name: 'dateFrom', label: 'Date From', type: 'date' as const },
{ name: 'dateTo', label: 'Date To', type: 'date' as const },
];
return (
<TaskPageTemplate
moduleName="material"
pageTitle="Material Management"
categoryTitle="Basic Information"
columnDefs={columnDefs}
searchFields={searchFields}
/>
);
};
export default MaterialManagement;
/components/Layout/MainLayout.tsx
import { Outlet } from 'react-router-dom';
import Header from '../Header/Header';
import Sidebar from '../Sidebar/Sidebar';
import Tabs from '../Tabs/Tabs';
import { useAppSelector } from '../../hooks';
const MainLayout = () => {
const { tabs } = useAppSelector((state) => state.ui);
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Fixed Header */}
<Header />
{/* Main Content Area: Sidebar + Content */}
<div className="flex-1 flex overflow-hidden mt-16">
{/* Sidebar */}
<Sidebar />
{/* Right Content Area */}
<div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
{/* Tabs Bar */}
{tabs.length > 0 && <Tabs />}
{/* Page Content */}
<div className="flex-1 overflow-auto">
<Outlet />
</div>
</div>
</div>
</div>
);
};
export default MainLayout;
/components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '../hooks';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const { isLoggedIn } = useAppSelector((state) => state.auth);
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;
App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from './app/store';
// Layout Components
import MainLayout from './components/Layout/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
// Pages
import LoginPage from './pages/Login/LoginPage';
import DashboardPage from './pages/Dashboard/DashboardPage';
// Basic Information Pages
import OptionManagement from './pages/BasicInfo/OptionManagement';
import MaterialManagement from './pages/BasicInfo/MaterialManagement';
import WarehouseBinsManagement from './pages/BasicInfo/WarehouseBinsManagement';
// Receiving Pages
import ArrivalManagement from './pages/Receiving/ArrivalManagement';
import PutawayManagement from './pages/Receiving/PutawayManagement';
// Issue Pages
import PickingManagement from './pages/Issue/PickingManagement';
// Inquiry Pages
import OnhandManagement from './pages/Inquiry/OnhandManagement';
function App() {
return (
<Provider store={store}>
<BrowserRouter>
<Routes>
{/* Public Route */}
<Route path="/login" element={<LoginPage />} />
{/* Protected Routes */}
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
{/* Default redirect */}
<Route index element={<Navigate to="/dashboard" replace />} />
{/* Dashboard */}
<Route path="dashboard" element={<DashboardPage />} />
{/* Basic Information */}
<Route path="basic/option" element={<OptionManagement />} />
<Route path="basic/material" element={<MaterialManagement />} />
<Route path="basic/bins" element={<WarehouseBinsManagement />} />
{/* Receiving */}
<Route path="receiving/arrival" element={<ArrivalManagement />} />
<Route path="receiving/putaway" element={<PutawayManagement />} />
{/* Issue */}
<Route path="issue/picking" element={<PickingManagement />} />
<Route
path="issue/takeover"
element={
<div className="p-6">
<h2 className="text-2xl font-bold">Takeover Management</h2>
<p className="text-gray-600 mt-2">
This page is under construction.
</p>
</div>
}
/>
<Route
path="issue/transfer"
element={
<div className="p-6">
<h2 className="text-2xl font-bold">Transfer Management</h2>
<p className="text-gray-600 mt-2">
This page is under construction.
</p>
</div>
}
/>
{/* Inquiry */}
<Route path="inquiry/onhand" element={<OnhandManagement />} />
<Route
path="inquiry/personal"
element={
<div className="p-6">
<h2 className="text-2xl font-bold">
Personal Performance Management
</h2>
<p className="text-gray-600 mt-2">
This page is under construction.
</p>
</div>
}
/>
<Route
path="inquiry/weekly"
element={
<div className="p-6">
<h2 className="text-2xl font-bold">
Personal Weekly Performance Management
</h2>
<p className="text-gray-600 mt-2">
This page is under construction.
</p>
</div>
}
/>
<Route
path="inquiry/daily"
element={
<div className="p-6">
<h2 className="text-2xl font-bold">
Personal Daily Performance Management
</h2>
<p className="text-gray-600 mt-2">
This page is under construction.
</p>
</div>
}
/>
{/* 404 Not Found */}
<Route
path="*"
element={
<div className="p-6">
<h2 className="text-2xl font-bold">Page Not Found</h2>
<p className="text-gray-600 mt-2">
The page you are looking for does not exist.
</p>
</div>
}
/>
</Route>
</Routes>
</BrowserRouter>
</Provider>
);
}
export default App;
main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
vite 설정을 확인하고 .env 파일을 생성
.env.example
# API Base URL
VITE_API_BASE_URL=http://localhost:8080/api
# Other environment variables
# VITE_APP_NAME=Warehouse Management System
.env
# API Base URL - Development
VITE_API_BASE_URL=/api
.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
PM2 설정 파일 생성
ecosystehttp://m.config.cjs
module.exports = {
apps: [
{
name: 'webapp',
script: 'npm',
args: 'run dev',
env: {
NODE_ENV: 'development',
PORT: 3000
},
watch: false,
instances: 1,
exec_mode: 'fork'
}
]
};
프로젝트 구조
webapp/
├── src/
│ ├── app/ # Redux store
│ ├── features/ # Redux slices (auth, ui, userSettings, notifications)
│ ├── components/ # 재사용 컴포넌트
│ │ ├── Header/
│ │ ├── Sidebar/
│ │ ├── Tabs/
│ │ ├── Modal/
│ │ ├── SearchConditions/
│ │ ├── DataArea/
│ │ └── Layout/
│ ├── pages/ # 페이지 컴포넌트
│ │ ├── Login/
│ │ ├── Dashboard/
│ │ ├── BasicInfo/
│ │ ├── Receiving/
│ │ ├── Issue/
│ │ └── Inquiry/
│ ├── api/ # API 호출 (stub)
│ ├── hooks/ # 커스텀 훅
│ ├── types/ # TypeScript 타입
│ └── utils/ # 유틸리티 함수
├── ecosystem.config.cjs # PM2 설정
└── package.json
로컬 개발
cd /home/user/webapp
npm install
npm run dev
완전한 타입 안정성: 모든 컴포넌트와 함수가 TypeScript로 엄격하게 타입 정의됨
Redux Toolkit 상태 관리: 인증, UI, 사용자 설정, 공지사항을 중앙에서 관리
로컬 스토리지 지속성: 사이드바 상태와 사용자 설정이 자동으로 저장됨
재사용 가능한 컴포넌트: TaskPageTemplate을 사용한 일관된 페이지 구조
프로덕션 준비 완료: 빌드, 최적화, PM2 설정 포함
--- 미 수행 내용 --
백엔드 통합: Java Spring Boot + iBatis + MariaDB API 연결
나머지 페이지 구현: 인수, 이전, 성과 관리 페이지
다국어 지원: react-i18next 추가
테스트: Jest + React Testing Library
배포: Cloudflare Pages 또는 다른 호스팅
'교육' 카테고리의 다른 글
| React 테마 디자인 추가 (0) | 2025.12.24 |
|---|---|
| 디자인 화면 분석 (0) | 2025.12.24 |
| Windows 환경 MariaDB 설치 및 기본 설정 가이드 (0) | 2025.12.23 |
| 엑셀에서 숫자 1을 0001처럼 네 자리로 표시하는 함수 (0) | 2025.12.16 |
| flushSync로 정밀한 렌더링 제어 (0) | 2025.11.19 |