본문 바로가기

교육

React 프로젝트 생성 흐름 확인

반응형

 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 또는 다른 호스팅

 

반응형