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/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *; @use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *; @use 'components/password-policy' as *;
@use 'components/board-common' as *;
// 5. Page-specific styles // 5. Page-specific styles
@use 'pages/index' as *; @use 'pages/index' as *;
@@ -8,7 +8,6 @@
.api-statistics-container { .api-statistics-container {
margin: 0 auto; margin: 0 auto;
padding: 48px 0px;
} }
// 집계 안내 문구 // 집계 안내 문구
@@ -34,10 +33,11 @@
// Search Section // Search Section
.statistics-search-section { .statistics-search-section {
display: flex; display: flex;
flex-direction: column;
align-items: flex-end; align-items: flex-end;
justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 30px; margin-bottom: 30px;
margin-top: 50px;
} }
// 일별/월별 모드 토글 // 일별/월별 모드 토글
@@ -46,6 +46,7 @@
background: #eef1f5; background: #eef1f5;
border-radius: 22px; border-radius: 22px;
padding: 4px; padding: 4px;
width: 150px;
} }
.mode-tab { .mode-tab {
@@ -347,9 +348,10 @@
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
margin-bottom: 10px;
svg { svg {
stroke: #fff; stroke: #1e293b;
} }
} }
@@ -375,39 +377,89 @@
padding: 0; padding: 0;
} }
.statistics-table { .board-table {
width: 100%; width: 100%;
border-collapse: collapse; border-top: 1px solid #c4c7c8;
background: #fff;
thead th { .board-table-header {
padding: 16px 20px; display: flex;
text-align: left; align-items: center;
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 {
background: #f9f9f9; 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 { .board-table-body {
background: #e3f2fd; display: flex;
flex-direction: column;
}
td { .board-table-row {
padding: 16px 20px; 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; font-weight: 600;
.row-cell {
color: #0049B4; color: #0049B4;
border-bottom: none; }
} }
} }
} }
@@ -440,39 +492,64 @@
} }
} }
// Progress Bar .api-name-text {
.progress-cell { display: inline-block;
min-width: 200px; 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; display: flex;
height: 12px;
background: #e0e0e0;
border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
.progress-success { .stats-empty {
background: #4A90D9; display: flex;
transition: width 0.3s ease; align-items: center;
justify-content: center;
height: 100px;
width: 100%;
color: #64748b;
font-weight: 400;
font-size: 16px;
} }
.progress-timeout { .progress-cell {
background: #F5C36B; min-width: 200px;
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;
} }
// Responsive // Responsive
@@ -480,6 +557,7 @@
.summary-content { .summary-content {
flex-direction: column; flex-direction: column;
gap: 30px; gap: 30px;
align-items: center;
} }
.summary-left, .summary-left,
@@ -487,6 +565,22 @@
.summary-right { .summary-right {
flex: none; flex: none;
width: 100%; 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 { .summary-center {
@@ -495,42 +589,101 @@
.summary-right { .summary-right {
flex-direction: row; flex-direction: row;
flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 20px;
}
.rate-display {
border-bottom: none;
padding: 0;
} }
} }
@media (max-width: 768px) { @media (max-width: $breakpoint-sm) {
.api-statistics-container { // .api-statistics-container {
padding: 20px 16px; // padding: 20px 16px;
} // }
.statistics-summary-card { .statistics-summary-card {
padding: 24px 20px; padding: 24px 20px;
} }
.app-toggle-buttons { .statistics-search-section {
flex-wrap: wrap; flex-direction: column;
} align-items: stretch;
gap: 16px;
.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;
} }
.search-filter-row { .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; 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 // Footer Buttons layout
.dt-actions { .dt-actions {
display: flex; display: flex;
+133 -72
View File
@@ -10,12 +10,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #EDF9FE; // Light blue background from Figma padding: 100px 20px;
padding: 40px 20px;
position: relative; position: relative;
border-radius: 12px; border-radius: 12px;
margin-top: 60px; margin-top: 60px;
margin-bottom: 60px; margin-bottom: 50px;
} }
.login-container { .login-container {
@@ -37,60 +36,104 @@
} }
} }
.login-logo { .login-header {
width: 90px; display: flex;
height: 90px; flex-direction: column;
margin-bottom: 30px; align-items: center;
margin-bottom: 40px;
img {
width: 100%; width: 100%;
height: 100%;
object-fit: contain; .header-main-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
} }
} }
.login-message { .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-family: $font-family-primary;
font-size: 20px; font-size: 36px;
font-weight: 400; font-weight: 700;
color: #000000; color: #000000;
text-align: center; margin: 0;
margin-bottom: 40px;
line-height: 1; 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 { .login-form {
width: 100%; width: 100%;
margin-bottom: 0; margin-bottom: 0;
.form-group { .form-group {
margin-bottom: 24px; margin-bottom: 16px;
position: relative;
&:last-of-type { &.input-with-icon {
margin-bottom: 0; .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 { .form-input {
width: 100%; width: 100%;
height: 70px; height: 58px;
padding: 0 32px; padding: 0 20px;
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 300;
color: #5F666C; color: #000000;
background: #FFFFFF; background: #fcfcfc;
border: 1px solid #DDDDDD; border: 1px solid #dfdfdf;
border-radius: 12px; border-radius: 5px;
outline: none; outline: none;
transition: all 0.3s ease; transition: all 0.3s ease;
&::placeholder { &::placeholder {
color: #5F666C; color: #767e89;
} }
&:focus { &:focus {
border-color: #0049B4; border-color: #1b4ab7;
background: #ffffff;
} }
&.error { &.error {
@@ -101,17 +144,16 @@
.form-checkbox { .form-checkbox {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-start;
gap: 7px; gap: 8px;
margin-top: 24px; margin-top: 14px;
margin-bottom: 22px; margin-bottom: 30px;
margin-right: 10px;
input[type="checkbox"] { input[type="checkbox"] {
width: 20px; width: 14px;
height: 20px; height: 14px;
border: 1.5px solid #000000; border: 1px solid #dfdfdf;
border-radius: 4px; border-radius: 2px;
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
background: #FFFFFF; background: #FFFFFF;
@@ -119,8 +161,8 @@
flex-shrink: 0; flex-shrink: 0;
&:checked { &:checked {
background: #FFFFFF; background: #1b4ab7;
border-color: #2C2C2C; border-color: #1b4ab7;
&::after { &::after {
content: ''; content: '';
@@ -128,22 +170,22 @@
left: 50%; left: 50%;
top: 45%; top: 45%;
transform: translate(-50%, -50%) rotate(45deg); transform: translate(-50%, -50%) rotate(45deg);
width: 6px; width: 3px;
height: 12px; height: 6px;
border: solid #2C2C2C; border: solid #ffffff;
border-width: 0 2px 2px 0; border-width: 0 1.5px 1.5px 0;
} }
} }
} }
label { label {
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 16px; font-size: 12px;
font-weight: 400; font-weight: 300;
color: #000000; color: #767e89;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
line-height: 24px; line-height: 1;
white-space: nowrap; white-space: nowrap;
} }
} }
@@ -151,26 +193,26 @@
.login-button { .login-button {
width: 100%; width: 100%;
height: 80px; height: 58px;
padding: 10px; padding: 10px;
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 20px; font-size: 16px;
font-weight: 700; font-weight: 500;
color: #FFFFFF; color: #FFFFFF;
background: #0049B4; background: #1b4ab7;
border: none; border: none;
border-radius: 12px; border-radius: 5px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
margin-bottom: 40px; margin-bottom: 30px;
line-height: 1; line-height: 20px;
&:hover { &:hover {
background: darken(#0049B4, 5%); background: darken(#1b4ab7, 5%);
} }
&:active { &:active {
background: darken(#0049B4, 10%); background: darken(#1b4ab7, 10%);
} }
&:disabled { &:disabled {
@@ -179,31 +221,51 @@
} }
} }
.login-links { .login-links-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 26px; width: 100%;
flex-wrap: wrap;
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-family: $font-family-primary;
font-size: 16px; font-size: 14px;
font-weight: 400; font-weight: 400;
color: #000000; color: #2a69de;
text-decoration: none; text-decoration: none;
transition: color 0.3s ease; transition: all 0.2s ease;
line-height: 1; 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 { &:hover {
color: #0049B4; background: darken(#ecf0fa, 4%);
text-decoration: underline;
}
} }
.link-separator { &:active {
color: #000000; background: darken(#ecf0fa, 8%);
font-size: 16px; }
} }
} }
@@ -282,9 +344,8 @@
@include respond-to('sm') { @include respond-to('sm') {
.login-page { .login-page {
// Figma: 전체 배경 #ebfbff, 풀 높이 // Figma: 전체 배경 #ebfbff, 풀 높이
background: #ebfbff;
min-height: calc(100vh - 44px); // 모바일 헤더 높이 제외 min-height: calc(100vh - 44px); // 모바일 헤더 높이 제외
padding: 40px 20px; padding: 100px 20px;
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
border-radius: 0; border-radius: 0;
@@ -26,13 +26,14 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Corporate Manager Profile Form Styles // Corporate Manager Profile Form Styles
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.corp-manager-wrapper, .password-change-wrapper { .corp-manager-wrapper,
.password-change-wrapper {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 40px 0; // padding: 40px 0;
.page-outer-title { .page-outer-title {
font-size: 36px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #000; color: #000;
margin-bottom: 24px; margin-bottom: 24px;
@@ -81,12 +82,17 @@
flex-direction: row; flex-direction: row;
gap: 34px; gap: 34px;
> div { >div {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 13px; gap: 13px;
} }
@media (max-width: $breakpoint-sm) {
flex-direction: column;
gap: 26px;
}
} }
} }
@@ -218,7 +224,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 40px; margin-top: 0;
} }
.btn-withdrawal { .btn-withdrawal {
@@ -291,6 +297,14 @@
font-weight: 500; font-weight: 500;
margin: 0; margin: 0;
} }
&.danger {
background: #fdf3f3;
p {
color: #f4253c;
}
}
} }
.password-rules-list { .password-rules-list {
@@ -93,7 +93,9 @@
th:if="${notice.noticeType == '4'}">테스트</span> th:if="${notice.noticeType == '4'}">테스트</span>
<span class="notice-type-badge notice-type-badge--normal" <span class="notice-type-badge notice-type-badge--normal"
th:if="${notice.noticeType == '1'}">공지</span> 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()}"> <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"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
@@ -39,11 +39,9 @@
<section class="service-content"> <section class="service-content">
<!-- 상단: 최근 작성한 글 목록 --> <!-- 상단: 최근 작성한 글 목록 -->
<div class="recent-apps"> <div class="recent-apps">
<div class="recent-apps-header-group"> <div class="board-header">
<p class="recent-apps-title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;"> <h2 class="board-title">피드백 / 개선 요청</h2>
피드백 / 개선 요청</p> <p class="board-desc">최근 최대 3개의 나의 피드백/ 개선 요청을 보여줍니다.</p>
<p class="recent-apps-subtitle" style="font-size: 14px; color: #7f8a95; margin-bottom: 24px;">최근 최대 3개의 나의
피드백/ 개선 요청을 보여줍니다.</p>
</div> </div>
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}"> <ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
@@ -73,10 +71,10 @@
<!-- 하단: 신규 작성 폼 카드 --> <!-- 하단: 신규 작성 폼 카드 -->
<div class="djb-board-write-container"> <div class="djb-board-write-container">
<!-- Title Bar --> <!-- Title Bar -->
<div class="djb-board-write-header" style="margin-bottom: 30px;"> <div class="board-header">
<h2 class="title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">피드백 / 개선 요청 <h2 class="board-title">피드백 / 개선 요청
</h2> </h2>
<p class="subtitle" style="font-size: 14px; color: #7f8a95; margin: 0;">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 <p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
환영합니다.</p> 환영합니다.</p>
</div> </div>
@@ -1,18 +1,26 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <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}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body> <body>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="login-page"> <div class="login-page">
<div class="login-container"> <div class="login-container">
<div class="login-card"> <div class="login-card">
<!-- Logo --> <!-- Logo & Title -->
<div class="login-logo"> <div class="login-header">
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank"> <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> </div>
<!-- Header Message -->
<p class="login-message">로그인 하시면 더 많은 서비스를 이용하실 수 있습니다.</p>
<!-- Alert Messages --> <!-- Alert Messages -->
<div th:if="${param.logout}" class="login-alert alert-info"> <div th:if="${param.logout}" class="login-alert alert-info">
@@ -33,36 +41,42 @@
</div> </div>
<!-- Login Form --> <!-- 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" id="message" name="message" th:value="${loginMessage}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" id="loginId" th:value="${loginId}"> <input type="hidden" id="loginId" th:value="${loginId}">
<!-- ID Field --> <!-- ID Field -->
<div class="form-group"> <div class="form-group input-with-icon">
<input type="text" <span class="input-icon">
id="id" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
name="id" stroke-width="2">
th:value="${loginId}" <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
class="form-input" <circle cx="12" cy="7" r="4" />
placeholder="아이디" </svg>
required> </span>
<input type="text" id="id" name="id" th:value="${loginId}" class="form-input"
placeholder="아이디를 입력해주세요" required>
</div> </div>
<!-- Password Field --> <!-- Password Field -->
<div class="form-group"> <div class="form-group input-with-icon">
<input type="password" <span class="input-icon">
id="password" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
name="password" stroke-width="2">
class="form-input" <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
placeholder="비밀번호" <path d="M7 11V7a5 5 0 0 1 10 0v4" />
required> </svg>
</span>
<input type="password" id="password" name="password" class="form-input"
placeholder="비밀번호를 입력해주세요" required>
</div> </div>
<!-- Remember Me Checkbox --> <!-- Remember Me Checkbox -->
<div class="form-checkbox"> <div class="form-checkbox">
<input id="checkId" name="checkId" type="checkbox"> <input id="checkId" name="checkId" type="checkbox">
<label for="checkId">아이디저장</label> <label for="checkId">아이디 저장</label>
</div> </div>
</form> </form>
@@ -72,12 +86,10 @@
</button> </button>
<!-- Additional Links --> <!-- Additional Links -->
<div class="login-links"> <div class="login-links-container">
<a th:href="@{/account_recovery(tab='findId')}">아이디 찾기</a> <a th:href="@{/account_recovery(tab='findId')}" class="link-btn first">아이디 찾기</a>
<span class="link-separator">|</span> <a th:href="@{/account_recovery(tab='resetPassword')}" class="link-btn middle">비밀번호 초기화</a>
<a th:href="@{/account_recovery(tab='resetPassword')}">비밀번호 초기화</a> <a th:href="@{/signup}" class="link-btn last">회원가입</a>
<span class="link-separator">|</span>
<a th:href="@{/signup}">회원가입</a>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
@@ -87,7 +99,7 @@
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
@@ -182,7 +194,7 @@
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고, // 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다. // 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
var successMsg = [[${success}]]; var successMsg = [[${ success }]];
console.log("Success message:", successMsg); console.log("Success message:", successMsg);
if (successMsg) { if (successMsg) {
console.log("Showing alert"); console.log("Showing alert");
@@ -190,13 +202,13 @@
} }
// 비밀번호 만료 체크 관련 코드 추가 // 비밀번호 만료 체크 관련 코드 추가
var successMsg = [[${session.success}]]; var successMsg = [[${ session.success }]];
var passwordExpired = [[${session.passwordExpired}]]; var passwordExpired = [[${ session.passwordExpired }]];
var emailVerificationRequired = [[${session.emailVerificationRequired}]]; var emailVerificationRequired = [[${ session.emailVerificationRequired }]];
var redirectUrl = [[${session.redirectUrl}]]; var redirectUrl = [[${ session.redirectUrl }]];
if (successMsg) { if (successMsg) {
customPopups.showAlert(successMsg, function() { customPopups.showAlert(successMsg, function () {
// 비밀번호 만료·미인증(이메일 인증 필요) 시 전용 화면으로 이동 // 비밀번호 만료·미인증(이메일 인증 필요) 시 전용 화면으로 이동
if ((passwordExpired || emailVerificationRequired) && redirectUrl) { if ((passwordExpired || emailVerificationRequired) && redirectUrl) {
window.location.href = redirectUrl; window.location.href = redirectUrl;
@@ -237,7 +249,7 @@
form.classList.add('was-validated'); form.classList.add('was-validated');
}; };
$('#actionLogin').on('click', function(event) { $('#actionLogin').on('click', function (event) {
event.preventDefault(); event.preventDefault();
actionLogin(event); actionLogin(event);
}); });
@@ -252,4 +264,5 @@
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -25,9 +25,13 @@
<!-- 모든 스타일은 detail-wrap 안에서만 적용 --> <!-- 모든 스타일은 detail-wrap 안에서만 적용 -->
<div class="detail-wrap"> <div class="detail-wrap">
<!-- Title Bar (Figma 및 스크린샷 대응) --> <!-- Title Bar (Figma 및 스크린샷 대응) -->
<div class="common-title-bar"> <div class="board-header">
<h2 class="common-title" th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '인증키 정보' : '신청 상세'}">신청 상세</h2> <h2 class="board-title"
<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> 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> </div>
<!-- App Basic Info Card --> <!-- App Basic Info Card -->
@@ -49,7 +53,7 @@
<div class="dt-app-status-row" th:if="${appRequest.approval != null}"> <div class="dt-app-status-row" th:if="${appRequest.approval != null}">
<span class="dt-status-badge" <span class="dt-status-badge"
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' : 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}"> th:text="${appRequest.approval.approvalStatus.description}">
대기중 대기중
</span> </span>
@@ -80,7 +84,8 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" <svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"> stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> <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> </svg>
<span>복사</span> <span>복사</span>
</button> </button>
@@ -176,7 +181,8 @@
</div> </div>
<!-- 승인일시 (approvalDate 가 있을 때만) --> <!-- 승인일시 (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"> <div class="dt-col">
<label class="dt-label">승인일시</label> <label class="dt-label">승인일시</label>
<div class="dt-input-group"> <div class="dt-input-group">
@@ -204,11 +210,16 @@
</div> </div>
<!-- 내부 결재 진행중: 취소 불가 안내 --> <!-- 내부 결재 진행중: 취소 불가 안내 -->
<p class="dt-cancel-notice" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" <div class="info-notice-box danger" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}"> appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요. <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4253c"
</p> 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 --> <!-- Bottom Navigation Actions -->
<div class="dt-actions"> <div class="dt-actions">
@@ -1,19 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/djbank_title_layout}">
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/djbank_title_layout}">
<head> <head>
<title>API 키 상세</title> <title>API 키 상세</title>
</head> </head>
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1> <h1>앱관리</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout"> <div class="service-main app-management-layout">
@@ -24,16 +24,18 @@
<div class="app-management-content"> <div class="app-management-content">
<div class="detail-wrap"> <div class="detail-wrap">
<!-- Title Bar --> <!-- Title Bar -->
<div class="common-title-bar"> <div class="board-header">
<h2 class="common-title">인증키 정보</h2> <h2 class="board-title">인증키 정보</h2>
<span class="common-subtitle">등록된 앱의 상세 정보를 확인할 수 있습니다.</span> <span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div> </div>
<!-- App Basic Info Card --> <!-- App Basic Info Card -->
<div class="dt-app-card"> <div class="dt-app-card">
<div class="dt-app-icon-box"> <div class="dt-app-icon-box">
<img th:if="${apiKey.appIconFileId != null}" th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘"> <img th:if="${apiKey.appIconFileId != null}"
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5"> 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> <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> <circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline> <polyline points="21 15 16 10 5 21"></polyline>
@@ -41,10 +43,12 @@
</div> </div>
<div class="dt-app-info"> <div class="dt-app-info">
<div class="dt-app-status-row"> <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> </div>
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3> <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>
</div> </div>
@@ -57,8 +61,10 @@
<label class="dt-label">Client ID</label> <label class="dt-label">Client ID</label>
<div class="dt-input-group"> <div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.clientid}">K00000001</div> <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)"> <button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}"
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 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> <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> </svg>
@@ -73,7 +79,8 @@
<div class="dt-col"> <div class="dt-col">
<label class="dt-label"> <label class="dt-label">
Client Secret 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> 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> </label>
@@ -81,8 +88,13 @@
<th:block th:if="${secretAvailable}"> <th:block th:if="${secretAvailable}">
<!-- View Button (shown initially) --> <!-- View Button (shown initially) -->
<div class="dt-input-group" id="hiddenSecretBox"> <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()"> <button type="button"
<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;"> 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> <circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path> <path d="m21 21-4.35-4.35"></path>
</svg> </svg>
@@ -94,15 +106,18 @@
<div id="revealedSecretBox" style="display: none; width: 100%;"> <div id="revealedSecretBox" style="display: none; width: 100%;">
<div class="dt-input-group"> <div class="dt-input-group">
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div> <div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)"> <button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn"
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 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> <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> </svg>
<span>복사</span> <span>복사</span>
</button> </button>
</div> </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> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요. 반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
</p> </p>
@@ -110,10 +125,12 @@
</th:block> </th:block>
<!-- secret이 이미 노출·삭제됨 --> <!-- 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> <span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()" <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> </div>
</div> </div>
@@ -135,7 +152,8 @@
<div class="dt-col"> <div class="dt-col">
<label class="dt-label">화이트 리스트</label> <label class="dt-label">화이트 리스트</label>
<div class="dt-input-group"> <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> </div>
</div> </div>
@@ -161,7 +179,8 @@
<div class="dt-col"> <div class="dt-col">
<label class="dt-label">최종 수정일자</label> <label class="dt-label">최종 수정일자</label>
<div class="dt-input-group"> <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> </div>
</div> </div>
@@ -170,8 +189,10 @@
<!-- Bottom Navigation Actions --> <!-- Bottom Navigation Actions -->
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;"> <div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a> <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> <button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue" th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a> 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>
</div><!-- /detail-wrap --> </div><!-- /detail-wrap -->
@@ -179,9 +200,9 @@
</div><!-- /service-main --> </div><!-- /service-main -->
</div><!-- /signup-guide-v2 --> </div><!-- /signup-guide-v2 -->
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ /*<![CDATA[*/
@@ -202,7 +223,7 @@
customPopups.showPasswordInput({ customPopups.showPasswordInput({
title: 'Client Secret 조회', title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.', message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function(password) { onConfirm: function (password) {
$.ajax({ $.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret', url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST', type: 'POST',
@@ -211,7 +232,7 @@
headers: { headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
} }
}).done(function(response) { }).done(function (response) {
if (response.success) { if (response.success) {
customPopups.hidePasswordInput(); customPopups.hidePasswordInput();
@@ -227,11 +248,11 @@
} else { } else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.'); customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
} }
}).fail(function() { }).fail(function () {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.'); customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
}); });
}, },
onCancel: function() { onCancel: function () {
// User cancelled - do nothing // User cancelled - do nothing
} }
}); });
@@ -242,9 +263,9 @@
var text = $(button).data('secret'); var text = $(button).data('secret');
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() { navigator.clipboard.writeText(text).then(function () {
alert('클립보드에 복사되었습니다.'); alert('클립보드에 복사되었습니다.');
}).catch(function(err) { }).catch(function (err) {
console.error('Failed to copy:', err); console.error('Failed to copy:', err);
fallbackCopy(text); fallbackCopy(text);
}); });
@@ -273,7 +294,7 @@
function deleteApiKeyFromButton(button) { function deleteApiKeyFromButton(button) {
var clientId = $(button).data('client-id'); var clientId = $(button).data('client-id');
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) { customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function (confirmed) {
if (!confirmed) { if (!confirmed) {
return; return;
} }
@@ -292,17 +313,17 @@
headers: { headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
} }
}).done(function(response) { }).done(function (response) {
if (response && response.success === false) { if (response && response.success === false) {
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.'); customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
return; return;
} }
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() { customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey'; window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
}); });
}).fail(function(jqXHR, textStatus, errorThrown) { }).fail(function (jqXHR, textStatus, errorThrown) {
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown); customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() { }).always(function () {
$('.loading-overlay').hide(); $('.loading-overlay').hide();
}); });
}); });
@@ -311,7 +332,8 @@
/*]]>*/ /*]]>*/
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -9,8 +9,8 @@
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="service-main app-management-layout"> <div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
<div class="corp-manager-wrapper"> <div class="corp-manager-wrapper">
<h2 class="page-outer-title">법인 기본 정보</h2> <h2 class="page-outer-title">법인 기본 정보</h2>
@@ -3,101 +3,15 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>APP 통계</h1> <h1>APP 통계</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/> <!-- <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>
<div class="service-main app-management-layout"> <div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
@@ -156,9 +70,7 @@
<div class="app-select-wrapper"> <div class="app-select-wrapper">
<select id="appSelect" class="app-select"> <select id="appSelect" class="app-select">
<option value="">전체 앱</option> <option value="">전체 앱</option>
<option th:each="app : ${appList}" <option th:each="app : ${appList}" th:value="${app.clientId}" th:text="${app.clientName}"
th:value="${app.clientId}"
th:text="${app.clientName}"
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}"> th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}">
앱 이름 앱 이름
</option> </option>
@@ -167,32 +79,45 @@
<div class="summary-info"> <div class="summary-info">
<p class="summary-total-text"> <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> </p>
<div class="summary-detail-list"> <div class="summary-detail-list">
<div class="summary-detail-item success"> <div class="summary-detail-item success">
<span class="detail-icon"> <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>
<span class="detail-label">성공</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-value" id="successCount"
<span class="detail-percent">(<span id="successRateText" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span> 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>
<div class="summary-detail-item timeout"> <div class="summary-detail-item timeout">
<span class="detail-icon"> <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>
<span class="detail-label">타임아웃</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-value" id="timeoutCount"
<span class="detail-percent">(<span id="timeoutRateText" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span> 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>
<div class="summary-detail-item failure"> <div class="summary-detail-item failure">
<span class="detail-icon"> <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>
<span class="detail-label">실패</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-value" id="errorCount"
<span class="detail-percent">(<span id="errorRateText" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span> 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> </div>
</div> </div>
@@ -201,21 +126,18 @@
<!-- Center: Donut Chart (SVG, 3 segments: 성공/타임아웃/실패) --> <!-- Center: Donut Chart (SVG, 3 segments: 성공/타임아웃/실패) -->
<div class="summary-center"> <div class="summary-center">
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200"> <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 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" <circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#4A90D9"
stroke="#4A90D9" stroke-width="24" stroke-linecap="butt" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'" th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'" stroke-dashoffset="0"
stroke-dashoffset="0" transform="rotate(-90 100 100)"/> transform="rotate(-90 100 100)" />
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none" <circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#F5C36B"
stroke="#F5C36B" stroke-width="24" stroke-linecap="butt" stroke-width="24" stroke-linecap="butt" th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8', stroke-dashoffset='-' + ${summary.successRate * 4.398}" transform="rotate(-90 100 100)" />
stroke-dashoffset='-' + ${summary.successRate * 4.398}" <circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#F5A3B5"
transform="rotate(-90 100 100)"/> stroke-width="24" stroke-linecap="butt" th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
<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}" stroke-dashoffset='-' + ${(summary.successRate + summary.timeoutRate) * 4.398}"
transform="rotate(-90 100 100)"/> transform="rotate(-90 100 100)" />
</svg> </svg>
</div> </div>
@@ -224,19 +146,22 @@
<div class="rate-display success-rate"> <div class="rate-display success-rate">
<span class="rate-indicator success"></span> <span class="rate-indicator success"></span>
<span class="rate-label">성공률</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> <span class="rate-percent">%</span>
</div> </div>
<div class="rate-display timeout-rate"> <div class="rate-display timeout-rate">
<span class="rate-indicator timeout"></span> <span class="rate-indicator timeout"></span>
<span class="rate-label">타임아웃율</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> <span class="rate-percent">%</span>
</div> </div>
<div class="rate-display failure-rate"> <div class="rate-display failure-rate">
<span class="rate-indicator failure"></span> <span class="rate-indicator failure"></span>
<span class="rate-label">실패률</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> <span class="rate-percent">%</span>
</div> </div>
</div> </div>
@@ -245,140 +170,164 @@
<!-- API별 통계 Table Section --> <!-- API별 통계 Table Section -->
<div class="statistics-table-section" style="margin-top: 40px;"> <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;"> <div class="table-controls">
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;"> <h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/> <path d="M3 3v18h18" />
<path d="M18 9l-5 5-4-4-4 4"/> <path d="M18 9l-5 5-4-4-4 4" />
</svg> </svg>
API별 통계 API별 통계
</h3> </h3>
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
</div> </div>
<table class="stats-table stats-table--api"> <div class="board-table">
<thead> <!-- Table Header -->
<tr> <div class="board-table-header">
<th class="col-name">API명</th> <div class="header-cell col-name" style="flex: 2;">API명</div>
<th class="col-num">API호출수</th> <div class="header-cell col-num" style="width: 100px;">API호출수</div>
<th class="col-num">성공</th> <div class="header-cell col-num" style="width: 100px;">성공</div>
<th class="col-num">실패</th> <div class="header-cell col-num" style="width: 100px;">실패</div>
<th class="col-num">타임아웃</th> <div class="header-cell col-num" style="width: 100px;">타임아웃</div>
<th class="col-chart">통계</th> <div class="header-cell col-chart" style="width: 180px;">통계</div>
</tr> </div>
</thead>
<tbody id="statisticsTableBody"> <!-- Table Body -->
<tr th:each="detail : ${details}"> <div class="board-table-body" id="statisticsTableBody">
<td class="col-name"> <div class="board-table-row" th:each="detail : ${details}">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon"> <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"
<rect x="3" y="3" width="18" height="18" rx="2"/> stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg> </svg>
</span><span class="api-name-text" th:text="${detail.apiName}">API 이름</span> </span>
</td> <span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
<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>
</div> </div>
</td> <div class="row-cell col-num" style="width: 100px;" data-label="API호출수"
</tr> th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</div>
<tr th:if="${details == null or details.isEmpty()}" class="stats-empty"> <div class="row-cell col-num" style="width: 100px;" data-label="성공"
<td colspan="6">데이터가 없습니다.</td> th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</div>
</tr> <div class="row-cell col-num" style="width: 100px;" data-label="실패"
</tbody> th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</div>
<tfoot id="statisticsTableFoot"> <div class="row-cell col-num" style="width: 100px;" data-label="타임아웃"
<tr class="total-row" th:if="${details != null and !details.isEmpty()}"> th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</div>
<td class="col-name"> <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"> <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"
<path d="M3 3v18h18"/> stroke-width="2">
<path d="M18 9l-5 5-4-4-4 4"/> <path d="M3 3v18h18" />
<path d="M18 9l-5 5-4-4-4 4" />
</svg> </svg>
</span><span class="api-name-text">전체</span> </span>
</td> <span class="api-name-text">전체</span>
<td class="col-num" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td> </div>
<td class="col-num" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td> <div class="row-cell col-num" style="width: 100px;" data-label="전체 API호출수"
<td class="col-num" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td> th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</div>
<td class="col-num" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td> <div class="row-cell col-num" style="width: 100px;" data-label="전체 성공"
<td class="col-chart"> th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</div>
<div class="stats-progress"> <div class="row-cell col-num" style="width: 100px;" data-label="전체 실패"
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div> th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</div>
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div> <div class="row-cell col-num" style="width: 100px;" data-label="전체 타임아웃"
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div> 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> </div>
</td>
</tr>
</tfoot>
</table>
</div> </div>
<!-- 기간별(일별/월별) 누적 통계 Table Section --> <!-- 기간별(일별/월별) 누적 통계 Table Section -->
<div class="statistics-table-section" style="margin-top: 40px;"> <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;"> <div class="table-controls">
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;"> <h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18"/> <path d="M16 2v4M8 2v4M3 10h18" />
</svg> </svg>
<span id="periodTableTitle">일별 통계</span> <span id="periodTableTitle">일별 통계</span>
</h3> </h3>
</div> </div>
<table class="stats-table stats-table--period"> <div class="board-table">
<thead> <!-- Table Header -->
<tr> <div class="board-table-header">
<th id="periodLabel">일자</th> <div class="header-cell" id="periodLabel" style="flex: 1;">일자</div>
<th>API호출수</th> <div class="header-cell" style="flex: 1;">API호출수</div>
<th>성공</th> <div class="header-cell" style="flex: 1;">성공</div>
<th>실패</th> <div class="header-cell" style="flex: 1;">실패</div>
<th>타임아웃</th> <div class="header-cell" style="flex: 1;">타임아웃</div>
</tr> </div>
</thead> <!-- Table Body -->
<tbody id="periodTableBody"> <div class="board-table-body" id="periodTableBody">
<tr th:each="p : ${periods}"> <div class="board-table-row" th:each="p : ${periods}">
<td th:text="${p.period}">2026-07-15 (수)</td> <div class="row-cell" style="flex: 1;" data-label="일자" th:text="${p.period}">2026-07-15 (수)</div>
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td> <div class="row-cell" style="flex: 1;" data-label="API호출수"
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td> th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</div>
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td> <div class="row-cell" style="flex: 1;" data-label="성공"
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td> th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</div>
</tr> <div class="row-cell" style="flex: 1;" data-label="실패"
<tr th:if="${periods == null or periods.isEmpty()}" class="stats-empty"> th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</div>
<td colspan="5">데이터가 없습니다.</td> <div class="row-cell" style="flex: 1;" data-label="타임아웃"
</tr> th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</div>
</tbody> </div>
</table> <div th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
데이터가 없습니다.
</div>
</div>
</div>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
// Initial data from server // Initial data from server
let currentData = { let currentData = {
summary: { summary: {
totalCount: [[${summary.totalCount}]], totalCount: [[${ summary.totalCount }]],
successCount: [[${summary.successCount}]], successCount: [[${ summary.successCount }]],
timeoutCount: [[${summary.timeoutCount}]], timeoutCount: [[${ summary.timeoutCount }]],
errorCount: [[${summary.errorCount}]], errorCount: [[${ summary.errorCount }]],
successRate: [[${summary.successRate}]], successRate: [[${ summary.successRate }]],
timeoutRate: [[${summary.timeoutRate}]], timeoutRate: [[${ summary.timeoutRate }]],
errorRate: [[${summary.errorRate}]] errorRate: [[${ summary.errorRate }]]
}, },
details: [[${details}]], details: [[${ details }]],
periods: [[${periods}]] periods: [[${ periods }]]
}; };
let selectedClientId = [[${searchDto.clientId}]] || ''; let selectedClientId = [[${ searchDto.clientId }]] || '';
let currentMode = 'DAILY'; let currentMode = 'DAILY';
const CIRCUMFERENCE = 439.8; // 2 * PI * 70 const CIRCUMFERENCE = 439.8; // 2 * PI * 70
const MAX_DATE_RANGE_DAYS = 40; const MAX_DATE_RANGE_DAYS = 40;
@@ -415,32 +364,32 @@
tbody.empty(); tbody.empty();
if (currentData.details && currentData.details.length > 0) { if (currentData.details && currentData.details.length > 0) {
currentData.details.forEach(function(d) { currentData.details.forEach(function (d) {
tbody.append(` tbody.append(`
<tr> <div class="board-table-row">
<td class="col-name"> <div class="row-cell col-name" style="flex: 2;">
<span class="api-icon"> <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"/> <rect x="3" y="3" width="18" height="18" rx="2"/>
</svg> </svg>
</span><span class="api-name-text">${d.apiName || '-'}</span> </span><span class="api-name-text">${d.apiName || '-'}</span>
</td> </div>
<td class="col-num">${numberWithCommas(d.totalCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="API호출수">${numberWithCommas(d.totalCount)}</div>
<td class="col-num">${numberWithCommas(d.successCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="성공">${numberWithCommas(d.successCount)}</div>
<td class="col-num">${numberWithCommas(d.errorCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="실패">${numberWithCommas(d.errorCount)}</div>
<td class="col-num">${numberWithCommas(d.timeoutCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="타임아웃">${numberWithCommas(d.timeoutCount)}</div>
<td class="col-chart"> <div class="row-cell col-chart" style="width: 180px;" data-label="통계">
<div class="stats-progress"> <div class="stats-progress">
<div class="progress-success" style="width:${d.successRate}%; background: #4A90D9;"></div> <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-timeout" style="width:${d.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${d.errorRate}%; background: #F5A3B5;"></div> <div class="progress-failure" style="width:${d.errorRate}%; background: #F5A3B5;"></div>
</div> </div>
</td> </div>
</tr>`); </div>`);
}); });
renderTotalFooter(); renderTotalFooter();
} else { } else {
tbody.html('<tr class="stats-empty"><td colspan="6">데이터가 없습니다.</td></tr>'); tbody.html('<div class="stats-empty">데이터가 없습니다.</div>');
$('#statisticsTableFoot').empty(); $('#statisticsTableFoot').empty();
} }
} }
@@ -448,26 +397,26 @@
function renderTotalFooter() { function renderTotalFooter() {
var sm = currentData.summary; var sm = currentData.summary;
$('#statisticsTableFoot').html(` $('#statisticsTableFoot').html(`
<tr class="total-row"> <div class="board-table-row total-row">
<td class="col-name"> <div class="row-cell col-name" style="flex: 2;">
<span class="api-icon total"> <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"/> <path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg> </svg>
</span><span class="api-name-text">전체</span> </span><span class="api-name-text">전체</span>
</td> </div>
<td class="col-num">${numberWithCommas(sm.totalCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="전체 API호출수">${numberWithCommas(sm.totalCount)}</div>
<td class="col-num">${numberWithCommas(sm.successCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="전체 성공">${numberWithCommas(sm.successCount)}</div>
<td class="col-num">${numberWithCommas(sm.errorCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="전체 실패">${numberWithCommas(sm.errorCount)}</div>
<td class="col-num">${numberWithCommas(sm.timeoutCount)}</td> <div class="row-cell col-num" style="width: 100px;" data-label="전체 타임아웃">${numberWithCommas(sm.timeoutCount)}</div>
<td class="col-chart"> <div class="row-cell col-chart" style="width: 180px;" data-label="전체 통계">
<div class="stats-progress"> <div class="stats-progress">
<div class="progress-success" style="width:${sm.successRate}%; background: #4A90D9;"></div> <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-timeout" style="width:${sm.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${sm.errorRate}%; background: #F5A3B5;"></div> <div class="progress-failure" style="width:${sm.errorRate}%; background: #F5A3B5;"></div>
</div> </div>
</td> </div>
</tr>`); </div>`);
} }
// ── 기간별(일/월) 표 (table DOM) ── // ── 기간별(일/월) 표 (table DOM) ──
@@ -475,18 +424,18 @@
const tbody = $('#periodTableBody'); const tbody = $('#periodTableBody');
tbody.empty(); tbody.empty();
if (currentData.periods && currentData.periods.length > 0) { if (currentData.periods && currentData.periods.length > 0) {
currentData.periods.forEach(function(p) { currentData.periods.forEach(function (p) {
tbody.append(` tbody.append(`
<tr> <div class="board-table-row">
<td>${p.period || '-'}</td> <div class="row-cell" style="flex: 1;" data-label="일자">${p.period || '-'}</div>
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td> <div class="row-cell" style="flex: 1;" data-label="API호출수">${p.hasData ? numberWithCommas(p.totalCount) : '-'}</div>
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td> <div class="row-cell" style="flex: 1;" data-label="성공">${p.hasData ? numberWithCommas(p.successCount) : '-'}</div>
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td> <div class="row-cell" style="flex: 1;" data-label="실패">${p.hasData ? numberWithCommas(p.errorCount) : '-'}</div>
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td> <div class="row-cell" style="flex: 1;" data-label="타임아웃">${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</div>
</tr>`); </div>`);
}); });
} else { } else {
tbody.html('<tr class="stats-empty"><td colspan="5">데이터가 없습니다.</td></tr>'); tbody.html('<div class="stats-empty">데이터가 없습니다.</div>');
} }
} }
@@ -538,7 +487,7 @@
endDate: $('#endDate').val(), endDate: $('#endDate').val(),
month: $('#month').val() || '' month: $('#month').val() || ''
})); }));
} catch (e) {} } catch (e) { }
} }
function restoreParams() { function restoreParams() {
try { try {
@@ -574,16 +523,16 @@
url: '/statistics/api/search', url: '/statistics/api/search',
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
beforeSend: function(xhr) { xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()); }, beforeSend: function (xhr) { xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()); },
data: JSON.stringify(buildPayload()), data: JSON.stringify(buildPayload()),
success: function(data) { success: function (data) {
currentData = data; currentData = data;
updateSummaryDisplay(); updateSummaryDisplay();
updateChart(); updateChart();
updateTable(); updateTable();
updatePeriodTable(); updatePeriodTable();
}, },
error: function(xhr) { error: function (xhr) {
var errorMsg = '통계 조회에 실패했습니다.'; var errorMsg = '통계 조회에 실패했습니다.';
if (xhr.responseJSON && xhr.responseJSON.error) errorMsg = xhr.responseJSON.error; if (xhr.responseJSON && xhr.responseJSON.error) errorMsg = xhr.responseJSON.error;
alert(errorMsg); alert(errorMsg);
@@ -592,7 +541,7 @@
} }
// ── 모드 토글 ── // ── 모드 토글 ──
$('.mode-tab').on('click', function() { $('.mode-tab').on('click', function () {
currentMode = $(this).data('mode'); currentMode = $(this).data('mode');
$('.mode-tab').removeClass('active'); $('.mode-tab').removeClass('active');
$(this).addClass('active'); $(this).addClass('active');
@@ -609,10 +558,10 @@
// Event Handlers // Event Handlers
$('#btnSearch').on('click', searchStatistics); $('#btnSearch').on('click', searchStatistics);
$('#appSelect').on('change', function() { selectedClientId = $(this).val(); searchStatistics(); }); $('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
$('#startDate, #endDate').on('change', searchStatistics); $('#startDate, #endDate').on('change', searchStatistics);
$('#month').on('change', searchStatistics); $('#month').on('change', searchStatistics);
$('#startDate, #endDate').on('keypress', function(e) { if (e.which === 13) searchStatistics(); }); $('#startDate, #endDate').on('keypress', function (e) { if (e.which === 13) searchStatistics(); });
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지) // 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
if (restoreParams()) { if (restoreParams()) {
@@ -620,7 +569,8 @@
} }
}); });
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>