Files
eapim-admin/WebContent/jsp/dashboard-modern/components/dashboard-transactions.jsp
T
youna 972819b492 모던 대시보드 패치
- ApexCharts 기반 실시간 모니터링 대시보드
- 어댑터/배치/서버/소켓/트랜잭션 모니터링 컴포넌트
- 처리량 계산 및 데이터 매핑 서비스
2025-11-14 17:59:06 +09:00

835 lines
29 KiB
Plaintext

<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.util.*" %>
<%@ page import="com.eactive.eai.rms.common.datasource.*" %>
<%@ page import="java.util.stream.Collectors" %>
<div class="dashboard-transactions">
<div class="dashboard-header">
<div class="header-title-container">
<h2 class="section-title">거래처리 집계(일누적)</h2>
</div>
</div>
<div class="card shadow mb-0" style="overflow-y:auto;">
<div class="transaction-filters">
<div class="filter-group">
<button class="tran-filter-btn active" data-type="all">전체</button>
<%
// serverTypeMap 변수를 가져오기
Map<String, List<DataSourceType>> serverTypeMap =
(Map<String, List<DataSourceType>>)request.getAttribute("serverTypeMap");
if (serverTypeMap == null) {
// application 스코프에서 가져오기 시도
serverTypeMap = (Map<String, List<DataSourceType>>)application.getAttribute("serverTypeMap");
}
Map<String, Integer> priorityMap = new HashMap<>();
priorityMap.put("MCI", 0);
priorityMap.put("EAI", 1);
priorityMap.put("FEP", 2);
priorityMap.put("CGW", 3);
priorityMap.put("FGW", 4);
priorityMap.put("API", 5);
priorityMap.put("APIGW", 6);
priorityMap.put("ONL", 7);
Map<String,List<DataSourceType>> sortedMap = serverTypeMap.entrySet().stream()
.sorted(Comparator.comparingInt(e -> priorityMap.getOrDefault(e.getKey(), Integer.MAX_VALUE)))
.collect(Collectors.toMap(
Map.Entry::getKey
, Map.Entry::getValue
,(e1, e2) -> e1
, LinkedHashMap::new
));
if (sortedMap != null) {
// 서버 타입별 필터 버튼 생성
for (Map.Entry<String, List<DataSourceType>> entry : sortedMap.entrySet()) {
String serverType = entry.getKey();
List<DataSourceType> servers = entry.getValue();
// BAT 서버는 제외
if (DataSourceTypeManager.BAT.equals(serverType) || DataSourceTypeManager.BAP.equals(serverType)) {
continue;
}
String serverTypeText = servers.size() > 0 ? servers.get(0).getText() : serverType;
if( !serverTypeText.endsWith("GW") ) {
%>
<button class="tran-filter-btn" data-type="<%= serverType.toLowerCase() %>"><%= serverTypeText %></button>
<% }
}
} %>
</div>
</div>
<div class="transaction-table-container">
<table class="transaction-table">
<thead>
<tr>
<th style="width: 1%"></th>
<th>업무그룹</th>
<th>정상처리</th>
<th>업무에러</th>
<th>타임아웃</th>
<th>통신에러</th>
</tr>
</thead>
<tbody id="transaction-table-body">
<!-- 거래 처리 데이터가 동적으로 추가됩니다 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 거래 처리 행 템플릿 -->
<template id="transaction-row-template">
<tr>
<td>
<span class="instance-status status-normal"></span>
</td>
<td class="group-name">업무그룹</td>
<td>
<div class="metric">
<div class="metric-value">
<span class="clickable transaction-count">0</span>
<span class="metric-change transaction-change" style="visibility: hidden;"><i class="bi bi-arrow-up-short"></i>0</span>
</div>
</div>
</td>
<td>
<div class="metric">
<div class="metric-value">
<span class="clickable error-count">0</span>
<span class="metric-change error-change" style="visibility: hidden;"><i class="bi bi-arrow-up-short"></i>0</span>
</div>
</div>
</td>
<td>
<div class="metric">
<div class="metric-value">
<span class="clickable timeout-count">0</span>
<span class="metric-change timeout-change" style="visibility: hidden;"><i class="bi bi-arrow-up-short"></i>0</span>
</div>
</div>
</td>
<td>
<div class="metric">
<div class="metric-value">
<span class="clickable syserror-count">0</span>
<span class="metric-change syserror-change" style="visibility: hidden;"><i class="bi bi-arrow-up-short"></i>0</span>
</div>
</div>
</td>
</tr>
</template>
<script>
// 데이터 로딩 상태 플래그
var isTransactionDataLoading = false;
// 이전 데이터 저장 객체
var previousTransactionData = {};
// 숫자 형식화 함수 (천 단위 콤마 추가)
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// 기존 행 업데이트 함수 수정
function updateExistingRow(row, transactionData) {
// 상태 업데이트
var statusElement = row.querySelector('.instance-status');
statusElement.className = 'instance-status';
// 정상 처리와 오류 비율에 따라 색상 계산
var totalCount = (transactionData.count || 0);
var errorCount = (transactionData.error || 0) + (transactionData.timeout || 0) + (transactionData.syserror || 0);
var totalTransactions = totalCount + errorCount;
// 색상 설정
if (totalTransactions > 0) {
var successRatio = totalCount / totalTransactions;
var statusColor = calculateStatusColor(successRatio);
statusElement.style.backgroundColor = statusColor;
} else {
// 거래가 없는 경우 회색으로 표시
statusElement.style.backgroundColor = '#9e9e9e';
}
// 이전 데이터 가져오기
var prevData = previousTransactionData[transactionData.name] || {
count: 0,
error: 0,
timeout: 0,
syserror: 0
};
// 정상처리 건수 업데이트
var transactionCount = row.querySelector('.transaction-count');
transactionCount.textContent = formatNumber(transactionData.count || 0);
// 증가량 표시
var transactionChange = row.querySelector('.transaction-change');
var countDiff = (transactionData.count || 0) - prevData.count;
if (countDiff > 0) {
transactionChange.innerHTML = '<i class="bi bi-arrow-up-short"></i>' + formatNumber(countDiff);
transactionChange.style.visibility = 'visible';
// 2초 후 사라지게 설정
setTimeout(function() {
transactionChange.style.visibility = 'hidden';
}, 2000);
} else {
transactionChange.style.visibility = 'hidden';
}
// 업무에러 업데이트
var errorCount = row.querySelector('.error-count');
errorCount.textContent = formatNumber(transactionData.error || 0);
// 증가량 표시
var errorChange = row.querySelector('.error-change');
var errorDiff = (transactionData.error || 0) - prevData.error;
if (errorDiff > 0) {
errorChange.innerHTML = '<i class="bi bi-arrow-up-short"></i>' + formatNumber(errorDiff);
errorChange.style.visibility = 'visible';
// 2초 후 사라지게 설정
setTimeout(function() {
errorChange.style.visibility = 'hidden';
}, 2000);
} else {
errorChange.style.visibility = 'hidden';
}
// 타임아웃 업데이트
var timeoutCount = row.querySelector('.timeout-count');
timeoutCount.textContent = formatNumber(transactionData.timeout || 0);
// 증가량 표시
var timeoutChange = row.querySelector('.timeout-change');
var timeoutDiff = (transactionData.timeout || 0) - prevData.timeout;
if (timeoutDiff > 0) {
timeoutChange.innerHTML = '<i class="bi bi-arrow-up-short"></i>' + formatNumber(timeoutDiff);
timeoutChange.style.visibility = 'visible';
// 2초 후 사라지게 설정
setTimeout(function() {
timeoutChange.style.visibility = 'hidden';
}, 2000);
} else {
timeoutChange.style.visibility = 'hidden';
}
// 통신에러 업데이트
var syserrorCount = row.querySelector('.syserror-count');
syserrorCount.textContent = formatNumber(transactionData.syserror || 0);
// 증가량 표시
var syserrorChange = row.querySelector('.syserror-change');
var syserrorDiff = (transactionData.syserror || 0) - prevData.syserror;
if (syserrorDiff > 0) {
syserrorChange.innerHTML = '<i class="bi bi-arrow-up-short"></i>' + formatNumber(syserrorDiff);
syserrorChange.style.visibility = 'visible';
// 2초 후 사라지게 설정
setTimeout(function() {
syserrorChange.style.visibility = 'hidden';
}, 2000);
} else {
syserrorChange.style.visibility = 'hidden';
}
// 현재 데이터를 이전 데이터로 저장
previousTransactionData[transactionData.name] = {
count: transactionData.count || 0,
error: transactionData.error || 0,
timeout: transactionData.timeout || 0,
syserror: transactionData.syserror || 0
};
}
// 거래 행 생성 함수 수정
function createTransactionRow(transactionData) {
var template = document.getElementById('transaction-row-template');
var clone = document.importNode(template.content, true);
// 업무그룹 이름 설정
clone.querySelector('.group-name').textContent = transactionData.name;
// 정상 처리와 오류 비율에 따라 색상 계산
var totalCount = (transactionData.count || 0);
var errorCount = (transactionData.error || 0) + (transactionData.timeout || 0) + (transactionData.syserror || 0);
var totalTransactions = totalCount + errorCount;
// 상태 설정
var statusElement = clone.querySelector('.instance-status');
// 색상 설정
if (totalTransactions > 0) {
var successRatio = totalCount / totalTransactions;
var statusColor = calculateStatusColor(successRatio);
statusElement.style.backgroundColor = statusColor;
} else {
// 거래가 없는 경우 회색으로 표시
statusElement.style.backgroundColor = '#9e9e9e';
}
// 정상처리 건수 설정
clone.querySelector('.transaction-count').textContent = formatNumber(transactionData.count || 0);
// 업무에러 설정
clone.querySelector('.error-count').textContent = formatNumber(transactionData.error || 0);
// 타임아웃 설정
clone.querySelector('.timeout-count').textContent = formatNumber(transactionData.timeout || 0);
// 통신에러 설정
clone.querySelector('.syserror-count').textContent = formatNumber(transactionData.syserror || 0);
// 현재 데이터를 이전 데이터로 저장
previousTransactionData[transactionData.name] = {
count: transactionData.count || 0,
error: transactionData.error || 0,
timeout: transactionData.timeout || 0,
syserror: transactionData.syserror || 0
};
return clone;
}
// 거래 건수 테이블 업데이트 함수
function updateTransactionTable(data) {
if (!data || !data.transactions) return;
var tableBody = document.getElementById('transaction-table-body');
// 합계 값 초기화
var totalCount = 0;
var totalError = 0;
var totalTimeout = 0;
var totalSyserror = 0;
// 현재 테이블에 있는 행들 저장
var currentRows = {}; // 현재 행들을 저장할 객체
// 현재 테이블의 상태를 저장
Array.from(tableBody.querySelectorAll('tr:not(.total-row)')).forEach(function(row) {
var transactionType = row.querySelector('.group-name').textContent;
currentRows[transactionType] = row;
});
// 합계 행 제거 (나중에 다시 추가)
var totalRow = tableBody.querySelector('.total-row');
if (totalRow) {
tableBody.removeChild(totalRow);
}
// 거래 데이터 행 업데이트 또는 추가
data.transactions.forEach(function(transaction) {
// TOTAL 항목은 표에 표시하지 않고 마지막에 별도로 처리
// APIGW는 표시하고, CGW, FGW는 제외
if (transaction.type === 'TOTAL' || (transaction.type.endsWith("GW") && transaction.type !== 'APIGW') ) {
return;
}
// 각 거래별 데이터 설정
var transactionData = {
name: transaction.type,
status: 'normal',
count: transaction.success || 0,
error: transaction.fail || 0,
timeout: transaction.timeout || 0,
syserror: transaction.syserror || 0
};
// 상태 설정: 에러가 많으면 경고 또는 오류 상태로 표시
if ((transaction.fail + transaction.timeout + transaction.syserror) > 50) {
transactionData.status = 'error';
} else if ((transaction.fail + transaction.timeout + transaction.syserror) > 10) {
transactionData.status = 'warning';
}
// 이미 존재하는 행인지 확인하여 업데이트 또는 새로 생성
if (currentRows[transaction.type]) {
updateExistingRow(currentRows[transaction.type], transactionData);
} else {
// 새 행 생성 및 추가
var newRow = createTransactionRow(transactionData);
tableBody.appendChild(newRow);
}
// 합계 계산
totalCount += transactionData.count;
totalError += transactionData.error;
totalTimeout += transactionData.timeout;
totalSyserror += transactionData.syserror;
});
// 사라진 행 제거 (현재 데이터에 없는 행)
var remainingTypes = data.transactions
.filter(t => t.type !== 'TOTAL')
.map(t => t.type);
for (var type in currentRows) {
if (!remainingTypes.includes(type)) {
tableBody.removeChild(currentRows[type]);
}
}
// 합계 행 생성 및 추가
totalRow = document.createElement('tr');
totalRow.className = 'total-row';
// 상태 아이콘 셀
var statusCell = document.createElement('td');
totalRow.appendChild(statusCell);
// 업무그룹 셀
var nameCell = document.createElement('td');
nameCell.className = 'group-name';
nameCell.textContent = '합계';
totalRow.appendChild(nameCell);
// 정상처리 셀
var countCell = document.createElement('td');
var countMetric = document.createElement('div');
countMetric.className = 'metric';
var countValue = document.createElement('div');
countValue.className = 'metric-value';
var countSpan = document.createElement('span');
countSpan.className = 'transaction-count';
countSpan.textContent = formatNumber(totalCount);
// 가짜 metric-change span 추가 (정상처리)
var countChangeSpan = document.createElement('span');
countChangeSpan.className = 'metric-change transaction-change';
countChangeSpan.style.visibility = 'hidden';
countChangeSpan.innerHTML = '<i class="bi bi-arrow-up-short"></i>0';
countValue.appendChild(countSpan);
countValue.appendChild(countChangeSpan); // 가짜 span 추가
countMetric.appendChild(countValue);
countCell.appendChild(countMetric);
totalRow.appendChild(countCell);
// 업무에러 셀
var errorCell = document.createElement('td');
var errorMetric = document.createElement('div');
errorMetric.className = 'metric';
var errorValue = document.createElement('div');
errorValue.className = 'metric-value';
var errorSpan = document.createElement('span');
errorSpan.className = 'error-count';
errorSpan.textContent = formatNumber(totalError);
// 가짜 metric-change span 추가 (업무에러)
var errorChangeSpan = document.createElement('span');
errorChangeSpan.className = 'metric-change error-change';
errorChangeSpan.style.visibility = 'hidden';
errorChangeSpan.innerHTML = '<i class="bi bi-arrow-up-short"></i>0';
errorValue.appendChild(errorSpan);
errorValue.appendChild(errorChangeSpan); // 가짜 span 추가
errorMetric.appendChild(errorValue);
errorCell.appendChild(errorMetric);
totalRow.appendChild(errorCell);
// 타임아웃 셀
var timeoutCell = document.createElement('td');
var timeoutMetric = document.createElement('div');
timeoutMetric.className = 'metric';
var timeoutValue = document.createElement('div');
timeoutValue.className = 'metric-value';
var timeoutSpan = document.createElement('span');
timeoutSpan.className = 'timeout-count';
timeoutSpan.textContent = formatNumber(totalTimeout);
// 가짜 metric-change span 추가 (타임아웃)
var timeoutChangeSpan = document.createElement('span');
timeoutChangeSpan.className = 'metric-change timeout-change';
timeoutChangeSpan.style.visibility = 'hidden';
timeoutChangeSpan.innerHTML = '<i class="bi bi-arrow-up-short"></i>0';
timeoutValue.appendChild(timeoutSpan);
timeoutValue.appendChild(timeoutChangeSpan); // 가짜 span 추가
timeoutMetric.appendChild(timeoutValue);
timeoutCell.appendChild(timeoutMetric);
totalRow.appendChild(timeoutCell);
// 통신에러 셀
var syserrorCell = document.createElement('td');
var syserrorMetric = document.createElement('div');
syserrorMetric.className = 'metric';
var syserrorValue = document.createElement('div');
syserrorValue.className = 'metric-value';
var syserrorSpan = document.createElement('span');
syserrorSpan.className = 'syserror-count';
syserrorSpan.textContent = formatNumber(totalSyserror);
// 가짜 metric-change span 추가 (통신에러)
var syserrorChangeSpan = document.createElement('span');
syserrorChangeSpan.className = 'metric-change syserror-change';
syserrorChangeSpan.style.visibility = 'hidden';
syserrorChangeSpan.innerHTML = '<i class="bi bi-arrow-up-short"></i>0';
syserrorValue.appendChild(syserrorSpan);
syserrorValue.appendChild(syserrorChangeSpan); // 가짜 span 추가
syserrorMetric.appendChild(syserrorValue);
syserrorCell.appendChild(syserrorMetric);
totalRow.appendChild(syserrorCell);
// 테이블에 합계 행 추가
tableBody.appendChild(totalRow);
}
// 트랜잭션 데이터 로드 함수
function loadTransactionData(type) {
// 이미 로딩 중이면 중복 요청 방지
if (isTransactionDataLoading) {
return;
}
// 로딩 상태 플래그 설정
isTransactionDataLoading = true;
// 전역 로딩 이벤트 발생 (DashboardEventManager 사용)
if (window.DashboardEventManager) {
window.DashboardEventManager.trigger('loading-start');
}
// 필터 조건을 파라미터로 API 호출
ModernDashboardApi.getTransactionInfo(type, function(data) {
// 데이터 처리 완료 후 UI 업데이트
// 테이블 업데이트
updateTransactionTable(data);
// 전역 로딩 완료 이벤트 발생
if (window.DashboardEventManager) {
window.DashboardEventManager.trigger('loading-end');
}
// 로딩 상태 플래그 해제
isTransactionDataLoading = false;
});
}
// 트랜잭션 필터 버튼 클릭 이벤트
$('.tran-filter-btn').click(function() {
// 모든 버튼 비활성화
$('.tran-filter-btn').removeClass('active');
// 클릭한 버튼 활성화
$(this).addClass('active');
// 선택한 타입에 따라 트랜잭션 데이터 필터링 로직 추가
var type = $(this).data('type');
loadTransactionData(type);
});
// 실제 서비스에서 표시할 거래 유형 목록 (정렬 순서 포함)
var preferredTransactionOrder = ['MCI', 'EAI', 'FEP', 'MCU', 'APIGW'];
// refreshDashboardData 함수가 호출될 때 거래 처리 테이블이 초기화되지 않도록
// modern-dashboard.js 파일의 refreshDashboardData 함수를 확장합니다.
$(document).ready(function() {
// DashboardEventManager를 사용하여 새로고침 이벤트 구독
if (window.DashboardEventManager) {
window.DashboardEventManager.on('refresh', function() {
// 트랜잭션 정보 조회 - 현재 선택된 필터 사용
var activeType = $('.tran-filter-btn.active').data('type') || 'all';
console.log('거래처리 집계 데이터 갱신 시작: ' + activeType);
loadTransactionData(activeType);
});
// 초기화 완료 이벤트 로깅
console.log('거래처리 집계 컴포넌트 초기화 완료');
}
// 페이지 로드 시 데이터 로드
var activeType = $('.tran-filter-btn.active').data('type') || 'all';
loadTransactionData(activeType);
});
// 성공 비율에 따른 색상 계산 함수
function calculateStatusColor(successRatio) {
// 성공 비율이 0.5(50%)일 때 주황색(#ff9800)
// 성공 비율이 1.0(100%)에 가까울수록 초록색(#4caf50)
// 성공 비율이 0.0(0%)에 가까울수록 빨간색(#e53935)
// 정확히 50%에 가까울 때는 주황색
if (Math.abs(successRatio - 0.5) < 0.05) {
return '#ff9800'; // 주황색 (50:50 비율 근처)
} else if (successRatio > 0.5) {
// 50% ~ 100% 사이: 주황색에서 초록색으로 매우 급격한 변화
// 55% 이상이면 거의 바로 초록색으로 변화
var greenRatio;
if (successRatio > 0.65) {
// 65% 이상이면 완전한 초록색
greenRatio = 1;
} else {
// 50%~65% 사이에서 급격하게 변화
greenRatio = (successRatio - 0.5) * 6.67; // 0.5에서 0.65 사이를 0에서 1로 매핑
}
// 주황색(#ff9800)에서 초록색(#4caf50)으로 보간
var r = Math.round(255 - (255 - 76) * greenRatio);
var g = Math.round(152 + (175 - 152) * greenRatio);
var b = Math.round(0 + (80 - 0) * greenRatio);
return 'rgb(' + r + ',' + g + ',' + b + ')';
} else {
// 0% ~ 50% 사이: 빨간색에서 주황색으로 매우 급격한 변화
// 45% 이하면 거의 바로 빨간색으로 변화
var orangeRatio;
if (successRatio < 0.35) {
// 35% 이하면 완전한 빨간색
orangeRatio = 0;
} else {
// 35%~50% 사이에서 급격하게 변화
orangeRatio = (successRatio - 0.35) * 6.67; // 0.35에서 0.5 사이를 0에서 1로 매핑
}
// 빨간색(#e53935)에서 주황색(#ff9800)으로 보간
var r = Math.round(229 + (255 - 229) * orangeRatio);
var g = Math.round(57 + (152 - 57) * orangeRatio);
var b = Math.round(53 + (0 - 53) * orangeRatio);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
}
</script>
<style>
/* 트랜잭션 섹션 */
.dashboard-transactions .section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
flex-shrink: 0;
}
.dashboard-transactions .card {
border-radius: 0.35rem;
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15);
overflow: hidden;
height: 242px;
}
.transaction-filters {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e3e6f0;
}
.filter-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-btn:hover {
background-color: #e9ecef;
color: #495057;
}
.filter-btn.active {
background-color: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.transaction-table-container {
max-height: 300px;
overflow-y: auto;
position: relative;
}
.transaction-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
table-layout: fixed;
}
.transaction-table th {
background-color: #f8f9fc;
font-weight: 600;
text-align: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e3e6f0;
position: sticky;
top: 0;
z-index: 10;
}
.transaction-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.transaction-table td:nth-child(1) {
text-align: left;
}
.transaction-table td:nth-child(2) {
text-align: center;
}
.transaction-table td:nth-child(n+3) {
text-align: center;
}
.transaction-table tr:last-child td {
border-bottom: none;
}
.transaction-table tr:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.transaction-table tr.total-row {
background-color: rgba(200, 200, 200, 0.2);
font-weight: 600;
}
.transaction-table tr.total-row td {
border-bottom: 2px solid #9e9e9e;
}
.transaction-table tr.total-row .group-name {
color: #616161;
}
/* 인스턴스 상태 표시 */
.instance-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
}
.status-normal {
background-color: #4caf50;
}
.status-warning {
background-color: #ff9800;
}
.status-error {
background-color: #e53935;
}
/* 메트릭 스타일 */
.metric {
display: flex;
flex-direction: column;
position: relative;
min-height: 24px;
}
.metric-value {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.clickable {
cursor: pointer;
font-weight: 500;
}
.transaction-count {
color: #4caf50;
font-weight: 600;
}
.error-count {
color: #e53935;
font-weight: 600;
}
.timeout-count {
color: #ff9800;
font-weight: 600;
}
.syserror-count {
color: #e53935;
font-weight: 600;
}
.metric-change {
font-size: 0.75rem;
color: #4caf50;
font-weight: 500;
display: inline-flex;
align-items: center;
transition: opacity 0.3s ease;
position: relative;
margin-left: 2px;
white-space: nowrap;
}
.error-change {
color: #e53935;
}
.timeout-change {
color: #ff9800;
}
.syserror-change {
color: #e53935;
}
.metric-change i {
font-size: 1rem;
margin-right: 0;
}
/* 그룹 이름 스타일 */
.group-name {
text-align: center;
}
/* 대시보드 헤더 스타일 */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
/* 제목과 버튼을 한 줄에 나란히 표시하기 위한 컨테이너 */
.header-title-container {
display: flex;
align-items: center;
}
/* 트랜지션 효과 제거 */
#transaction-table-body {
/* transition: opacity 0.2s ease; */
}
</style>