반응형
- 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
반응형
'개발자정보' 카테고리의 다른 글
LuckyFrame 오픈소스 자동화 테스트 플랫폼 - 자동화 테스트 소개 (0) | 2025.05.16 |
---|---|
LuckyFrame 자동화 테스트 플랫폼 SQL 스크립트 초기화 (1) | 2025.05.16 |
React 17 챠트 에제 클래스형 컴퍼넌트 (0) | 2025.05.14 |
LM Studio 설치 가이드 (1) | 2025.05.13 |
React17, Typescript 생성 (0) | 2025.05.11 |