fix: 마이페이지 이용 통계 , 내정보 관리 , 비밀전호 변경 & 로그인 반응형 퍼블리싱

This commit is contained in:
hong
2026-07-28 14:30:03 +09:00
parent 2106142d93
commit cdd68f48f0
17 changed files with 1841 additions and 29794 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
// -----------------------------------------------------------------------------
// Common Board Header Component Styling
// -----------------------------------------------------------------------------
.board-header {
margin-bottom: 24px;
.board-title {
font-size: 24px;
font-weight: 700;
color: #000000;
margin-bottom: 8px;
line-height: 1.2;
}
.board-desc {
font-size: 14px;
color: #7f8a95;
margin: 0;
line-height: 1.5;
}
}
+1
View File
@@ -45,6 +45,7 @@
@use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *;
@use 'components/board-common' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -8,7 +8,6 @@
.api-statistics-container {
margin: 0 auto;
padding: 48px 0px;
}
// 집계 안내 문구
@@ -34,10 +33,11 @@
// Search Section
.statistics-search-section {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 30px;
margin-top: 50px;
}
// 일별/월별 모드 토글
@@ -46,6 +46,7 @@
background: #eef1f5;
border-radius: 22px;
padding: 4px;
width: 150px;
}
.mode-tab {
@@ -347,9 +348,10 @@
font-size: 18px;
font-weight: 600;
margin: 0;
margin-bottom: 10px;
svg {
stroke: #fff;
stroke: #1e293b;
}
}
@@ -375,39 +377,89 @@
padding: 0;
}
.statistics-table {
.board-table {
width: 100%;
border-collapse: collapse;
border-top: 1px solid #c4c7c8;
background: #fff;
thead th {
padding: 16px 20px;
text-align: left;
font-weight: 500;
color: #666;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
tbody {
td {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
color: #333;
}
tr:hover {
.board-table-header {
display: flex;
align-items: center;
background: #f9f9f9;
height: 50px;
padding: 0 45px;
border-bottom: 1px solid #dedede;
@media (max-width: 768px) {
display: none;
}
.header-cell {
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 500;
color: #000;
text-align: center;
}
}
tfoot .total-row {
background: #e3f2fd;
.board-table-body {
display: flex;
flex-direction: column;
}
td {
padding: 16px 20px;
.board-table-row {
display: flex;
align-items: center;
height: 52px;
padding: 0 45px;
border-bottom: 1px solid #dedede;
transition: background-color 0.2s;
background: #ffffff;
&:hover {
background-color: #f8fbfe;
}
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
height: auto;
padding: 15px;
gap: 10px;
}
.row-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 400;
color: #000;
white-space: nowrap;
@media (max-width: 768px) {
justify-content: flex-start;
width: 100% !important;
&::before {
content: attr(data-label);
font-weight: 500;
color: #64748b;
margin-right: 10px;
min-width: 80px;
}
}
}
&.total-row {
background: #f8fbfe;
font-weight: 600;
.row-cell {
color: #0049B4;
border-bottom: none;
}
}
}
}
@@ -440,39 +492,64 @@
}
}
// Progress Bar
.progress-cell {
min-width: 200px;
.api-name-text {
display: inline-block;
vertical-align: middle;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 768px) {
max-width: 320px;
}
}
.progress-bar {
// Progress Bar
.col-name {
flex: 2;
justify-content: flex-start !important;
padding-left: 10px;
min-width: 0; // Allows flex child truncation
}
.board-table-header .col-name {
justify-content: center !important;
padding-left: 0;
}
.col-num {
width: 100px;
justify-content: center !important;
}
.col-chart {
width: 180px;
justify-content: center !important;
}
.stats-progress {
width: 150px;
height: 8px;
background: #f1f5f9;
border-radius: 4px;
display: flex;
height: 12px;
background: #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.progress-success {
background: #4A90D9;
transition: width 0.3s ease;
.stats-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
width: 100%;
color: #64748b;
font-weight: 400;
font-size: 16px;
}
.progress-timeout {
background: #F5C36B;
transition: width 0.3s ease;
}
.progress-failure {
background: #F5A3B5;
transition: width 0.3s ease;
}
// Empty State
.empty-row td {
text-align: center;
padding: 40px 20px;
color: #999;
.progress-cell {
min-width: 200px;
}
// Responsive
@@ -480,6 +557,7 @@
.summary-content {
flex-direction: column;
gap: 30px;
align-items: center;
}
.summary-left,
@@ -487,6 +565,22 @@
.summary-right {
flex: none;
width: 100%;
align-items: center;
justify-content: center;
text-align: center;
}
.summary-info {
padding-left: 0;
}
.app-select-wrapper {
display: flex;
justify-content: center;
}
.summary-detail-list {
align-items: center;
}
.summary-center {
@@ -495,42 +589,101 @@
.summary-right {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
.rate-display {
border-bottom: none;
padding: 0;
}
}
@media (max-width: 768px) {
.api-statistics-container {
padding: 20px 16px;
}
@media (max-width: $breakpoint-sm) {
// .api-statistics-container {
// padding: 20px 16px;
// }
.statistics-summary-card {
padding: 24px 20px;
}
.app-toggle-buttons {
flex-wrap: wrap;
}
.toggle-btn {
flex: 1;
text-align: center;
}
.statistics-table {
thead th,
tbody td,
tfoot td {
padding: 12px 10px;
font-size: 13px;
}
}
.progress-cell {
min-width: 100px;
.statistics-search-section {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.search-filter-row {
flex-wrap: wrap;
flex-direction: row;
justify-content: space-between;
width: 100%;
}
.date-range-picker {
flex: 1;
justify-content: space-between;
}
.date-input {
width: 45%;
font-size: 13px;
padding: 6px 8px;
}
.board-table-header {
display: none !important;
}
.board-table-row {
flex-direction: column;
align-items: stretch;
height: auto;
padding: 10px 14px;
gap: 6px;
border-bottom: 1px solid #e2e8f0;
.row-cell {
display: flex !important;
justify-content: space-between !important;
align-items: center;
width: 100% !important;
text-align: right;
font-size: 13px;
padding: 0 !important;
&::before {
content: attr(data-label);
font-weight: 500;
color: #64748b;
text-align: left;
min-width: 80px;
}
&.col-name {
font-weight: 600;
justify-content: flex-start !important;
text-align: left;
border-bottom: none;
padding-bottom: 0 !important;
margin-bottom: 4px;
display: flex !important;
gap: 10px;
&::before {
// content: "API명" !important;
// display: inline-block !important;
// font-weight: 500;
// color: #64748b;
// min-width: 80px;
display: none;
}
}
}
}
.stats-progress {
width: 100px;
}
}
@@ -1240,6 +1240,36 @@
color: #64748b;
}
.info-notice-box {
background: #ecf0fa;
border-radius: 7px;
padding: 13px 14px 12px;
display: flex;
align-items: center;
gap: 13px;
margin-bottom: 32px;
svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
p {
color: #4685ef;
font-size: 16px;
font-weight: 500;
margin: 0;
}
&.danger {
background: #fdf3f3;
p {
color: #f4253c;
}
}
}
// Footer Buttons layout
.dt-actions {
display: flex;
+138 -77
View File
@@ -10,12 +10,11 @@
display: flex;
align-items: center;
justify-content: center;
background: #EDF9FE; // Light blue background from Figma
padding: 40px 20px;
padding: 100px 20px;
position: relative;
border-radius: 12px;
margin-top: 60px;
margin-bottom: 60px;
margin-bottom: 50px;
}
.login-container {
@@ -37,60 +36,104 @@
}
}
.login-logo {
width: 90px;
height: 90px;
margin-bottom: 30px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.login-message {
font-family: $font-family-primary;
font-size: 20px;
font-weight: 400;
color: #000000;
text-align: center;
.login-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
width: 100%;
.header-main-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
}
.login-lock-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
svg {
stroke: #000;
}
}
.login-title {
font-family: $font-family-primary;
font-size: 36px;
font-weight: 700;
color: #000000;
margin: 0;
line-height: 1;
}
.login-message-sub {
font-family: $font-family-primary;
font-size: 18px;
font-weight: 300;
color: #80878d;
text-align: center;
margin: 0;
line-height: 1.2;
}
.login-form {
width: 100%;
margin-bottom: 0;
.form-group {
margin-bottom: 24px;
margin-bottom: 16px;
position: relative;
&:last-of-type {
margin-bottom: 0;
&.input-with-icon {
.input-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
svg {
stroke: #767e89;
}
}
.form-input {
padding-left: 56px;
}
}
}
.form-input {
width: 100%;
height: 70px;
padding: 0 32px;
height: 58px;
padding: 0 20px;
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #5F666C;
background: #FFFFFF;
border: 1px solid #DDDDDD;
border-radius: 12px;
font-weight: 300;
color: #000000;
background: #fcfcfc;
border: 1px solid #dfdfdf;
border-radius: 5px;
outline: none;
transition: all 0.3s ease;
&::placeholder {
color: #5F666C;
color: #767e89;
}
&:focus {
border-color: #0049B4;
border-color: #1b4ab7;
background: #ffffff;
}
&.error {
@@ -101,17 +144,16 @@
.form-checkbox {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 7px;
margin-top: 24px;
margin-bottom: 22px;
margin-right: 10px;
justify-content: flex-start;
gap: 8px;
margin-top: 14px;
margin-bottom: 30px;
input[type="checkbox"] {
width: 20px;
height: 20px;
border: 1.5px solid #000000;
border-radius: 4px;
width: 14px;
height: 14px;
border: 1px solid #dfdfdf;
border-radius: 2px;
cursor: pointer;
appearance: none;
background: #FFFFFF;
@@ -119,8 +161,8 @@
flex-shrink: 0;
&:checked {
background: #FFFFFF;
border-color: #2C2C2C;
background: #1b4ab7;
border-color: #1b4ab7;
&::after {
content: '';
@@ -128,22 +170,22 @@
left: 50%;
top: 45%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 12px;
border: solid #2C2C2C;
border-width: 0 2px 2px 0;
width: 3px;
height: 6px;
border: solid #ffffff;
border-width: 0 1.5px 1.5px 0;
}
}
}
label {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #000000;
font-size: 12px;
font-weight: 300;
color: #767e89;
cursor: pointer;
user-select: none;
line-height: 24px;
line-height: 1;
white-space: nowrap;
}
}
@@ -151,26 +193,26 @@
.login-button {
width: 100%;
height: 80px;
height: 58px;
padding: 10px;
font-family: $font-family-primary;
font-size: 20px;
font-weight: 700;
font-size: 16px;
font-weight: 500;
color: #FFFFFF;
background: #0049B4;
background: #1b4ab7;
border: none;
border-radius: 12px;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 40px;
line-height: 1;
margin-bottom: 30px;
line-height: 20px;
&:hover {
background: darken(#0049B4, 5%);
background: darken(#1b4ab7, 5%);
}
&:active {
background: darken(#0049B4, 10%);
background: darken(#1b4ab7, 10%);
}
&:disabled {
@@ -179,31 +221,51 @@
}
}
.login-links {
.login-links-container {
display: flex;
justify-content: center;
align-items: center;
gap: 26px;
flex-wrap: wrap;
width: 100%;
a {
.link-btn {
flex: 1;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #ecf0fa;
border: 1px solid #2a69de;
font-family: $font-family-primary;
font-size: 16px;
font-size: 14px;
font-weight: 400;
color: #000000;
color: #2a69de;
text-decoration: none;
transition: color 0.3s ease;
line-height: 1;
transition: all 0.2s ease;
line-height: 20px;
text-align: center;
&.first {
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
border-right: none;
}
&.middle {
border-right: none;
}
&.last {
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
&:hover {
color: #0049B4;
text-decoration: underline;
}
background: darken(#ecf0fa, 4%);
}
.link-separator {
color: #000000;
font-size: 16px;
&:active {
background: darken(#ecf0fa, 8%);
}
}
}
@@ -282,9 +344,8 @@
@include respond-to('sm') {
.login-page {
// Figma: 전체 배경 #ebfbff, 풀 높이
background: #ebfbff;
min-height: calc(100vh - 44px); // 모바일 헤더 높이 제외
padding: 40px 20px;
padding: 100px 20px;
margin-top: 0;
margin-bottom: 0;
border-radius: 0;
@@ -26,13 +26,14 @@
// -----------------------------------------------------------------------------
// Corporate Manager Profile Form Styles
// -----------------------------------------------------------------------------
.corp-manager-wrapper, .password-change-wrapper {
.corp-manager-wrapper,
.password-change-wrapper {
width: 100%;
margin: 0 auto;
padding: 40px 0;
// padding: 40px 0;
.page-outer-title {
font-size: 36px;
font-size: 24px;
font-weight: 700;
color: #000;
margin-bottom: 24px;
@@ -87,6 +88,11 @@
flex-direction: column;
gap: 13px;
}
@media (max-width: $breakpoint-sm) {
flex-direction: column;
gap: 26px;
}
}
}
@@ -218,7 +224,7 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
margin-top: 0;
}
.btn-withdrawal {
@@ -291,6 +297,14 @@
font-weight: 500;
margin: 0;
}
&.danger {
background: #fdf3f3;
p {
color: #f4253c;
}
}
}
.password-rules-list {
@@ -93,7 +93,9 @@
th:if="${notice.noticeType == '4'}">테스트</span>
<span class="notice-type-badge notice-type-badge--normal"
th:if="${notice.noticeType == '1'}">공지</span>
<span th:text="${#strings.replace(#strings.replace(#strings.replace(#strings.replace(notice.noticeSubject, '[장애]', ''), '[점검]', ''), '[테스트]', ''), '[공지]', '')}">공지사항 제목</span>
<span
th:text="${#strings.replace(#strings.replace(#strings.replace(#strings.replace(notice.noticeSubject, '[장애]', ''), '[점검]', ''), '[테스트]', ''), '[공지]', '')}">공지사항
제목</span>
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -39,11 +39,9 @@
<section class="service-content">
<!-- 상단: 최근 작성한 글 목록 -->
<div class="recent-apps">
<div class="recent-apps-header-group">
<p class="recent-apps-title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">
피드백 / 개선 요청</p>
<p class="recent-apps-subtitle" style="font-size: 14px; color: #7f8a95; margin-bottom: 24px;">최근 최대 3개의 나의
피드백/ 개선 요청을 보여줍니다.</p>
<div class="board-header">
<h2 class="board-title">피드백 / 개선 요청</h2>
<p class="board-desc">최근 최대 3개의 나의 피드백/ 개선 요청을 보여줍니다.</p>
</div>
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
@@ -73,10 +71,10 @@
<!-- 하단: 신규 작성 폼 카드 -->
<div class="djb-board-write-container">
<!-- Title Bar -->
<div class="djb-board-write-header" style="margin-bottom: 30px;">
<h2 class="title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">피드백 / 개선 요청
<div class="board-header">
<h2 class="board-title">피드백 / 개선 요청
</h2>
<p class="subtitle" style="font-size: 14px; color: #7f8a95; margin: 0;">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
<p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
환영합니다.</p>
</div>
@@ -1,18 +1,26 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<div class="login-page">
<div class="login-container">
<div class="login-card">
<!-- Logo -->
<div class="login-logo">
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank">
<!-- Logo & Title -->
<div class="login-header">
<div class="header-main-row">
<div class="login-lock-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#000"
stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h2 class="login-title">로그인</h2>
</div>
<p class="login-message-sub">DJ Bank에 오신걸 환영합니다</p>
</div>
<!-- Header Message -->
<p class="login-message">로그인 하시면 더 많은 서비스를 이용하실 수 있습니다.</p>
<!-- Alert Messages -->
<div th:if="${param.logout}" class="login-alert alert-info">
@@ -33,30 +41,36 @@
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
class="login-form">
<input type="hidden" id="message" name="message" th:value="${loginMessage}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" id="loginId" th:value="${loginId}">
<!-- ID Field -->
<div class="form-group">
<input type="text"
id="id"
name="id"
th:value="${loginId}"
class="form-input"
placeholder="아이디"
required>
<div class="form-group input-with-icon">
<span class="input-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<input type="text" id="id" name="id" th:value="${loginId}" class="form-input"
placeholder="아이디를 입력해주세요" required>
</div>
<!-- Password Field -->
<div class="form-group">
<input type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호"
required>
<div class="form-group input-with-icon">
<span class="input-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<input type="password" id="password" name="password" class="form-input"
placeholder="비밀번호를 입력해주세요" required>
</div>
<!-- Remember Me Checkbox -->
@@ -72,12 +86,10 @@
</button>
<!-- Additional Links -->
<div class="login-links">
<a th:href="@{/account_recovery(tab='findId')}">아이디 찾기</a>
<span class="link-separator">|</span>
<a th:href="@{/account_recovery(tab='resetPassword')}">비밀번호 초기화</a>
<span class="link-separator">|</span>
<a th:href="@{/signup}">회원가입</a>
<div class="login-links-container">
<a th:href="@{/account_recovery(tab='findId')}" class="link-btn first">아이디 찾기</a>
<a th:href="@{/account_recovery(tab='resetPassword')}" class="link-btn middle">비밀번호 초기화</a>
<a th:href="@{/signup}" class="link-btn last">회원가입</a>
</div>
<!-- Loading State -->
@@ -252,4 +264,5 @@
});
</script>
</th:block>
</html>
@@ -25,9 +25,13 @@
<!-- 모든 스타일은 detail-wrap 안에서만 적용 -->
<div class="detail-wrap">
<!-- Title Bar (Figma 및 스크린샷 대응) -->
<div class="common-title-bar">
<h2 class="common-title" th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '인증키 정보' : '신청 상세'}">신청 상세</h2>
<span class="common-subtitle" th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '등록된 앱의 상세 정보를 확인할 수 있습니다.' : '신청하신 API Key의 상세 정보를 확인할 수 있습니다.'}">신청하신 API Key의 상세 정보를 확인할 수 있습니다.</span>
<div class="board-header">
<h2 class="board-title"
th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '인증키 정보' : '신청 상세'}">
신청 상세</h2>
<span class="board-desc"
th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '등록된 앱의 상세 정보를 확인할 수 있습니다.' : '신청하신 API Key의 상세 정보를 확인할 수 있습니다.'}">신청하신
API Key의 상세 정보를 확인할 수 있습니다.</span>
</div>
<!-- App Basic Info Card -->
@@ -49,7 +53,7 @@
<div class="dt-app-status-row" th:if="${appRequest.approval != null}">
<span class="dt-status-badge"
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' :
(appRequest.approval.approvalStatus.toString() == 'REJECTED' ? 'status-rejected' : 'status-pending')}"
(appRequest.approval.approvalStatus.toString() == 'REJECTED' or appRequest.approval.approvalStatus.toString() == 'REQUESTED' ? 'status-rejected' : 'status-pending')}"
th:text="${appRequest.approval.approvalStatus.description}">
대기중
</span>
@@ -80,7 +84,8 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">
</path>
</svg>
<span>복사</span>
</button>
@@ -176,7 +181,8 @@
</div>
<!-- 승인일시 (approvalDate 가 있을 때만) -->
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<div class="dt-row"
th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<div class="dt-col">
<label class="dt-label">승인일시</label>
<div class="dt-input-group">
@@ -204,11 +210,16 @@
</div>
<!-- 내부 결재 진행중: 취소 불가 안내 -->
<p class="dt-cancel-notice" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
<div class="info-notice-box danger" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.
</p>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4253c"
stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<p>내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.</p>
</div>
<!-- Bottom Navigation Actions -->
<div class="dt-actions">
@@ -1,11 +1,11 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/djbank_title_layout}">
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/djbank_title_layout}">
<head>
<title>API 키 상세</title>
</head>
<body>
<section layout:fragment="title">
<div class="page-title-banner">
@@ -24,16 +24,18 @@
<div class="app-management-content">
<div class="detail-wrap">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">인증키 정보</h2>
<span class="common-subtitle">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
<div class="board-header">
<h2 class="board-title">인증키 정보</h2>
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div>
<!-- App Basic Info Card -->
<div class="dt-app-card">
<div class="dt-app-icon-box">
<img th:if="${apiKey.appIconFileId != null}" th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5">
<img th:if="${apiKey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none"
stroke="#64748b" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
@@ -41,10 +43,12 @@
</div>
<div class="dt-app-info">
<div class="dt-app-status-row">
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}" class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}"
class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
</div>
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
<p class="dt-app-desc" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
<p class="dt-app-desc"
th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
</div>
</div>
@@ -57,8 +61,10 @@
<label class="dt-label">Client ID</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.clientid}">K00000001</div>
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}" onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}"
onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
@@ -73,7 +79,8 @@
<div class="dt-col">
<label class="dt-label">
Client Secret
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내"
aria-label="비밀정보 안내"
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
</label>
@@ -81,8 +88,13 @@
<th:block th:if="${secretAvailable}">
<!-- View Button (shown initially) -->
<div class="dt-input-group" id="hiddenSecretBox">
<button type="button" style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#1d56c0'" onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;">
<button type="button"
style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;"
onmouseover="this.style.backgroundColor='#1d56c0'"
onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="margin-right: 8px;">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
@@ -94,15 +106,18 @@
<div id="revealedSecretBox" style="display: none; width: 100%;">
<div class="dt-input-group">
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn"
onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>복사</span>
</button>
</div>
<p class="secret-warning" style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
<p class="secret-warning"
style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
</p>
@@ -110,10 +125,12 @@
</th:block>
<!-- secret이 이미 노출·삭제됨 -->
<div th:unless="${secretAvailable}" class="secret-revealed-notice" style="display:flex;align-items:center;gap:12px;">
<div th:unless="${secretAvailable}" class="secret-revealed-notice"
style="display:flex;align-items:center;gap:12px;">
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실
시 안내</button>
</div>
</div>
</div>
@@ -135,7 +152,8 @@
<div class="dt-col">
<label class="dt-label">화이트 리스트</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">설정되지 않음</div>
<div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">
설정되지 않음</div>
</div>
</div>
</div>
@@ -161,7 +179,8 @@
<div class="dt-col">
<label class="dt-label">최종 수정일자</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22</div>
<div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22
</div>
</div>
</div>
</div>
@@ -170,8 +189,10 @@
<!-- Bottom Navigation Actions -->
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red" th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">인증키 삭제</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue" th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">인증키 삭제</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
</div>
</div><!-- /detail-wrap -->
@@ -314,4 +335,5 @@
</th:block>
</body>
</html>
@@ -9,8 +9,8 @@
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="corp-manager-wrapper">
<h2 class="page-outer-title">법인 기본 정보</h2>
@@ -11,93 +11,7 @@
</section>
<th:block layout:fragment="contentFragment">
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/>
<style>
/* 통계 테이블 (실제 table 태그) 공통 */
.api-statistics-container .stats-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
background: #fff;
}
.api-statistics-container .stats-table th,
.api-statistics-container .stats-table td {
padding: 14px 12px;
border-bottom: 1px solid #e2e8f0;
vertical-align: middle;
}
.api-statistics-container .stats-table thead th {
text-align: center;
background: #f8fafc;
color: #475569;
font-weight: 600;
white-space: nowrap;
}
.api-statistics-container .stats-table tfoot td {
font-weight: 600;
background: #f8fafc;
}
/* API별 표: auto layout — 숫자열은 내용에 맞춰 최소(nowrap), 통계는 고정, API명이 남은 폭 전체 */
.api-statistics-container .stats-table--api { table-layout: auto; }
.api-statistics-container .stats-table--api .col-name {
text-align: left;
width: 100%;
white-space: normal;
word-break: break-word;
padding-left: 4px;
}
/* API명 헤더 셀은 가운데 정렬 (데이터 셀은 좌측 유지) */
.api-statistics-container .stats-table--api thead .col-name {
text-align: center;
}
.api-statistics-container .stats-table--api .col-num {
text-align: center;
white-space: nowrap;
width: 1%;
}
.api-statistics-container .stats-table--api .col-chart {
text-align: center;
width: 180px;
min-width: 180px;
}
.api-statistics-container .stats-table--api .col-chart .stats-progress {
min-width: 150px;
}
.api-statistics-container .stats-table--api .api-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-right: 6px;
}
.api-statistics-container .stats-table--api .api-icon svg { display: block; }
.api-statistics-container .stats-table--api .api-name-text { vertical-align: middle; }
/* 일별/기간별 표: fixed layout — 5열 동일 너비, 전부 가운데 */
.api-statistics-container .stats-table--period { table-layout: fixed; }
.api-statistics-container .stats-table--period th,
.api-statistics-container .stats-table--period td {
text-align: center;
width: 20%;
white-space: nowrap;
}
/* progress bar */
.api-statistics-container .stats-progress {
width: 100%;
height: 8px;
background: #f1f5f9;
border-radius: 4px;
display: flex;
overflow: hidden;
}
/* empty state */
.api-statistics-container .stats-empty td {
text-align: center;
padding: 40px 0;
color: #64748b;
font-weight: 400;
font-size: 16px;
}
</style>
<!-- <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}" /> -->
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
<div class="app-management-content">
@@ -156,9 +70,7 @@
<div class="app-select-wrapper">
<select id="appSelect" class="app-select">
<option value="">전체 앱</option>
<option th:each="app : ${appList}"
th:value="${app.clientId}"
th:text="${app.clientName}"
<option th:each="app : ${appList}" th:value="${app.clientId}" th:text="${app.clientName}"
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}">
앱 이름
</option>
@@ -167,32 +79,45 @@
<div class="summary-info">
<p class="summary-total-text">
총 API 호출 수는 <strong id="totalCount" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
총 API 호출 수는 <strong id="totalCount"
th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
</p>
<div class="summary-detail-list">
<div class="summary-detail-item success">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" />
</svg>
</span>
<span class="detail-label">성공</span>
<span class="detail-value" id="successCount" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="successRateText" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
<span class="detail-value" id="successCount"
th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="successRateText"
th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
</div>
<div class="summary-detail-item timeout">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" />
</svg>
</span>
<span class="detail-label">타임아웃</span>
<span class="detail-value" id="timeoutCount" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="timeoutRateText" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
<span class="detail-value" id="timeoutCount"
th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="timeoutRateText"
th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
</div>
<div class="summary-detail-item failure">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" />
</svg>
</span>
<span class="detail-label">실패</span>
<span class="detail-value" id="errorCount" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="errorRateText" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
<span class="detail-value" id="errorCount"
th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="errorRateText"
th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
</div>
</div>
</div>
@@ -202,18 +127,15 @@
<div class="summary-center">
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200">
<circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24" />
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#4A90D9" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'"
stroke-dashoffset="0" transform="rotate(-90 100 100)"/>
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5C36B" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${summary.successRate * 4.398}"
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#4A90D9"
stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'" stroke-dashoffset="0"
transform="rotate(-90 100 100)" />
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5A3B5" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#F5C36B"
stroke-width="24" stroke-linecap="butt" th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${summary.successRate * 4.398}" transform="rotate(-90 100 100)" />
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#F5A3B5"
stroke-width="24" stroke-linecap="butt" th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${(summary.successRate + summary.timeoutRate) * 4.398}"
transform="rotate(-90 100 100)" />
</svg>
@@ -224,19 +146,22 @@
<div class="rate-display success-rate">
<span class="rate-indicator success"></span>
<span class="rate-label">성공률</span>
<span class="rate-value" id="successRate" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
<span class="rate-value" id="successRate"
th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
<span class="rate-percent">%</span>
</div>
<div class="rate-display timeout-rate">
<span class="rate-indicator timeout"></span>
<span class="rate-label">타임아웃율</span>
<span class="rate-value" id="timeoutRate" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
<span class="rate-value" id="timeoutRate"
th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
<span class="rate-percent">%</span>
</div>
<div class="rate-display failure-rate">
<span class="rate-indicator failure"></span>
<span class="rate-label">실패률</span>
<span class="rate-value" id="errorRate" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
<span class="rate-value" id="errorRate"
th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
<span class="rate-percent">%</span>
</div>
</div>
@@ -245,83 +170,104 @@
<!-- API별 통계 Table Section -->
<div class="statistics-table-section" style="margin-top: 40px;">
<div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
<div class="table-controls">
<h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18" />
<path d="M18 9l-5 5-4-4-4 4" />
</svg>
API별 통계
</h3>
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
</div>
<table class="stats-table stats-table--api">
<thead>
<tr>
<th class="col-name">API명</th>
<th class="col-num">API호출수</th>
<th class="col-num">성공</th>
<th class="col-num">실패</th>
<th class="col-num">타임아웃</th>
<th class="col-chart">통계</th>
</tr>
</thead>
<tbody id="statisticsTableBody">
<tr th:each="detail : ${details}">
<td class="col-name">
<div class="board-table">
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell col-name" style="flex: 2;">API명</div>
<div class="header-cell col-num" style="width: 100px;">API호출수</div>
<div class="header-cell col-num" style="width: 100px;">성공</div>
<div class="header-cell col-num" style="width: 100px;">실패</div>
<div class="header-cell col-num" style="width: 100px;">타임아웃</div>
<div class="header-cell col-chart" style="width: 180px;">통계</div>
</div>
<!-- Table Body -->
<div class="board-table-body" id="statisticsTableBody">
<div class="board-table-row" th:each="detail : ${details}">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
</span><span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td>
<td class="col-chart">
<div class="stats-progress">
<div class="progress-success" th:style="'width:' + ${detail.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%; background: #F5A3B5;'"></div>
</span>
<span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
</div>
</td>
</tr>
<tr th:if="${details == null or details.isEmpty()}" class="stats-empty">
<td colspan="6">데이터가 없습니다.</td>
</tr>
</tbody>
<tfoot id="statisticsTableFoot">
<tr class="total-row" th:if="${details != null and !details.isEmpty()}">
<td class="col-name">
<div class="row-cell col-num" style="width: 100px;" data-label="API호출수"
th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="성공"
th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="실패"
th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="타임아웃"
th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="통계">
<div class="stats-progress">
<div class="progress-success"
th:style="'width:' + ${detail.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout"
th:style="'width:' + ${detail.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure"
th:style="'width:' + ${detail.errorRate} + '%; background: #F5A3B5;'">
</div>
</div>
</div>
</div>
<div th:if="${details == null or details.isEmpty()}" class="stats-empty">
데이터가 없습니다.
</div>
</div>
<!-- Table Footer -->
<div class="board-table-body" id="statisticsTableFoot">
<div class="board-table-row total-row" th:if="${details != null and !details.isEmpty()}">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M3 3v18h18" />
<path d="M18 9l-5 5-4-4-4 4" />
</svg>
</span><span class="api-name-text">전체</span>
</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td>
<td class="col-chart">
<div class="stats-progress">
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div>
</span>
<span class="api-name-text">전체</span>
</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 API호출수"
th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 성공"
th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 실패"
th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 타임아웃"
th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="전체 통계">
<div class="stats-progress">
<div class="progress-success"
th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout"
th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure"
th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- 기간별(일별/월별) 누적 통계 Table Section -->
<div class="statistics-table-section" style="margin-top: 40px;">
<div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
<div class="table-controls">
<h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
@@ -330,31 +276,34 @@
</h3>
</div>
<table class="stats-table stats-table--period">
<thead>
<tr>
<th id="periodLabel">일자</th>
<th>API호출수</th>
<th>성공</th>
<th>실패</th>
<th>타임아웃</th>
</tr>
</thead>
<tbody id="periodTableBody">
<tr th:each="p : ${periods}">
<td th:text="${p.period}">2026-07-15 (수)</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
</tr>
<tr th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
<td colspan="5">데이터가 없습니다.</td>
</tr>
</tbody>
</table>
<div class="board-table">
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell" id="periodLabel" style="flex: 1;">일자</div>
<div class="header-cell" style="flex: 1;">API호출수</div>
<div class="header-cell" style="flex: 1;">성공</div>
<div class="header-cell" style="flex: 1;">실패</div>
<div class="header-cell" style="flex: 1;">타임아웃</div>
</div>
<!-- Table Body -->
<div class="board-table-body" id="periodTableBody">
<div class="board-table-row" th:each="p : ${periods}">
<div class="row-cell" style="flex: 1;" data-label="일자" th:text="${p.period}">2026-07-15 (수)</div>
<div class="row-cell" style="flex: 1;" data-label="API호출수"
th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</div>
<div class="row-cell" style="flex: 1;" data-label="성공"
th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</div>
<div class="row-cell" style="flex: 1;" data-label="실패"
th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</div>
<div class="row-cell" style="flex: 1;" data-label="타임아웃"
th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</div>
</div>
<div th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
데이터가 없습니다.
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@@ -417,30 +366,30 @@
if (currentData.details && currentData.details.length > 0) {
currentData.details.forEach(function (d) {
tbody.append(`
<tr>
<td class="col-name">
<div class="board-table-row">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
</svg>
</span><span class="api-name-text">${d.apiName || '-'}</span>
</td>
<td class="col-num">${numberWithCommas(d.totalCount)}</td>
<td class="col-num">${numberWithCommas(d.successCount)}</td>
<td class="col-num">${numberWithCommas(d.errorCount)}</td>
<td class="col-num">${numberWithCommas(d.timeoutCount)}</td>
<td class="col-chart">
</div>
<div class="row-cell col-num" style="width: 100px;" data-label="API호출수">${numberWithCommas(d.totalCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="성공">${numberWithCommas(d.successCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="실패">${numberWithCommas(d.errorCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="타임아웃">${numberWithCommas(d.timeoutCount)}</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="통계">
<div class="stats-progress">
<div class="progress-success" style="width:${d.successRate}%; background: #4A90D9;"></div>
<div class="progress-timeout" style="width:${d.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${d.errorRate}%; background: #F5A3B5;"></div>
</div>
</td>
</tr>`);
</div>
</div>`);
});
renderTotalFooter();
} else {
tbody.html('<tr class="stats-empty"><td colspan="6">데이터가 없습니다.</td></tr>');
tbody.html('<div class="stats-empty">데이터가 없습니다.</div>');
$('#statisticsTableFoot').empty();
}
}
@@ -448,26 +397,26 @@
function renderTotalFooter() {
var sm = currentData.summary;
$('#statisticsTableFoot').html(`
<tr class="total-row">
<td class="col-name">
<div class="board-table-row total-row">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg>
</span><span class="api-name-text">전체</span>
</td>
<td class="col-num">${numberWithCommas(sm.totalCount)}</td>
<td class="col-num">${numberWithCommas(sm.successCount)}</td>
<td class="col-num">${numberWithCommas(sm.errorCount)}</td>
<td class="col-num">${numberWithCommas(sm.timeoutCount)}</td>
<td class="col-chart">
</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 API호출수">${numberWithCommas(sm.totalCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 성공">${numberWithCommas(sm.successCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 실패">${numberWithCommas(sm.errorCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 타임아웃">${numberWithCommas(sm.timeoutCount)}</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="전체 통계">
<div class="stats-progress">
<div class="progress-success" style="width:${sm.successRate}%; background: #4A90D9;"></div>
<div class="progress-timeout" style="width:${sm.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${sm.errorRate}%; background: #F5A3B5;"></div>
</div>
</td>
</tr>`);
</div>
</div>`);
}
// ── 기간별(일/월) 표 (table DOM) ──
@@ -477,16 +426,16 @@
if (currentData.periods && currentData.periods.length > 0) {
currentData.periods.forEach(function (p) {
tbody.append(`
<tr>
<td>${p.period || '-'}</td>
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
</tr>`);
<div class="board-table-row">
<div class="row-cell" style="flex: 1;" data-label="일자">${p.period || '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="API호출수">${p.hasData ? numberWithCommas(p.totalCount) : '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="성공">${p.hasData ? numberWithCommas(p.successCount) : '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="실패">${p.hasData ? numberWithCommas(p.errorCount) : '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="타임아웃">${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</div>
</div>`);
});
} else {
tbody.html('<tr class="stats-empty"><td colspan="5">데이터가 없습니다.</td></tr>');
tbody.html('<div class="stats-empty">데이터가 없습니다.</div>');
}
}
@@ -623,4 +572,5 @@
</th:block>
</body>
</html>