본문 바로가기

개발자정보

React 17.0.2, TypeScript 4.3.5, Recharts 2.1.5를 사용한 클래스형 컴포넌트로 구현

반응형
  • React 17.0.2, TypeScript 4.3.5, Recharts 2.1.5를 사용한 클래스형 컴포넌트로 구현
  • ag-Grid를 사용한 데이터 표시
  • 상단 메뉴바, 좌측 네비게이션, 메인 컨텐츠 영역으로 구성된 레이아웃
  • 대시보드와 코드 관리 페이지 구현
npm install react@17.0.2 react-dom@17.0.2 typescript@4.3.5 recharts@2.1.5 ag-grid-react ag-grid-community react-router-dom axios

 

1. 기본 인터페이스 및 타입 정의 (types.ts)

// types.ts
export interface User {
  name: string;
  image?: string;
  corporation: string;
  department: string;
  language: string;
  numberFormat: string;
  dateFormat: string;
  theme: string;
}

export interface Code {
  id: string;
  groupName: string;
  codeName: string;
  description?: string;
  isActive: boolean;
  corporation: string;
  language: string;
}

export interface TestPlanSummary {
  scenarioTotal: number;
  scenarioMapped: number;
  testCaseTotal: number;
  testCaseMapped: number;
  activityTotal: number;
  activityMapped: number;
}

export interface TestExecutionSummary {
  scenarioTotal: number;
  scenarioSuccess: number;
  testCaseTotal: number;
  testCaseSuccess: number;
  activityTotal: number;
  activitySuccess: number;
}

export interface AnswerSummary {
  plan: number;
  execution: number;
}

export interface AlertSummary {
  error: number;
  total: number;
}

export interface DashboardSummary {
  testPlan: TestPlanSummary;
  testExecution: TestExecutionSummary;
  answer: AnswerSummary;
  alert: AlertSummary;
}

export interface PersonalResult {
  username: string;
  scenario: number;
  testCase: number;
  activity: number;
  total: number;
}

export interface WorkTrend {
  date: string;
  type: 'process' | 'answer' | 'alert';
  success: number;
  fail: number;
  error: number;
  notExecuted: number;
}

export interface SearchCodeCriteria {
  corporation: string;
  language: string;
  groupName: string;
  codeName: string;
  isActive: boolean | null;
}

export interface MenuItem {
  id: string;
  title: string;
  icon?: string;
  path?: string;
  children?: MenuItem[];
  isOpen?: boolean;
}

 

 

2. 앱 레이아웃 구현 (App.tsx)

// App.tsx
import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import Header from './components/Header';
import SideMenu from './components/SideMenu';
import Dashboard from './pages/Dashboard';
import CodeManagement from './pages/CodeManagement';
import { User } from './types';
import './App.css';

interface AppState {
  user: User;
  corporation: string;
  authority: string;
  menuCollapsed: boolean;
}

class App extends Component<{}, AppState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      user: {
        name: '사용자',
        corporation: '본사',
        department: '개발팀',
        language: 'ko',
        numberFormat: '#,###',
        dateFormat: 'YYYY-MM-DD',
        theme: 'light'
      },
      corporation: '본사',
      authority: '관리자',
      menuCollapsed: false
    };
  }

  handleUpdateUser = (user: User) => {
    this.setState({ user });
  }

  handleUpdateCorporation = (corporation: string) => {
    this.setState({ corporation });
  }

  handleUpdateAuthority = (authority: string) => {
    this.setState({ authority });
  }

  toggleMenu = () => {
    this.setState(prevState => ({
      menuCollapsed: !prevState.menuCollapsed
    }));
  }

  render() {
    const { user, corporation, authority, menuCollapsed } = this.state;
    
    return (
      <Router>
        <div className="app">
          <Header 
            user={user}
            corporation={corporation}
            authority={authority}
            onUpdateUser={this.handleUpdateUser}
            onUpdateCorporation={this.handleUpdateCorporation}
            onUpdateAuthority={this.handleUpdateAuthority}
            onToggleMenu={this.toggleMenu}
          />
          <div className="content-container">
            <SideMenu collapsed={menuCollapsed} />
            <main className="main-content">
              <Switch>
                <Route path="/" exact component={Dashboard} />
                <Route path="/code-management" component={CodeManagement} />
                {/* 다른 라우트들도 여기에 추가 */}
              </Switch>
            </main>
          </div>
        </div>
      </Router>
    );
  }
}

export default App;

 

3. 헤더 컴포넌트 (Header.tsx)

// Header.tsx
import React, { Component } from 'react';
import { User } from '../types';
import UserSettingModal from './UserSettingModal';
import AuthorityModal from './AuthorityModal';
import CorporationModal from './CorporationModal';
import './Header.css';

interface HeaderProps {
  user: User;
  corporation: string;
  authority: string;
  onUpdateUser: (user: User) => void;
  onUpdateCorporation: (corporation: string) => void;
  onUpdateAuthority: (authority: string) => void;
  onToggleMenu: () => void;
}

interface HeaderState {
  userSettingModalOpen: boolean;
  authorityModalOpen: boolean;
  corporationModalOpen: boolean;
}

class Header extends Component<HeaderProps, HeaderState> {
  constructor(props: HeaderProps) {
    super(props);
    this.state = {
      userSettingModalOpen: false,
      authorityModalOpen: false,
      corporationModalOpen: false
    };
  }

  toggleUserSettingModal = () => {
    this.setState(prevState => ({
      userSettingModalOpen: !prevState.userSettingModalOpen
    }));
  }

  toggleAuthorityModal = () => {
    this.setState(prevState => ({
      authorityModalOpen: !prevState.authorityModalOpen
    }));
  }

  toggleCorporationModal = () => {
    this.setState(prevState => ({
      corporationModalOpen: !prevState.corporationModalOpen
    }));
  }

  render() {
    const { user, corporation, authority, onToggleMenu } = this.props;
    const { userSettingModalOpen, authorityModalOpen, corporationModalOpen } = this.state;
    
    return (
      <header className="header">
        <div className="header-left">
          <button className="menu-toggle" onClick={onToggleMenu}>
            <i className="fas fa-bars"></i>
          </button>
          <h1 className="app-title">테스트 관리 시스템</h1>
        </div>
        <div className="header-right">
          <div className="header-item" onClick={this.toggleAuthorityModal}>
            <span>권한: {authority}</span>
          </div>
          <div className="header-item" onClick={this.toggleCorporationModal}>
            <span>법인: {corporation}</span>
          </div>
          <div className="user-profile" onClick={this.toggleUserSettingModal}>
            <i className="fas fa-user-circle"></i>
          </div>
        </div>

        {userSettingModalOpen && (
          <UserSettingModal 
            user={user}
            onSave={this.props.onUpdateUser}
            onClose={this.toggleUserSettingModal}
          />
        )}

        {authorityModalOpen && (
          <AuthorityModal 
            currentAuthority={authority}
            onSave={this.props.onUpdateAuthority}
            onClose={this.toggleAuthorityModal}
          />
        )}

        {corporationModalOpen && (
          <CorporationModal 
            currentCorporation={corporation}
            onSave={this.props.onUpdateCorporation}
            onClose={this.toggleCorporationModal}
          />
        )}
      </header>
    );
  }
}

export default Header;

 

4. 사이드 메뉴 컴포넌트 (SideMenu.tsx)

// SideMenu.tsx
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { MenuItem } from '../types';
import './SideMenu.css';

interface SideMenuProps {
  collapsed: boolean;
}

interface SideMenuState {
  menuItems: MenuItem[];
}

class SideMenu extends Component<SideMenuProps, SideMenuState> {
  constructor(props: SideMenuProps) {
    super(props);
    this.state = {
      menuItems: [
        {
          id: 'dashboard',
          title: '대시보드',
          icon: 'fa-tachometer-alt',
          path: '/',
          isOpen: false
        },
        {
          id: 'basic-info',
          title: '기본정보',
          icon: 'fa-info-circle',
          isOpen: false,
          children: [
            { id: 'code-management', title: '코드관리', path: '/code-management' },
            { id: 'organization', title: '조직관리', path: '/organization' },
            { id: 'user-management', title: '사용자 관리', path: '/user-management' }
          ]
        },
        {
          id: 'test-plan',
          title: '테스트 계획',
          icon: 'fa-clipboard-list',
          isOpen: false,
          children: [
            { id: 'test-plan-main', title: '테스트 계획', path: '/test-plan' },
            { id: 'test-scenario', title: '시나리오', path: '/test-scenario' },
            { id: 'test-case', title: '케이스', path: '/test-case' },
            { id: 'partner', title: '파트너사', path: '/partner' }
          ]
        },
        {
          id: 'test-execution',
          title: '테스트 수행',
          icon: 'fa-play-circle',
          isOpen: false,
          children: [
            { id: 'test-execution-main', title: '테스트 수행', path: '/test-execution' },
            { id: 'test-results', title: '테스트 수행 결과', path: '/test-results' },
            { id: 'test-status', title: '테스트 수행 현황', path: '/test-status' }
          ]
        },
        {
          id: 'answer',
          title: '정답지',
          icon: 'fa-check-circle',
          isOpen: false,
          children: [
            { id: 'answer-plan', title: '정답지 계획', path: '/answer-plan' },
            { id: 'answer-execution', title: '정답지 수행', path: '/answer-execution' }
          ]
        },
        {
          id: 'alert',
          title: '알람',
          icon: 'fa-bell',
          isOpen: false,
          children: [
            { id: 'alert-registration', title: '알람 등록', path: '/alert-registration' },
            { id: 'alert-status', title: '알람 현황', path: '/alert-status' }
          ]
        }
      ]
    };
  }

  toggleSubmenu = (id: string) => {
    this.setState(prevState => ({
      menuItems: prevState.menuItems.map(item => 
        item.id === id 
          ? { ...item, isOpen: !item.isOpen } 
          : item
      )
    }));
  }

  renderMenuItem = (item: MenuItem) => {
    const { collapsed } = this.props;
    
    if (item.children) {
      return (
        <li key={item.id} className="menu-item parent-item">
          <div 
            className="menu-link"
            onClick={() => this.toggleSubmenu(item.id)}
          >
            {item.icon && <i className={`fas ${item.icon}`}></i>}
            {!collapsed && <span className="menu-title">{item.title}</span>}
            {!collapsed && <i className={`fas fa-chevron-${item.isOpen ? 'up' : 'down'}`}></i>}
          </div>
          {item.isOpen && !collapsed && (
            <ul className="submenu">
              {item.children.map(child => (
                <li key={child.id} className="submenu-item">
                  <Link to={child.path || '#'} className="submenu-link">
                    <span className="submenu-title">{child.title}</span>
                  </Link>
                </li>
              ))}
            </ul>
          )}
        </li>
      );
    } else {
      return (
        <li key={item.id} className="menu-item">
          <Link to={item.path || '#'} className="menu-link">
            {item.icon && <i className={`fas ${item.icon}`}></i>}
            {!collapsed && <span className="menu-title">{item.title}</span>}
          </Link>
        </li>
      );
    }
  }

  render() {
    const { collapsed } = this.props;
    const { menuItems } = this.state;
    
    return (
      <aside className={`side-menu ${collapsed ? 'collapsed' : ''}`}>
        <nav className="menu-nav">
          <ul className="menu-list">
            {menuItems.map(this.renderMenuItem)}
          </ul>
        </nav>
      </aside>
    );
  }
}

export default SideMenu;

 

5. 사용자 설정 모달 (UserSettingModal.tsx)

// UserSettingModal.tsx
import React, { Component } from 'react';
import { User } from '../types';
import './Modal.css';

interface UserSettingModalProps {
  user: User;
  onSave: (user: User) => void;
  onClose: () => void;
}

interface UserSettingModalState {
  user: User;
}

class UserSettingModal extends Component<UserSettingModalProps, UserSettingModalState> {
  constructor(props: UserSettingModalProps) {
    super(props);
    this.state = {
      user: { ...props.user }
    };
  }

  handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
    const { name, value } = e.target;
    this.setState(prevState => ({
      user: { ...prevState.user, [name]: value }
    }));
  }

  handleSave = () => {
    this.props.onSave(this.state.user);
    this.props.onClose();
  }

  render() {
    const { user } = this.state;
    
    return (
      <div className="modal-overlay">
        <div className="modal">
          <div className="modal-header">
            <h2>사용자 설정</h2>
            <button className="close-button" onClick={this.props.onClose}>
              <i className="fas fa-times"></i>
            </button>
          </div>
          <div className="modal-body">
            <div className="form-group">
              <label htmlFor="image">사진</label>
              <input 
                type="text" 
                id="image" 
                name="image" 
                value={user.image || ''} 
                onChange={this.handleChange} 
              />
            </div>
            <div className="form-group">
              <label htmlFor="corporation">법인</label>
              <input 
                type="text" 
                id="corporation" 
                name="corporation" 
                value={user.corporation} 
                onChange={this.handleChange} 
              />
            </div>
            <div className="form-group">
              <label htmlFor="department">소속</label>
              <input 
                type="text" 
                id="department" 
                name="department" 
                value={user.department} 
                onChange={this.handleChange} 
              />
            </div>
            <div className="form-group">
              <label htmlFor="language">언어</label>
              <select 
                id="language" 
                name="language" 
                value={user.language} 
                onChange={this.handleChange}
              >
                <option value="ko">한국어</option>
                <option value="en">English</option>
                <option value="ja">日本語</option>
                <option value="zh">中文</option>
              </select>
            </div>
            <div className="form-group">
              <label htmlFor="numberFormat">숫자 포맷</label>
              <select 
                id="numberFormat" 
                name="numberFormat" 
                value={user.numberFormat} 
                onChange={this.handleChange}
              >
                <option value="#,###">#,###</option>
                <option value="###,###">###,###</option>
                <option value="# ###"># ###</option>
              </select>
            </div>
            <div className="form-group">
              <label htmlFor="dateFormat">날짜 포맷</label>
              <select 
                id="dateFormat" 
                name="dateFormat" 
                value={user.dateFormat} 
                onChange={this.handleChange}
              >
                <option value="YYYY-MM-DD">YYYY-MM-DD</option>
                <option value="MM/DD/YYYY">MM/DD/YYYY</option>
                <option value="DD/MM/YYYY">DD/MM/YYYY</option>
              </select>
            </div>
            <div className="form-group">
              <label htmlFor="theme">테마 색상</label>
              <select 
                id="theme" 
                name="theme" 
                value={user.theme} 
                onChange={this.handleChange}
              >
                <option value="light">라이트 모드</option>
                <option value="dark">다크 모드</option>
                <option value="blue">블루 테마</option>
              </select>
            </div>
          </div>
          <div className="modal-footer">
            <button className="save-button" onClick={this.handleSave}>저장</button>
          </div>
        </div>
      </div>
    );
  }
}

export default UserSettingModal;

 

6. 권한 변경 모달 및 법인 변경 모달

// AuthorityModal.tsx
import React, { Component } from 'react';
import './Modal.css';

interface AuthorityModalProps {
  currentAuthority: string;
  onSave: (authority: string) => void;
  onClose: () => void;
}

interface AuthorityModalState {
  authority: string;
}

class AuthorityModal extends Component<AuthorityModalProps, AuthorityModalState> {
  constructor(props: AuthorityModalProps) {
    super(props);
    this.state = {
      authority: props.currentAuthority
    };
  }

  handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    this.setState({ authority: e.target.value });
  }

  handleSave = () => {
    this.props.onSave(this.state.authority);
    this.props.onClose();
  }

  render() {
    return (
      <div className="modal-overlay">
        <div className="modal">
          <div className="modal-header">
            <h2>권한 변경</h2>
            <button className="close-button" onClick={this.props.onClose}>
              <i className="fas fa-times"></i>
            </button>
          </div>
          <div className="modal-body">
            <div className="form-group">
              <label htmlFor="authority">권한 선택</label>
              <select 
                id="authority" 
                value={this.state.authority} 
                onChange={this.handleChange}
              >
                <option value="관리자">관리자</option>
                <option value="일반사용자">일반사용자</option>
                <option value="게스트">게스트</option>
              </select>
            </div>
          </div>
          <div className="modal-footer">
            <button className="save-button" onClick={this.handleSave}>저장</button>
          </div>
        </div>
      </div>
    );
  }
}

export default AuthorityModal;

 

 

7. 코드 관리 컴포넌트 (CodeManagement.tsx)

// CodeManagement.tsx
import React, { Component } from 'react';
import { AgGridReact } from 'ag-grid-react';
import 'ag-grid-community/dist/styles/ag-grid.css';
import 'ag-grid-community/dist/styles/ag-theme-alpine.css';
import { Code, SearchCodeCriteria } from '../types';
import './CodeManagement.css';

interface CodeManagementProps {}

interface CodeManagementState {
  searchCriteria: SearchCodeCriteria;
  codes: Code[];
  corporations: string[];
  languages: { value: string; label: string }[];
  gridApi: any;
  columnDefs: any[];
}

class CodeManagement extends Component<CodeManagementProps, CodeManagementState> {
  constructor(props: CodeManagementProps) {
    super(props);
    this.state = {
      searchCriteria: {
        corporation: '',
        language: 'ko',
        groupName: '',
        codeName: '',
        isActive: null
      },
      codes: [],
      corporations: ['본사', '한국법인', '미국법인', '중국법인', '일본법인'],
      languages: [
        { value: 'ko', label: '한국어' },
        { value: 'en', label: 'English' },
        { value: 'ja', label: '日本語' },
        { value: 'zh', label: '中文' }
      ],
      gridApi: null,
      columnDefs: [
        { headerName: '코드 그룹', field: 'groupName', sortable: true, filter: true },
        { headerName: '코드 명', field: 'codeName', sortable: true, filter: true },
        { headerName: '설명', field: 'description', sortable: true, filter: true },
        { 
          headerName: '사용여부', 
          field: 'isActive',
          sortable: true,
          filter: true,
          cellRenderer: (params: any) => params.value ? '사용' : '미사용'
        },
        { headerName: '법인', field: 'corporation', sortable: true, filter: true },
        { headerName: '언어', field: 'language', sortable: true, filter: true }
      ]
    };
  }

  componentDidMount() {
    // 데이터를 가져오는 API 호출이 있을 것임
    // 여기서는 mockup 데이터를 사용
    const mockData: Code[] = [
      { 
        id: '1', 
        groupName: '테스트 유형', 
        codeName: '단위 테스트', 
        description: '개별 기능에 대한 테스트',
        isActive: true,
        corporation: '본사',
        language: 'ko'
      },
      { 
        id: '2', 
        groupName: '테스트 유형', 
        codeName: '통합 테스트', 
        description: '여러 기능 간 통합 테스트',
        isActive: true,
        corporation: '본사',
        language: 'ko'
      },
      { 
        id: '3', 
        groupName: '테스트 단계', 
        codeName: '알파 테스트', 
        description: '내부 테스트 단계',
        isActive: true,
        corporation: '한국법인',
        language: 'ko'
      }
    ];
    
    this.setState({ codes: mockData });
  }

  onGridReady = (params: any) => {
    this.setState({ gridApi: params.api });
    params.api.sizeColumnsToFit();
  }

  handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
    const { name, value } = e.target;
    this.setState(prevState => ({
      searchCriteria: {
        ...prevState.searchCriteria,
        [name]: value
      }
    }));
  }

  handleActiveChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const value = e.target.value;
    let isActive = null;
    
    if (value === 'true') isActive = true;
    else if (value === 'false') isActive = false;
    
    this.setState(prevState => ({
      searchCriteria: {
        ...prevState.searchCriteria,
        isActive
      }
    }));
  }

  handleSearch = () => {
    // 실제로는 API 호출이 있을 것임
    // 여기서는 로컬 필터링으로 시뮬레이션
    console.log("검색 조건:", this.state.searchCriteria);
    
    // 실제 API 호출의 경우:
    // fetchCodes(this.state.searchCriteria).then(data => {
    //   this.setState({ codes: data });
    // });
  }

  handleReset = () => {
    this.setState({
      searchCriteria: {
        corporation: '',
        language: 'ko',
        groupName: '',
        codeName: '',
        isActive: null
      }
    });
  }

  render() {
    const { searchCriteria, corporations, languages, codes, columnDefs } = this.state;
    
    return (
      <div className="code-management">
        <h1>코드 관리</h1>
        
        <div className="search-section">
          <div className="search-row">
            <div className="search-field">
              <label htmlFor="corporation">법인</label>
              <select 
                id="corporation" 
                name="corporation" 
                value={searchCriteria.corporation} 
                onChange={this.handleChange}
              >
                <option value="">전체</option>
                {corporations.map(corp => (
                  <option key={corp} value={corp}>{corp}</option>
                ))}
              </select>
            </div>
            
            <div className="search-field">
              <label htmlFor="language">언어</label>
              <select 
                id="language" 
                name="language" 
                value={searchCriteria.language} 
                onChange={this.handleChange}
              >
                {languages.map(lang => (
                  <option key={lang.value} value={lang.value}>{lang.label}</option>
                ))}
              </select>
            </div>
            
            <div className="search-field">
              <label htmlFor="groupName">코드 그룹 명</label>
              <input 
                type="text" 
                id="groupName" 
                name="groupName" 
                value={searchCriteria.groupName} 
                onChange={this.handleChange} 
              />
            </div>
            
            <div className="search-field">
              <label htmlFor="codeName">코드 명</label>
              <input 
                type="text" 
                id="codeName" 
                name="codeName" 
                value={searchCriteria.codeName} 
                onChange={this.handleChange} 
              />
            </div>
            
            <div className="search-field">
              <label htmlFor="isActive">사용여부</label>
              <select 
                id="isActive" 
                value={searchCriteria.isActive === null ? '' : String(searchCriteria.isActive)} 
                onChange={this.handleActiveChange}
              >
                <option value="">전체</option>
                <option value="true">사용</option>
                <option value="false">미사용</option>
              </select>
            </div>
          </div>
          
          <div className="button-row">
            <button className="search-button" onClick={this.handleSearch}>검색</button>
            <button className="reset-button" onClick={this.handleReset}>초기화</button>
          </div>
        </div>
        
        <div className="grid-container">
          <div 
            className="ag-theme-alpine" 
            style={{ height: '500px', width: '100%' }}
          >
            <AgGridReact
              columnDefs={columnDefs}
              rowData={codes}
              pagination={true}
              paginationPageSize={10}
              onGridReady={this.onGridReady}
            />
          </div>
        </div>
      </div>
    );
  }
}

export default CodeManagement;

 

8. 대시보드 컴포넌트 (Dashboard.tsx)

// Dashboard.tsx
import React, { Component } from 'react';
import axios from 'axios';
import {
  DashboardSummary,
  PersonalResult,
  WorkTrend
} from '../types';
import PersonalResultChart from '../components/charts/PersonalResultChart';
import WorkTrendChart from '../components/charts/WorkTrendChart';
import './Dashboard.css';

interface DashboardProps {}

interface DashboardState {
  summary: DashboardSummary;
  personalResults: PersonalResult[];
  workTrends: WorkTrend[];
  isLoading: boolean;
  lastUpdated: string;
}

class Dashboard extends Component<DashboardProps, DashboardState> {
  constructor(props: DashboardProps) {
    super(props);
    
    // 초기 상태 설정
    this.state = {
      summary: {
        testPlan: {
          scenarioTotal: 0,
          scenarioMapped: 0,
          testCaseTotal: 0,
          testCaseMapped: 0,
          activityTotal: 0,
          activityMapped: 0
        },
        testExecution: {
          scenarioTotal: 0,
          scenarioSuccess: 0,
          testCaseTotal: 0,
          testCaseSuccess: 0,
          activityTotal: 0,
          activitySuccess: 0
        },
        answer: {
          plan: 0,
          execution: 0
        },
        alert: {
          error: 0,
          total: 0
        }
      },
      personalResults: [],
      workTrends: [],
      isLoading: true,
      lastUpdated: new Date().toLocaleString()
    };
  }

  componentDidMount() {
    this.fetchDashboardData();
  }

  fetchDashboardData = async () => {
    this.setState({ isLoading: true });
    
    try {
      // 실제 구현에서는 API 호출이 이루어질 것임
      // 여기서는 모의 데이터 사용
      // const response = await axios.get('/api/dashboard');
      // const data = response.data;
      
      // 모의 데이터
      const mockData = {
        summary: {
          testPlan: {
            scenarioTotal: 150,
            scenarioMapped: 120,
            testCaseTotal: 450,
            testCaseMapped: 350,
            activityTotal: 900,
            activityMapped: 750
          },
          testExecution: {
            scenarioTotal: 120,
            scenarioSuccess: 90,
            testCaseTotal: 350,
            testCaseSuccess: 280,
            activityTotal: 750,
            activitySuccess: 600
          },
          answer: {
            plan: 50,
            execution: 42
          },
          alert: {
            error: 15,
            total: 100
          }
        },
        personalResults: [
          { username: '담당자1', scenario: 45, testCase: 130, activity: 260, total: 435 },
          { username: '담당자2', scenario: 40, testCase: 120, activity: 240, total: 400 },
          { username: '담당자3', scenario: 35, testCase: 100, activity: 250, total: 385 }
        ],
        workTrends: [
          {
            date: '2025-05-10',
            type: 'process',
            success: 120,
            fail: 20,
            error: 10,
            notExecuted: 30
          },
          {
            date: '2025-05-11',
            type: 'process',
            success: 125,
            fail: 18,
            error: 8,
            notExecuted: 25
          },
          {
            date: '2025-05-12',
            type: 'process',
            success: 130,
            fail: 15,
            error: 7,
            notExecuted: 20
          },
          {
            date: '2025-05-13',
            type: 'process',
            success: 135,
            fail: 12,
            error: 5,
            notExecuted: 15
          },
          {
            date: '2025-05-14',
            type: 'process',
            success: 140,
            fail: 10,
            error: 3,
            notExecuted: 10
          },
          {
            date: '2025-05-10',
            type: 'answer',
            success: 35,
            fail: 8,
            error: 4,
            notExecuted: 3
          },
          {
            date: '2025-05-11',
            type: 'answer',
            success: 36,
            fail: 7,
            error: 4,
            notExecuted: 3
          },
          {
            date: '2025-05-12',
            type: 'answer',
            success: 38,
            fail: 6,
            error: 3,
            notExecuted: 3
          },
          {
            date: '2025-05-13',
            type: 'answer',
            success: 39,
            fail: 6,
            error: 2,
            notExecuted: 3
          },
          {
            date: '2025-05-14',
            type: 'answer',
            success: 40,
            fail: 5,
            error: 2,
            notExecuted: 3
          },
          {
            date: '2025-05-10',
            type: 'alert',
            success: 75,
            fail: 15,
            error: 8,
            notExecuted: 2
          },
          {
            date: '2025-05-11',
            type: 'alert',
            success: 78,
            fail: 14,
            error: 7,
            notExecuted: 1
          },
          {
            date: '2025-05-12',
            type: 'alert',
            success: 80,
            fail: 12,
            error: 7,
            notExecuted: 1
          },
          {
            date: '2025-05-13',
            type: 'alert',
            success: 82,
            fail: 11,
            error: 6,
            notExecuted: 1
          },
          {
            date: '2025-05-14',
            type: 'alert',
            success: 85,
            fail: 10,
            error: 5,
            notExecuted: 0
          }
        ]
      };
      
      this.setState({
        summary: mockData.summary,
        personalResults: mockData.personalResults,
        workTrends: mockData.workTrends,
        isLoading: false,
        lastUpdated: new Date().toLocaleString()
      });
    } catch (error) {
      console.error('대시보드 데이터를 가져오는 중 오류 발생:', error);
      this.setState({ isLoading: false });
    }
  }

  handleRefreshDashboard = () => {
    // API 호출을 통한 실제 새로고침
    // axios.get('127.0.0.1:8080/do/initDashboard')
    //   .then(response => {
    //     this.fetchDashboardData();
    //   })
    //   .catch(error => {
    //     console.error('대시보드 새로고침 중 오류 발생:', error);
    //   });
    
    // 여기서는 데이터를 다시 가져오는 방식으로 시뮬레이션
    this.fetchDashboardData();
  }

  render() {
    const { summary, personalResults, workTrends, isLoading, lastUpdated } = this.state;
    
    if (isLoading) {
      return <div className="dashboard-loading">로딩 중...</div>;
    }
    
    // 최근 5일간의 데이터만 필터링
    const latestDates = Array.from(new Set(workTrends.map(wt => wt.date))).sort().slice(-5);
    const filteredWorkTrends = workTrends.filter(wt => latestDates.includes(wt.date));
    
    return (
      <div className="dashboard">
        <h1>대시보드</h1>
        
        {/* 요약 통계 */}
        <div className="summary-section">
          <div className="summary-card">
            <h2>테스트 계획</h2>
            <div className="summary-stats">
              <div className="stat-item">
                <span className="stat-label">시나리오</span>
                <span className="stat-value">{summary.testPlan.scenarioMapped}/{summary.testPlan.scenarioTotal}</span>
              </div>
              <div className="stat-item">
                <span className="stat-label">테스트 케이스</span>
                <span className="stat-value">{summary.testPlan.testCaseMapped}/{summary.testPlan.testCaseTotal}</span>
              </div>
              <div className="stat-item">
                <span className="stat-label">액티비티</span>
                <span className="stat-value">{summary.testPlan.activityMapped}/{summary.testPlan.activityTotal}</span>
              </div>
            </div>
          </div>
          
          <div className="summary-card">
            <h2>테스트 수행</h2>
            <div className="summary-stats">
              <div className="stat-item">
                <span className="stat-label">시나리오</span>
                <span className="stat-value">{summary.testExecution.scenarioSuccess}/{summary.testExecution.scenarioTotal}</span>
              </div>
              <div className="stat-item">
                <span className="stat-label">테스트 케이스</span>
                <span className="stat-value">{summary.testExecution.testCaseSuccess}/{summary.testExecution.testCaseTotal}</span>
              </div>
              <div className="stat-item">
                <span className="stat-label">액티비티</span>
                <span className="stat-value">{summary.testExecution.activitySuccess}/{summary.testExecution.activityTotal}</span>
              </div>
            </div>
          </div>
          
          <div className="summary-card">
            <h2>정답지</h2>
            <div className="summary-stats">
              <div className="stat-item">
                <span className="stat-label">계획</span>
                <span className="stat-value">{summary.answer.plan}</span>
              </div>
              <div className="stat-item">
                <span className="stat-label">수행</span>
                <span className="stat-value">{summary.answer.execution}</span>
              </div>
            </div>
          </div>
          
          <div className="summary-card">
            <h2>알람</h2>
            <div className="summary-stats">
              <div className="stat-item">
                <span className="stat-label">오류/체크대상</span>
                <span className="stat-value">{summary.alert.error}/{summary.alert.total}</span>
              </div>
            </div>
          </div>
        </div>
        
        {/* 차트 섹션 */}
        <div className="charts-section">
          <div className="chart-container">
            <h2>Personal Result</h2>
            <PersonalResultChart data={personalResults} />
          </div>
          
          <div className="chart-container">
            <h2>Work Trend</h2>
            <WorkTrendChart data={filteredWorkTrends} />
          </div>
        </div>
        
        {/* 대시보드 푸터 */}
        <div className="dashboard-footer">
          <span className="last-updated">Last updated: {lastUpdated}</span>
          <button className="refresh-button" onClick={this.handleRefreshDashboard}>
            <i className="fas fa-sync-alt"></i>
          </button>
        </div>
      </div>
    );
  }
}

export default Dashboard;

 

9. 대시보드 차트 컴포넌트

// PersonalResultChart.tsx
import React, { Component } from 'react';
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer
} from 'recharts';
import { PersonalResult } from '../../types';

interface PersonalResultChartProps {
  data: PersonalResult[];
}

class PersonalResultChart extends Component<PersonalResultChartProps> {
  render() {
    const { data } = this.props;
    
    return (
      <ResponsiveContainer width="100%" height={300}>
        <BarChart
          data={data}
          margin={{
            top: 20,
            right: 30,
            left: 20,
            bottom: 5
          }}
        >
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="username" />
          <YAxis />
          <Tooltip />
          <Legend />
          <Bar dataKey="scenario" stackId="a" fill="#8884d8" name="시나리오" />
          <Bar dataKey="testCase" stackId="a" fill="#82ca9d" name="테스트 케이스" />
          <Bar dataKey="activity" stackId="a" fill="#ffc658" name="액티비티" />
        </BarChart>
      </ResponsiveContainer>
    );
  }
}

export default PersonalResultChart;

 

 

// WorkTrendChart.tsx
import React, { Component } from 'react';
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer
} from 'recharts';
import { WorkTrend } from '../../types';

interface WorkTrendChartProps {
  data: WorkTrend[];
}

interface FormattedWorkTrend {
  date: string;
  processSuccess: number;
  processFail: number;
  processError: number;
  processNotExecuted: number;
  answerSuccess: number;
  answerFail: number;
  answerError: number;
  answerNotExecuted: number;
  alertSuccess: number;
  alertFail: number;
  alertError: number;
  alertNotExecuted: number;
}

class WorkTrendChart extends Component<WorkTrendChartProps> {
  formatData = (data: WorkTrend[]): FormattedWorkTrend[] => {
    const dateMap = new Map<string, FormattedWorkTrend>();
    
    data.forEach(item => {
      if (!dateMap.has(item.date)) {
        dateMap.set(item.date, {
          date: item.date,
          processSuccess: 0,
          processFail: 0,
          processError: 0,
          processNotExecuted: 0,
          answerSuccess: 0,
          answerFail: 0,
          answerError: 0,
          answerNotExecuted: 0,
          alertSuccess: 0,
          alertFail: 0,
          alertError: 0,
          alertNotExecuted: 0
        });
      }
      
      const entry = dateMap.get(item.date)!;
      
      switch (item.type) {
        case 'process':
          entry.processSuccess = item.success;
          entry.processFail = item.fail;
          entry.processError = item.error;
          entry.processNotExecuted = item.notExecuted;
          break;
        case 'answer':
          entry.answerSuccess = item.success;
          entry.answerFail = item.fail;
          entry.answerError = item.error;
          entry.answerNotExecuted = item.notExecuted;
          break;
        case 'alert':
          entry.alertSuccess = item.success;
          entry.alertFail = item.fail;
          entry.alertError = item.error;
          entry.alertNotExecuted = item.notExecuted;
          break;
      }
    });
    
    return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
  }

  render() {
    const { data } = this.props;
    const formattedData = this.formatData(data);
    
    // 최근 5일간의 데이터만 표시
    const lastFiveDays = formattedData.slice(-5);
    
    return (
      <div className="work-trend-charts">
        <div className="chart-row">
          <div className="chart-title">프로세스</div>
          <ResponsiveContainer width="100%" height={120}>
            <BarChart
              data={lastFiveDays}
              margin={{
                top: 5,
                right: 30,
                left: 20,
                bottom: 5
              }}
            >
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="date" />
              <YAxis />
              <Tooltip />
              <Legend />
              <Bar dataKey="processSuccess" stackId="a" fill="#82ca9d" name="성공" />
              <Bar dataKey="processFail" stackId="a" fill="#ff8042" name="실패" />
              <Bar dataKey="processError" stackId="a" fill="#ff0000" name="오류" />
              <Bar dataKey="processNotExecuted" stackId="a" fill="#d3d3d3" name="비수행" />
            </BarChart>
          </ResponsiveContainer>
        </div>
        
        <div className="chart-row">
          <div className="chart-title">정답지</div>
          <ResponsiveContainer width="100%" height={120}>
            <BarChart
              data={lastFiveDays}
              margin={{
                top: 5,
                right: 30,
                left: 20,
                bottom: 5
              }}
            >
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="date" />
              <YAxis />
              <Tooltip />
              <Legend />
              <Bar dataKey="answerSuccess" stackId="a" fill="#82ca9d" name="성공" />
              <Bar dataKey="answerFail" stackId="a" fill="#ff8042" name="실패" />
              <Bar dataKey="answerError" stackId="a" fill="#ff0000" name="오류" />
              <Bar dataKey="answerNotExecuted" stackId="a" fill="#d3d3d3" name="비수행" />
            </BarChart>
          </ResponsiveContainer>
        </div>
        
        <div className="chart-row">
          <div className="chart-title">알람</div>
          <ResponsiveContainer width="100%" height={120}>
            <BarChart
              data={lastFiveDays}
              margin={{
                top: 5,
                right: 30,
                left: 20,
                bottom: 5
              }}
            >
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="date" />
              <YAxis />
              <Tooltip />
              <Legend />
              <Bar dataKey="alertSuccess" stackId="a" fill="#82ca9d" name="성공" />
              <Bar dataKey="alertFail" stackId="a" fill="#ff8042" name="실패" />
              <Bar dataKey="alertError" stackId="a" fill="#ff0000" name="오류" />
              <Bar dataKey="alertNotExecuted" stackId="a" fill="#d3d3d3" name="비수행" />
            </BarChart>
          </ResponsiveContainer>
        </div>
      </div>
    );
  }
}

export default WorkTrendChart;

 

10. CSS 파일들

/* App.css */
.app {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.content-container {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.main-content {
  flex: 1;
  padding: 20px;
  overflow-y: auto;
}

 

 

/* Header.css */
.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 20px;
  height: 60px;
  background-color: #2c3e50;
  color: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.header-left {
  display: flex;
  align-items: center;
}

.menu-toggle {
  background: none;
  border: none;
  color: white;
  font-size: 1.2rem;
  cursor: pointer;
  margin-right: 15px;
}

.app-title {
  margin: 0;
  font-size: 1.5rem;
}

.header-right {
  display: flex;
  align-items: center;
}

.header-item {
  margin-right: 20px;
  cursor: pointer;
}

.header-item:hover {
  text-decoration: underline;
}

.user-profile {
  font-size: 1.8rem;
  cursor: pointer;
}

 

/* SideMenu.css */
.side-menu {
  width: 250px;
  background-color: #34495e;
  color: white;
  transition: width 0.3s ease;
  overflow-y: auto;
}

.side-menu.collapsed {
  width: 60px;
}

.menu-nav {
  padding: 10px 0;
}

.menu-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.menu-item {
  margin-bottom: 5px;
}

.menu-link {
  display: flex;
  align-items: center;
  padding: 12px 20px;
  color: white;
  text-decoration: none;
  cursor: pointer;
}

.menu-link:hover {
  background-color: #2c3e50;
}

.menu-link i {
  width: 20px;
  text-align: center;
  margin-right: 10px;
}

.parent-item > .menu-link {
  justify-content: space-between;
}

.submenu {
  list-style: none;
  padding-left: 20px;
  margin: 0;
}

.submenu-item {
  margin: 5px 0;
}

.submenu-link {
  display: block;
  padding: 8px 20px;
  color: #ccc;
  text-decoration: none;
}

.submenu-link:hover {
  background-color: #2c3e50;
  color: white;
}

 

/* Modal.css */
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal {
  background-color: white;
  border-radius: 5px;
  padding: 0;
  width: 400px;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px 20px;
  border-bottom: 1px solid #eee;
}

.modal-header h2 {
  margin: 0;
  font-size: 1.5rem;
}

.close-button {
  background: none;
  border: none;
  font-size: 1.2rem;
  cursor: pointer;
}

.modal-body {
  padding: 20px;
  max-height: 400px;
  overflow-y: auto;
}

.modal-footer {
  padding: 15px 20px;
  border-top: 1px solid #eee;
  text-align: right;
}

.form-group {
  margin-bottom: 15px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: 500;
}

.form-group input,
.form-group select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.save-button {
  background-color: #3498db;
  color: white;
  border: none;
  padding: 8px 15px;
  border-radius: 4px;
  cursor: pointer;
}

.save-button:hover {
  background-color: #2980b9;
}

.search-box {
  margin-bottom: 15px;
}

.search-box input {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.corporation-list {
  max-height: 250px;
  overflow-y: auto;
}

.corporation-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
  cursor: pointer;
}

.corporation-item:hover {
  background-color: #f5f5f5;
}

.corporation-item.selected {
  background-color: #e3f2fd;
  font-weight: bold;
}

 

/* CodeManagement.css */
.code-management {
  padding: 20px;
}

.search-section {
  background-color: #f5f5f5;
  padding: 15px;
  border-radius: 5px;
  margin-bottom: 20px;
}

.search-row {
  display: flex;
  flex-wrap: wrap;
  gap: 15px;
  margin-bottom: 15px;
}

.search-field {
  flex: 1;
  min-width: 200px;
}

.search-field label {
  display: block;
  margin-bottom: 5px;
  font-weight: 500;
}

.search-field input,
.search-field select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.button-row {
  display: flex;
  justify-content: center;
  gap: 10px;
}

.search-button,
.reset-button {
  padding: 8px 15px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.search-button {
  background-color: #3498db;
  color: white;
}

.reset-button {
  background-color: #95a5a6;
  color: white;
}

.grid-container {
  height: calc(100vh - 300px);
  min-height: 400px;
}

 

 

 

npm start

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

반응형