12 Commits

Author SHA1 Message Date
Rinjae 37be9a361a 로고 및 스타일 변경 반영:
- 로고 사이즈 수정 (114px → 144px) 및 레이아웃 조정
- 컬러 및 폰트 스타일 Figma 가이드 기준 업데이트
- 버튼/배경 스타일 효과 개선 (글래스, 알약 태그 등)
2026-07-01 09:54:50 +09:00
Rinjae a91b56ed11 - 법인 탈퇴 시 민감정보 삭제 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSS 수정: 폰트 크기 제거 및 스타일 변경 적용
- 예상완료일 요일 계산 로직 추가
2026-06-30 15:38:40 +09:00
Rinjae 8d232734b1 IP 등록 UI 및 검증 로직 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `.ip-input-row`/`.btn-add-ip` 정렬 및 버튼 크기 수정
- Callback URL 관련 입력 및 검증 로직 주석 처리
- 화이트리스트 필수 입력 항목 검증 로직 추가
2026-06-30 10:54:22 +09:00
Rinjae 06c18e437b 회원가입 제출 필드 개선: 선택 입력 포함 모든 항목 전송 처리 2026-06-30 10:54:08 +09:00
Rinjae 97b9d2161a IP 입력 필드 스타일 개선: 정렬 및 버튼 크기 수정
- `.ip-input-row` 정렬 기준 추가 및 버튼 폭 고정
- `.btn-add-ip` 및 제거 버튼 스타일/반응형 조정
2026-06-30 10:54:00 +09:00
Rinjae 0c3b63a5b1 - UserSessionService 목 객체 추가 및 관련 의존성 주입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 시 휴대폰 번호 형식 통일 (`01012345678` → `010-1234-5678`)
- AccountControllerTest 테스트 코드 수정 및 누락 보완
2026-06-30 09:45:08 +09:00
Rinjae 582a3e0f69 회원가입 필수 항목 검증 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 입력/미충족 항목 구체적 수집 및 에러 메시지 개선
- 시나리오별/공통 항목 검증 로직 추가
2026-06-30 09:42:34 +09:00
Rinjae c1ae12e1ca 전화번호 정규화 유틸 추가 및 적용:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PhoneNumberUtil 도입: 한국번호 정규화/숫자 추출 지원
- 유틸 적용: 모든 저장 및 조회에 정규형 강제
- 초대 매칭 오류 수정: 휴대폰 형식 차이 대응
2026-06-29 18:26:15 +09:00
Rinjae 77aea79363 콘솔 로그 설정 추가 및 레벨 제어 기능 구현
- JVM 파라미터 기반 CONSOLE_LOG_ENABLED, LOG_LEVEL 제어 추가
- Stage 프로파일 정리 및 콘솔 로그 필터 설정 적용
2026-06-29 18:24:37 +09:00
Rinjae aa5e769148 JTA 설정 비활성화 및 신규 회원가입 경로 추가
- `spring.jta.enabled` 값 false로 변경 (stage/dev/prod 공통)
- 법인회원 초대 경로(`/signup/decision_process`) 신규 추가
2026-06-29 18:24:26 +09:00
Rinjae ee8e9e3529 CycloneDX BOM 플러그인 추가 - build.gradle에 org.cyclonedx.bom 적용 2026-06-29 18:24:07 +09:00
Rinjae 82948a13ce CSRF 만료 세션 처리 및 정적 리소스 캐싱 제어 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 만료 시 기존 세션 만료 안내로 리다이렉트 처리
- 정적 리소스 캐싱 토글 기능 추가 (개발 환경 즉시 반영 지원)
- 회원가입 페이지 및 세션 설정 스타일 수정
2026-06-26 15:05:19 +09:00
108 changed files with 9187 additions and 19722 deletions
+1 -6
View File
@@ -107,9 +107,4 @@ TODO.txt
diff
*rinjae*
*gf63*
# Local publishing and config files
/publishing/
/src/main/resources/application-local.yml
/src/main/resources/logback-local.xml
*gf63*
+1
View File
@@ -3,6 +3,7 @@ plugins {
id 'war'
id 'eclipse'
id 'idea'
id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3'
}
+47
View File
@@ -8,6 +8,9 @@
"name": "eapim-portal",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.61.1"
},
"devDependencies": {
"sass": "^1.69.5"
}
@@ -349,6 +352,20 @@
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -403,6 +420,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+4 -1
View File
@@ -13,5 +13,8 @@
"sass": "^1.69.5"
},
"author": "",
"license": "ISC"
"license": "ISC",
"dependencies": {
"playwright": "^1.61.1"
}
}
+1 -2
View File
@@ -254,8 +254,7 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
)
;
create table PT_TOKEN
(
create table DVPOWN.PT_TOKEN
(
TOKEN VARCHAR2(255) not null
primary key,
@@ -22,10 +22,6 @@ public class PortalApplication extends SpringBootServletInitializer {
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
public PortalApplication() {
super();
}
public static void main(String[] args) {
portal_logger.info("##### PortalApplication Start #####");
@@ -31,4 +31,21 @@ public class ApprovalDTO {
private LocalDateTime createdDate;
private LocalDateTime approvalDate;
private String expectEndDate; //예상완료일 (yyyyMMdd)
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
public String getExpectEndDateDayOfWeek() {
if (expectEndDate == null || expectEndDate.length() < 8) {
return "";
}
try {
return java.time.LocalDate
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
.getDayOfWeek()
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
} catch (Exception e) {
return "";
}
}
}
@@ -19,6 +19,10 @@ public interface UserSessionRepository extends JpaRepository<UserSession, String
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
int forceLogoutAllSessions(@Param("loginId") String loginId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@@ -74,6 +74,18 @@ public class UserSessionService {
}
}
/**
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
*/
@Transactional
public void forceLogoutAllSessions(String loginId) {
int count = userSessionRepository.forceLogoutAllSessions(loginId);
if (count > 0) {
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@@ -1,28 +1,18 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@@ -53,6 +48,7 @@ public class AccountController {
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@PostMapping("/confirm_password")
@@ -105,6 +101,12 @@ public class AccountController {
session.removeAttribute("success");
session.removeAttribute("redirectUrl");
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
@@ -143,7 +145,7 @@ public class AccountController {
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationMobileAndStatus(
currentUser.getMobileNumber(), InvitationStatus.PENDING);
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
@@ -7,9 +7,11 @@ import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -63,6 +65,7 @@ public class UserRegisterController {
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@GetMapping("/signup")
public String showSignupSelection() {
@@ -243,6 +246,11 @@ public class UserRegisterController {
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
// 이미 로그인된 상태면 수락 화면으로 직행
if (SecurityUtil.isAuthenticated()) {
return "redirect:/signup/decision_process";
}
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
@@ -281,7 +289,7 @@ public class UserRegisterController {
public String showDecisionProcessPage(HttpSession session, Model model) {
session.removeAttribute("decisionToken");
String mobile = SecurityUtil.getPortalAuthenticatedUser().getMobileNumber();
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
if (!invitation.isPresent()) {
@@ -305,6 +313,8 @@ public class UserRegisterController {
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -323,7 +333,7 @@ public class UserRegisterController {
agreementValidator.validate(agreement, bindingResult);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber(), UserInvitationEnums.InvitationStatus.PENDING);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
@@ -334,12 +344,19 @@ public class UserRegisterController {
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
} else {
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
portalUserAuthService.reloadCurrentAuthentication();
}
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
session.removeAttribute("pendingInvitation");
session.removeAttribute("pendingInvitationToken");
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
@@ -19,6 +20,8 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.service.MessageHandlerService;
@@ -54,6 +57,8 @@ public class UserManFacade {
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
private final CellPhoneValidator cellPhoneValidator;
private final UserSessionService userSessionService;
private final PortalUserAuthService portalUserAuthService;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -178,16 +183,7 @@ public class UserManFacade {
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
*/
private String normalizeMobile(String raw) {
if (raw == null) {
return null;
}
String digits = raw.replaceAll("[^0-9]", "");
if (digits.length() == 11) {
return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
} else if (digits.length() == 10) {
return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
}
return raw;
return PhoneNumberUtil.normalize(raw);
}
public String createInvitation(PortalUser sender, String mobile) {
@@ -253,6 +249,10 @@ public class UserManFacade {
portalUserRepository.save(targetUser);
portalUserRepository.save(currentUser);
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
portalUserAuthService.reloadCurrentAuthentication();
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(targetUser.getEmailAddr());
recipient.setPhone(targetUser.getMobileNumber());
@@ -284,6 +284,9 @@ public class UserManFacade {
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
portalUserRepository.save(targetUser);
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
/**
@@ -305,6 +308,9 @@ public class UserManFacade {
targetUser.setPortalOrg(null);
targetUser.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(targetUser);
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
@@ -263,7 +263,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
ValidationResponse response = new ValidationResponse();
response.setValid(true);
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
response.setShowPopup(true);
response.setPopupType("success");
return response;
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -30,7 +31,9 @@ import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -58,21 +61,46 @@ public class PortalUserAuthService implements UserDetailsService {
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
String normalizedUsername = username != null ? username.toLowerCase() : null;
PortalUser portalUser = findByEmailAddr(normalizedUsername);
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
}
/**
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
}
/**
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
*/
public void reloadCurrentAuthentication() {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return;
}
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
UsernamePasswordAuthenticationToken newAuth =
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
newAuth.setDetails(refreshed);
SecurityContextHolder.getContext().setAuthentication(newAuth);
}
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
try {
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.service;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -83,7 +84,7 @@ public class PortalUserService {
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
return false;
}
return portalUserRepository.existsByMobileNumber(mobileNumber.trim());
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
}
/**
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
persistenceUnit = "gateway";
}
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
// 개발 환경용
if (env.matchesProfiles("local")) {
properties.put("hibernate.hbm2ddl.auto", "none");
properties.put("hibernate.hbm2ddl.auto", "validate");
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
@@ -95,9 +96,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationMobileAndStatus(
user.getMobileNumber(), InvitationStatus.PENDING);
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
@@ -12,7 +12,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -107,6 +110,11 @@ public class PortalConfigSecurity {
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
@@ -121,6 +129,23 @@ public class PortalConfigSecurity {
}
}
/**
* CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
* 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
* 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
* 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
*/
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
return (request, response, accessDeniedException) -> {
if (accessDeniedException instanceof CsrfException) {
response.sendRedirect(request.getContextPath() + "/login?expired=true");
return;
}
delegate.handle(request, response, accessDeniedException);
};
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
@@ -45,6 +45,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
// 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@@ -97,10 +103,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
.resourceChain(true);
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
@@ -118,6 +129,20 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
return resourceVersioningEnabled;
}
/**
* 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
* prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
*
* <p>OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
* ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)</p>
*/
private boolean isResourceCachingEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceCachingEnabled;
}
@Bean
public MappingJackson2JsonView jsonView() {
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: dev
+8 -2
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: prod
@@ -29,4 +29,10 @@ spring:
cache: false
portal:
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
app:
resource-versioning:
enabled: true
resource-caching:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
web:
resources:
cache:
+15 -6
View File
@@ -2,7 +2,9 @@ server:
servlet:
context-path: /
session:
timeout: 15m
timeout: 10m
cookie:
name: PORTAL_JSESSIONID
encoding:
charset: UTF-8
port: '39130'
@@ -24,8 +26,6 @@ spring:
default-page-size: '10'
jpa:
open-in-view: false
hibernate:
ddl-auto: none
web:
resources:
@@ -41,10 +41,11 @@ spring:
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta:
enabled: true
enabled: false
atomikos:
properties:
log-base-dir: /logs/atomikos
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
log-base-dir: /dev/null
log-base-name: adp_tx
thymeleaf:
@@ -61,7 +62,12 @@ spring:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app:
resource-versioning:
enabled: true
enabled: false
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
security:
basic:
@@ -252,6 +258,9 @@ page:
verification_email:
name: "이메일 인증"
path: "/signup/verification-email"
decision:
name: "법인회원 초대"
path: "/signup/decision_process"
agreements:
name: 약관
path: "#"
+16 -13
View File
@@ -5,10 +5,20 @@
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
<!-- 콘솔 로그 JVM 파라미터 제어 -->
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
<property name="__consoleLevel.false" value="OFF"/>
<property name="__consoleLevel.FALSE" value="OFF"/>
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
@@ -47,6 +57,9 @@
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
</filter>
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
</appender>
@@ -58,6 +71,7 @@
<root level="INFO">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</root>
<springProfile name="dev">
@@ -66,15 +80,4 @@
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<springProfile name="stage">
<root level="INFO">
<appender-ref ref="ROLLING"/>
</root>
</springProfile>
<springProfile name="stage,dev">
</springProfile>
</configuration>
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
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 380 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

@@ -1,4 +1,3 @@
<svg width="1920" height="318" viewBox="0 0 1920 318" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1920 318H0V64.4473C158.076 179.873 527.685 260.879 958.538 260.999H960.462C1391.87 260.878 1761.87 179.662 1919.61 64H1920V318Z" fill="#D3EFFB"/>
<path d="M1920 281H0V13.71C158.194 148.461 528.235 243 959.499 243C1391.68 243 1762.37 148.059 1920 12.8525V0H1920V281ZM0 0V13.71H-0.000976562V0H0Z" fill="#CFF2FF" fill-opacity="0.5"/>
<svg width="1920" height="314" viewBox="0 0 1920 314" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1920 314H0V0.447266C158.076 115.873 527.685 196.879 958.538 196.999H960.462C1391.87 196.878 1761.87 115.662 1919.61 0H1920V314Z" fill="#CFF2FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 133 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 239 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -79,9 +79,6 @@
li.innerHTML = ''
+ '<div class="djb-comment-meta">'
+ ' <div class="djb-comment-meta-left">'
+ ' <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 2px; opacity: 0.5;">'
+ ' <path d="M5 3v5a2 2 0 0 0 2 2h5 M9 7l3 3-3 3" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+ ' </svg>'
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</span>' : '')
+ ' </div>'
@@ -5,7 +5,6 @@
// Color Palette - Vibrant and Modern
// Primary colors
$primary-color: #0049B4;
$primary-blue: #0049b4; // Vibrant blue
$secondary-blue: #c3dfea; // Deep blue
$accent-cyan: #00D4FF; // Sky blue accent
@@ -8,12 +8,9 @@
// Float animation
@keyframes float {
0%,
100% {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-30px) rotate(10deg);
}
@@ -21,12 +18,9 @@
// Bounce animation
@keyframes bounce {
0%,
100% {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
@@ -38,7 +32,6 @@
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -51,7 +44,6 @@
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -64,7 +56,6 @@
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
@@ -77,7 +68,6 @@
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
@@ -89,7 +79,6 @@
from {
opacity: 0;
}
to {
opacity: 1;
}
@@ -101,7 +90,6 @@
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
@@ -113,7 +101,6 @@
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
@@ -124,11 +111,9 @@
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
@@ -139,7 +124,6 @@
0% {
transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
@@ -150,31 +134,24 @@
0% {
transform: rotate(0deg);
}
10% {
transform: rotate(14deg);
}
20% {
transform: rotate(-8deg);
}
30% {
transform: rotate(14deg);
}
40% {
transform: rotate(-4deg);
}
50% {
transform: rotate(10deg);
}
60% {
transform: rotate(0deg);
}
100% {
transform: rotate(0deg);
}
@@ -186,11 +163,9 @@
opacity: 0;
transform: scale(0.5);
}
80% {
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
@@ -203,7 +178,6 @@
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
@@ -216,7 +190,6 @@
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(4);
opacity: 0;
@@ -228,11 +201,9 @@
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
@@ -243,27 +214,6 @@
animation: float 20s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-15px);
}
}
.floating-slow {
animation: float 6s ease-in-out infinite;
}
.floating-fast {
animation: float 4s ease-in-out infinite;
}
.animate-bounce {
animation: bounce 2s infinite;
}
@@ -272,37 +222,6 @@
animation: spin 1s linear infinite;
}
@keyframes drawLine {
to {
stroke-dashoffset: 0;
}
}
.draw-line {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: drawLine 3s ease-out forwards;
}
.pulsing {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.1);
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
@@ -406,4 +325,4 @@
&:hover::before {
transform: translateX(200%) rotate(45deg);
}
}
}
@@ -9,30 +9,28 @@
// FAQ Accordion - Figma Design
.faq-accordion {
display: flex;
flex-direction: column;
gap: 6px;
background: #F6F9FB;
border-radius: $border-radius-lg;
overflow: hidden;
margin-bottom: $spacing-2xl;
@include respond-to('sm') {
background: #FFFFFF;
border-radius: 0;
}
.faq-item {
position: relative;
border-radius: 10px;
background: #fff;
border-bottom: 1px solid #DADADA;
transition: $transition-base;
&:last-child {
border-bottom: none;
}
// Active state - when accordion is open
&.active {
.faq-question {
background: #4685ef;
border-color: #e8dddd;
.faq-question-q,
.faq-question-text {
color: #fff !important;
}
.faq-icon {
color: #fff;
transform: rotate(180deg);
}
}
@@ -44,48 +42,33 @@
}
}
// Question row
// Question row - Figma: 18px bold, #212529
.faq-question {
display: flex;
align-items: center;
min-height: 47px;
height: auto;
padding: 8px 35px 8px 21px;
background: #fff;
border: 1px solid #dedede;
border-radius: 10px;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
font-size: 18px;
font-weight: $font-weight-bold;
color: #212529;
cursor: pointer;
transition: $transition-base;
gap: 95px;
min-height: 54px;
@media (max-width: $breakpoint-md) {
padding: 12px 16px;
min-height: 44px;
padding: $spacing-md $spacing-lg;
font-size: 16px;
gap: $spacing-lg;
}
.faq-question-q {
font-size: 20px;
font-weight: 700;
color: #555;
margin-right: 15px;
line-height: 1;
@media (max-width: $breakpoint-md) {
font-size: 16px;
margin-right: 10px;
}
&:hover {
background: rgba($primary-blue, 0.02);
}
.faq-question-text {
flex: 1;
font-size: 14px;
color: #000;
line-height: normal;
word-break: keep-all;
@media (max-width: $breakpoint-md) {
font-size: 13px;
line-height: 1.4;
}
line-height: $line-height-normal;
}
// Chevron icon
@@ -96,7 +79,7 @@
justify-content: center;
width: 22px;
height: 22px;
color: #000;
color: #212529;
transition: transform 0.3s ease;
svg {
@@ -106,41 +89,49 @@
}
}
// Answer section
// Answer section - white background
.faq-answer {
display: none;
padding: 19px 21px 25px 21px;
background: #F6F9FB;
padding: $spacing-lg 20px $spacing-xl;
border-top: none;
@include respond-to('sm') {
background: #FFFFFF;
}
@media (max-width: $breakpoint-md) {
padding: 16px;
padding: $spacing-md $spacing-lg $spacing-lg;
}
.faq-answer-content {
font-size: 14px;
color: #000;
line-height: 1.6;
font-size: 18px;
font-weight: $font-weight-regular;
color: #515151;
background-color: #ffffff;
padding: $spacing-lg;
line-height: $line-height-loose;
@media (max-width: $breakpoint-md) {
font-size: 13px;
font-size: 14px;
}
// Content formatting
p {
margin-bottom: 8px;
margin-bottom: $spacing-md;
&:last-child {
margin-bottom: 0;
}
}
ul,
ol {
margin: 8px 0;
padding-left: 20px;
ul, ol {
margin: $spacing-sm 0;
padding-left: $spacing-lg;
}
li {
margin-bottom: 4px;
margin-bottom: $spacing-xs;
}
a {
@@ -234,7 +225,6 @@
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -316,4 +306,4 @@
animation: slideDown 0.3s ease;
}
}
}
}
@@ -573,33 +573,3 @@
height: 36px;
}
}
// -----------------------------------------------------------------------------
// Action Call Button (e.g. Signup, CTA)
// -----------------------------------------------------------------------------
.btn-action-primary {
display: inline-flex;
align-items: center;
justify-content: center;
background: #2a69de;
color: #fff;
font-size: 20px;
font-weight: 700;
padding: 16px 48px;
border-radius: 10px;
text-decoration: none;
transition: background 0.2s, transform 0.2s;
cursor: pointer;
border: none;
&:hover {
background: darken(#2a69de, 10%);
transform: translateY(-2px);
color: #fff;
}
@include respond-to('sm') {
font-size: 15px;
}
}
@@ -41,7 +41,9 @@
// ── 댓글 목록 박스 (설계서 ① 영역) ─────────────────────────
.djb-comment-list-box {
padding: 24px 28px;
background-color: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
min-height: 120px;
margin-bottom: 28px;
@@ -62,16 +64,16 @@
// ── 댓글 한 건 (설계서 작성자 + 날짜 + Contents) ───────────
.djb-comment-item {
padding: 40px 0;
border-bottom: 1px solid #DFDFDF;
padding: 18px 0;
border-bottom: 1px dashed #E5E7EB;
&:first-child {
padding-top: 20px;
padding-top: 4px;
}
&:last-child {
border-bottom: none;
padding-bottom: 20px;
padding-bottom: 4px;
}
&--admin .djb-comment-writer {
@@ -92,21 +94,21 @@
}
.djb-comment-writer {
font-size: 14px;
font-weight: 500;
color: #000;
font-size: 18px;
font-weight: 700;
color: #111827;
letter-spacing: -0.2px;
}
.djb-comment-admin-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
background-color: #ecf1ff;
color: #2a69de;
padding: 2px 8px;
background-color: #DBEAFE;
color: #1D4ED8;
border-radius: 10px;
font-size: 9px;
font-weight: 700;
font-size: 11px;
font-weight: 600;
}
.djb-comment-meta-right {
@@ -121,32 +123,29 @@
}
.djb-comment-delete-btn {
background: #fbe7e9;
border: 1px solid #f1c2d6;
border-radius: 5px;
padding: 3px 6px;
font-size: 10px;
font-weight: 500;
color: #bb1026;
background: none;
border: 1px solid #E5E7EB;
border-radius: 4px;
padding: 3px 10px;
font-size: 12px;
color: #6B7280;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
height: 20px;
&:hover {
background-color: #f1c2d6; // Changed from darken(#fbe7e9, 5%) to fix warning
background-color: #FEE2E2;
border-color: #FCA5A5;
color: #B91C1C;
}
}
.djb-comment-body {
padding: 24px;
background-color: #f4f5f9;
border-radius: 10px;
padding: 12px 16px;
background-color: #F3F4F6;
border-radius: 6px;
font-size: 14px;
line-height: 20px;
color: #000;
line-height: 1.6;
color: #1F2937;
white-space: pre-wrap;
word-break: break-word;
}
@@ -168,31 +167,31 @@ textarea.djb-comment-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 14px 18px;
border: 1px solid #dfdfdf;
border-radius: 10px;
background-color: #ffffff;
padding: 16px 18px;
border: 1px solid #D1D5DB;
border-radius: 8px;
background-color: #F9FAFB;
font-size: 14px;
line-height: 1.6;
color: #000;
color: #1F2937;
resize: vertical;
min-height: 103px;
min-height: 120px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
&::placeholder {
color: #7f8a95;
color: #9CA3AF;
}
&:hover {
border-color: #2a69de;
border-color: #9CA3AF;
}
&:focus {
border-color: #2a69de;
border-color: #1D4ED8;
background-color: #FFFFFF;
box-shadow: 0 0 0 3px rgba(42, 105, 222, 0.1);
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15);
}
}
@@ -210,22 +209,28 @@ textarea.djb-comment-input {
}
button.djb-comment-submit {
display: flex;
align-items: center;
justify-content: center;
width: 149px;
height: 39px;
background-color: #ffffff;
color: #4685ef;
border: 1px solid #2a69de;
border-radius: 10px;
font-size: 14px;
display: inline-block;
min-width: 140px;
padding: 12px 28px;
background-color: #1D4ED8;
color: #FFFFFF;
border: 1px solid #1D4ED8;
border-radius: 8px;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.2px;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
-webkit-appearance: none;
line-height: 1.2;
text-align: center;
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.18);
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
&:hover {
background-color: #f8faff;
background-color: #1E40AF;
border-color: #1E40AF;
box-shadow: 0 3px 10px rgba(29, 78, 216, 0.28);
}
&:active {
@@ -233,9 +238,9 @@ button.djb-comment-submit {
}
&:disabled {
background-color: #f4f5f9;
border-color: #dfdfdf;
color: #7f8a95;
background-color: #94A3B8;
border-color: #94A3B8;
box-shadow: none;
cursor: not-allowed;
}
}
@@ -248,4 +253,4 @@ button.djb-comment-submit {
text-align: center;
color: #6B7280;
font-size: 13px;
}
}
@@ -13,7 +13,7 @@
label {
display: block;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
font-weight: $font-weight-medium;
color: $text-dark;
margin-bottom: $spacing-sm;
@@ -453,7 +453,6 @@ select.form-control {
.form-field-wrapper {
flex: 1;
@include respond-to('sm') {
width: 100%;
}
@@ -758,7 +757,6 @@ select.form-control {
}
.btn {
// Figma 모바일 (1291-3170): 144px × 40px, border-radius 8px, 14px bold
@include respond-to('sm') {
width: 144px !important;
@@ -962,71 +960,3 @@ select.form-control {
display: flex;
align-items: center;
}
// -----------------------------------------------------------------------------
// DJB Common Form Elements (Global Reusable Styles)
// -----------------------------------------------------------------------------
.djb-label {
display: block;
font-size: 14px;
font-weight: 700;
color: #000;
margin-bottom: 12px;
.required {
color: #e53e3e;
margin-left: 4px;
}
}
.djb-input {
width: 100%;
padding: 14px 18px;
border: 1px solid #DFDFDF;
border-radius: 8px;
font-size: 14px;
color: #000;
transition: border-color 0.2s;
background-color: #fcfcfc;
&::placeholder {
color: #94A3B8;
}
&:focus {
outline: none;
border-color: #2a69de;
}
}
.djb-textarea {
width: 100%;
padding: 14px 18px;
border: 1px solid #DFDFDF;
border-radius: 8px;
font-size: 14px;
color: #000;
resize: none;
transition: border-color 0.2s;
min-height: 250px;
&::placeholder {
color: #94A3B8;
}
&:focus {
outline: none;
border-color: #2a69de;
}
}
.djb-char-counter-wrapper {
text-align: right;
margin-top: 8px;
.char-counter {
font-size: 13px;
color: #7f8a95;
}
}
@@ -18,76 +18,50 @@
&.authenticated {
gap: 0;
}
.login-btn{
font-size: 15px;
font-weight: 500;
color: var(--primary-color);
padding: 10px 20px;
border-radius: 8px;
transition: var(--transition-smooth);
&:hover{
background-color: var(--accent-light);
}
}
.btn-login:hover {
background-color: var(--accent-light);
}
}
// ===========================
// Login Button
// ===========================
.login-btn {
font-size: 15px;
font-weight: 500;
color: var(--primary-color);
padding: 10px 20px;
border-radius: 8px;
transition: var(--transition-smooth);
display: inline-flex;
align-items: center;
padding: 10px 20px;
color: $text-dark;
text-decoration: none;
border: 2px solid $primary-blue;
border-radius: $border-radius-full;
font-weight: $font-weight-semibold;
font-size: $font-size-sm;
transition: $transition-base;
&:hover {
background-color: var(--accent-light);
}
}
// Figma GNB 로그인 버튼: pill (#F2F5F7 배경 / #0046FF 보더·텍스트 / radius 20px)
&-box {
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px; // 아이콘-텍스트 간격 (Figma)
padding: 6px 16px 6px 12px;
color: #0046FF; // Figma 로그인 텍스트 블루
text-decoration: none;
border: 1px solid #0046FF;
border-radius: 20px;
font-family: $font-family-primary;
font-weight: $font-weight-regular; // 400 (Figma)
font-size: 19px;
background: #F2F5F7; // Figma 그레이2 pill 배경
transition: $transition-base;
.user-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.btn-signup {
font-size: 15px;
font-weight: 700;
color: #FFFFFF;
background-color: var(--primary-color);
padding: 10px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
transition: var(--transition-smooth);
&:hover {
background-color: var(--secondary-color);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 73, 180, 0.25);
color:white;
}
}
// Box style for Figma design (node-id: 721-1489)
.login-btn-box {
font-size: 15px;
font-weight: 500;
color: var(--primary-color);
padding: 10px 20px;
border-radius: 8px;
transition: var(--transition-smooth);
.user-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
&:hover {
background: #E9EDF2;
}
}
}
@@ -20,7 +20,6 @@ $pagination-text-disabled: #adb5bd;
align-items: center;
justify-content: center;
gap: $pagination-gap;
margin-top:50px;
// Page navigation buttons (first, prev, next, last)
.page-first,
@@ -275,138 +275,10 @@
}
}
// -----------------------------------------------------------------------------
// Board Table - Standard BBS design (Figma: 165-125)
// Used in: Notice, Q&A, FAQ etc.
// -----------------------------------------------------------------------------
.board-table {
width: 100%;
border-top: 1px solid #c4c7c8;
&-wrapper {
overflow-x: auto;
margin-bottom: $spacing-xl;
}
// Table Header - Gray background (#f9f9f9)
.board-table-header {
display: flex;
align-items: center;
background: #f9f9f9;
height: 50px;
padding: 0 45px;
border-bottom: 1px solid #dedede;
@media (max-width: $breakpoint-sm) {
display: none;
}
.header-cell {
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 500;
color: #000;
text-align: center;
}
}
// Table Body
.board-table-body {
display: flex;
flex-direction: column;
}
// Table Row
.board-table-row {
display: flex;
align-items: center;
height: 50px;
padding: 0 45px;
border-bottom: 1px solid #dedede;
transition: background-color 0.2s;
background: #ffffff;
&:hover {
background-color: #f8fbfe;
}
@media (max-width: $breakpoint-sm) {
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: $breakpoint-sm) {
justify-content: flex-start;
width: 100% !important;
&::before {
content: attr(data-label);
font-weight: 500;
color: #64748b;
margin-right: 10px;
min-width: 60px;
}
}
&--title {
justify-content: flex-start;
white-space: normal;
@media (max-width: $breakpoint-sm) {
&::before { display: none; }
}
.notice-title-link {
display: flex;
align-items: center;
gap: 10px;
color: inherit;
text-decoration: none;
width: 100%;
&:hover {
text-decoration: underline;
}
// Mobile number prefix [1] [2] etc
.notice-number {
display: none;
font-weight: 500;
color: #64748b;
flex-shrink: 0;
@media (max-width: $breakpoint-sm) {
display: inline;
}
}
}
}
&--number {
@media (max-width: $breakpoint-sm) {
display: none;
}
}
}
}
}
// -----------------------------------------------------------------------------
// List Table - Modern design with colored header (Figma: 984-2173)
// Used in: User management, API key list, etc.
// Refined: Clean Minimal sizing with original KJB colors
// -----------------------------------------------------------------------------
.list-table {
width: 100%;
@@ -587,17 +459,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 $spacing-md;
height: 28px;
padding: 0 $spacing-sm;
height: 30px;
white-space: nowrap;
border-radius: 5px;
font-size: 12px;
border-radius: $border-radius-sm;
font-size: 13px;
font-weight: $font-weight-regular;
font-family: 'Spoqa Han Sans Neo', sans-serif;
color: #000000;
border: 1px solid transparent;
border: none;
cursor: pointer;
transition: $transition-base;
white-space: nowrap;
&:hover {
transform: translateY(-1px);
@@ -610,42 +482,35 @@
// Button variants
&--default {
background-color: #f5f5f4;
border-color: #bdc7cf;
color: #64748b;
background-color: #DADADA;
&:hover {
background-color: darken(#f5f5f4, 5%);
background-color: darken(#DADADA, 5%);
}
}
&--primary {
background-color: #ecf0fa;
border-color: #4685ef;
color: #2a69de;
background-color: #A4D6EA;
&:hover {
background-color: darken(#ecf0fa, 5%);
background-color: darken(#A4D6EA, 5%);
}
}
&--secondary {
background-color: #f5f5f4;
border-color: #bdc7cf;
color: #64748b;
background-color: #CDD4F0;
&:hover {
background-color: darken(#f5f5f4, 5%);
background-color: darken(#CDD4F0, 5%);
}
}
&--danger {
background-color: #fbe7e9;
border-color: #f1c2d6;
color: #bb1026;
background-color: #F8D7DA;
color: #B02A37;
&:hover {
background-color: darken(#fbe7e9, 5%);
background-color: darken(#F8D7DA, 5%);
}
}
}
@@ -10,7 +10,7 @@
.container {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 20px;
padding: 0 26px;
@include respond-to('sm') {
padding: 0 $spacing-md;
@@ -109,7 +109,8 @@
font-size: $font-size-2xl;
}
.title-highlight {}
.title-highlight {
}
}
.section-subtitle {
@@ -148,4 +149,4 @@
.inner-content {
position: relative;
z-index: 1;
}
}
@@ -6,7 +6,6 @@
// Footer Styles - Figma Design Implementation
// -----------------------------------------------------------------------------
/* 원래 있던 Footer 스타일 (주석 처리)
.global-footer {
background: #FFFFFF;
border-top: 1px solid #E5E7EB;
@@ -41,7 +40,7 @@
gap: 16px;
.footer-logo {
width: 114px;
width: 144px; // Figma: 제주은행 로고 144x34
height: auto;
object-fit: contain;
}
@@ -54,7 +53,7 @@
.footer-link {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
font-weight: 700; // Figma: 링크 Bold
color: #5F666C;
text-decoration: none;
transition: color 0.3s ease;
@@ -66,7 +65,7 @@
}
.footer-separator {
color: #D1D5DB;
color: #5F666C; // Figma 구분자
font-size: 16px;
user-select: none;
}
@@ -76,7 +75,7 @@
font-family: $font-family-primary;
font-size: 14px;
font-weight: 400;
color: #9CA3AF;
color: #5F666C; // Figma: 저작권 그레이
margin: 0;
line-height: 1.5;
}
@@ -104,8 +103,8 @@
font-weight: 400;
color: #5F666C;
background: #FFFFFF;
border: 1px solid #D1D5DB;
border-radius: 6px;
border: 1px solid #5F666C; // Figma: 관련사이트 박스 보더
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
appearance: none;
@@ -132,151 +131,14 @@
.footer-contact {
font-family: $font-family-primary;
font-size: 18px;
font-size: 30px; // Figma: 고객센터 30px Bold
font-weight: 700;
color: #1F2937;
color: #5F666C; // Figma 그레이
margin: 0;
line-height: 1.2;
@include respond-to('md') {
font-size: 16px;
}
}
}
*/
/* 피그마 시안 적용 다크 모드 Footer 스타일 */
.global-footer {
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 40px;
@include respond-to('md') {
flex-direction: column;
align-items: flex-start;
gap: 32px;
padding: 32px 0;
}
}
// Left Section
.footer-left {
display: flex;
flex-direction: column;
gap: 16px;
.footer-logo {
width: 114px;
height: auto;
object-fit: contain;
}
.footer-links {
display: flex;
align-items: center;
gap: 8px;
.footer-link {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
font-weight: 400;
color: #9CA3AF;
text-decoration: none;
transition: color 0.3s ease;
&:hover {
color: #FFFFFF;
text-decoration: underline;
}
}
.footer-separator {
color: #9CA3AF;
font-size: 16px;
user-select: none;
}
}
.footer-copyright {
font-family: $font-family-primary;
font-size: 14px;
font-weight: 400;
color: #9CA3AF;
margin: 0;
line-height: 1.5;
}
}
// Right Section
.footer-right {
display: flex;
flex-direction: column;
gap: 16px;
align-items: flex-end;
@include respond-to('md') {
align-items: flex-start;
width: 100%;
}
.footer-related-sites {
.related-sites-select {
width: 240px;
height: 44px;
padding: 0 16px;
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #9CA3AF;
background: rgb(30, 41, 59);
border: 1px solid rgb(71, 85, 105);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%239CA3AF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
padding-right: 40px;
&:hover {
border-color: #FFFFFF;
}
&:focus {
outline: none;
border-color: #FFFFFF;
}
@include respond-to('md') {
width: 100%;
}
}
}
.footer-contact {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 18px;
font-weight: 700;
color: #9CA3AF;
margin: 0;
line-height: 1.2;
@include respond-to('md') {
font-size: 16px;
font-size: 22px;
}
}
}
@@ -9,8 +9,6 @@
// CSS Variables for modern header styling
:root {
// Enhanced color palette from design
--primary-color: #0049b4;
--secondary-color: #008ae2;
--primary-blue: #4B9BFF;
--secondary-blue: #2E7FF7;
--accent-cyan: #00D4FF;
@@ -18,8 +16,6 @@
--accent-orange: #FF6B6B;
--accent-green: #6BCF7F;
--accent-purple: #A78BFA;
--accent-color:#efdcb2;
--accent-light: #E9F9FF;
// Base colors
--text-dark: #1A1A2E;
@@ -35,13 +31,6 @@
--shadow-md: 0 4px 12px rgba(75, 155, 255, 0.1);
--shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
// border
--border-color : #E2E8F0;
//transition
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
// ===========================
@@ -102,28 +91,16 @@
@media (max-width: 768px) {
height: 40px;
.survey-label {
display: none;
}
.survey-btn {
padding: 4px 12px;
font-size: 12px;
}
.survey-label { display: none; }
.survey-btn { padding: 4px 12px; font-size: 12px; }
}
}
// Body offset when survey is active
body.design-survey-active {
.global-header {
margin-top: 48px;
}
.global-header { margin-top: 48px; }
@media (max-width: 768px) {
.global-header {
margin-top: 40px;
}
.global-header { margin-top: 40px; }
}
}
@@ -147,63 +124,36 @@ body.design-survey-active {
// Modern Header Structure
// ===========================
.global-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 80px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(15px);
border-bottom: 1px solid rgba(0, 73, 180, 0.08);
z-index: 1000;
display: flex;
align-items: center;
transition: var(--transition-smooth);
position: relative;
background-color: #ffffff;
z-index: $z-index-header;
height: 110px; // Figma GNB 높이
transition: all 0.3s ease;
border-bottom: 1px solid #dddddd;
// Index page header with light blue background
// Figma GNB: 흰색 바 (히어로 시안 그라데이션과 분리)
&.index-header {
background-color: white;
background-color: #FFFFFF;
border-bottom: none;
}
.container {
height: 100%;
padding: 0;
width: 100%;
.header-content {
height: 100%;
}
}
@media (max-width: 768px) {
height: clamp(44px, 11.73vw, 60px);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: none;
.container {
padding: 0;
max-width: 100%;
background-color: #FEFEFE;
.header-content {
padding: 0 clamp(16px, 4.27vw, 24px) 0 clamp(20px, 5.33vw, 28px);
height: clamp(44px, 11.73vw, 60px);
margin-top: 0;
}
}
}
}
.header-content {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
height: 65px;
max-width: 1920px;
margin: 0 auto;
padding: 0 20px;
}
// ===========================
@@ -217,6 +167,7 @@ body.design-survey-active {
.logo {
height: 32px;
width: 114px;
}
}
@@ -225,27 +176,13 @@ body.design-survey-active {
display: flex;
align-items: center;
gap: 12px;
font-size:24px;
.logo-text-bold {
font-weight: 700;
color: var(--primary-color);
letter-spacing: -0.5px;
}
.logo-text-thin {
font-weight: 300;
color: var(--secondary-color);
margin-left: 4px;
}
}
// Logo with modern styling
.logo {
display: block;
height: 32px;
width: 114px;
vertical-align: middle;
z-index: 10;
@@ -265,10 +202,10 @@ body.design-survey-active {
}
.logo-text {
font-size: 22px;
font-weight: 600;
font-size: 23px; // Figma: API Portal 23px Medium
font-weight: 500;
line-height: 32px;
color: var(--text-dark);
color: #000000;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -445,11 +382,9 @@ body.design-survey-active {
&:nth-child(1) {
transform: rotate(45deg) translateY(5px);
}
&:nth-child(2) {
opacity: 0;
}
&:nth-child(3) {
transform: rotate(-45deg) translateY(-5px);
}
@@ -867,27 +802,14 @@ body.design-survey-active {
transition: all $transition-fast;
}
&::before {
top: -8px;
}
&::after {
top: 8px;
}
&::before { top: -8px; }
&::after { top: 8px; }
}
&.on span {
background: transparent;
&::before {
top: 0;
transform: rotate(45deg);
}
&::after {
top: 0;
transform: rotate(-45deg);
}
&::before { top: 0; transform: rotate(45deg); }
&::after { top: 0; transform: rotate(-45deg); }
}
}
@@ -927,7 +849,7 @@ body.design-survey-active {
.nav-item {
border-bottom: 1px solid $border-gray;
>a {
> a {
display: flex;
align-items: center;
padding: $spacing-lg;
@@ -938,7 +860,7 @@ body.design-survey-active {
position: relative;
}
&.has-submenu>a::after {
&.has-submenu > a::after {
content: '';
position: absolute;
right: $spacing-lg;
@@ -951,7 +873,7 @@ body.design-survey-active {
transition: transform $transition-fast;
}
&.expanded>a::after {
&.expanded > a::after {
transform: translateY(-25%) rotate(-135deg);
}
@@ -964,33 +886,31 @@ body.design-survey-active {
a {
display: block;
padding: $spacing-md $spacing-lg $spacing-md (
$spacing-lg * 2
);
color: $text-gray;
font-size: $font-size-sm;
text-decoration: none;
padding: $spacing-md $spacing-lg $spacing-md ($spacing-lg * 2);
color: $text-gray;
font-size: $font-size-sm;
text-decoration: none;
&:hover {
background: $white;
color: $primary-blue;
&:hover {
background: $white;
color: $primary-blue;
}
}
}
}
&.expanded .sub-menu {
display: block;
&.expanded .sub-menu {
display: block;
}
}
}
}
.info_wrap {
padding: $spacing-xl $spacing-lg;
background: $white;
border-top: 1px solid $border-gray;
margin-top: auto;
// styles for tit1, tit2...
}
.info_wrap {
padding: $spacing-xl $spacing-lg;
background: $white;
border-top: 1px solid $border-gray;
margin-top: auto;
// styles for tit1, tit2...
}
}
// ===========================
@@ -999,16 +919,16 @@ body.design-survey-active {
.nav-menu {
display: flex;
list-style: none;
gap: 36px;
gap: 8px;
margin: 0;
padding: 0;
// Navigation item with submenu
>li {
> li {
position: relative;
&.submenu-open {
>.nav-link {
> .nav-link {
background: var(--light-bg);
color: var(--primary-blue);
}
@@ -1061,12 +981,16 @@ body.design-survey-active {
}
.nav-link {
font-size: 16px;
font-weight: 500;
color: var(--text-gray);
padding: 8px 0;
position: relative;
transition: var(--transition-smooth);
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: #212529; // Figma 강조텍스트
font-weight: 700; // Figma: 네비 Bold
font-size: 20px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
color: var(--primary-blue);
@@ -1099,17 +1023,10 @@ body.design-survey-active {
// Desktop Navigation Styles
// ===========================
@include respond-to('desktop') {
.m-only {
display: none !important;
}
.m-only { display: none !important; }
.pc-only { display: block !important; }
.pc-only {
display: block !important;
}
.btn-mobilemenu {
display: none;
}
.btn-mobilemenu { display: none; }
.main-nav.m-nav {
position: static;
@@ -1122,13 +1039,8 @@ body.design-survey-active {
height: 100%;
flex: 1;
.nav-header {
display: none;
}
.info_wrap {
display: none;
}
.nav-header { display: none; }
.info_wrap { display: none; }
.nav-list {
display: flex;
@@ -1140,7 +1052,7 @@ body.design-survey-active {
position: relative;
height: 100%;
>a {
> a {
height: 100%;
padding: 0 24px;
color: var(--text-dark);
@@ -1150,7 +1062,6 @@ body.design-survey-active {
&:hover {
color: var(--primary-blue);
&::after {
content: '';
position: absolute;
@@ -1164,9 +1075,7 @@ body.design-survey-active {
}
}
&.has-submenu>a::after {
display: none;
}
&.has-submenu > a::after { display: none; }
}
}
}
@@ -1336,4 +1245,4 @@ body.design-survey-active {
.header-user-info {
display: none; // Hide on mobile, use drawer instead
}
}
}
@@ -8,128 +8,38 @@
// -----------------------------------------------------------------------------
.api-market-container {
// display: flex;
// max-width: 1240px;
// margin: 0 auto;
// min-height: 100vh;
// background-color: transparent;
// position: relative;
// margin-bottom: 20px;
max-width: 1240px;
margin: 0 auto;
padding: 60px 40px;
display: flex;
gap: 60px;
align-items: flex-start;
}
// Hero Section
.api-market-hero {
background: linear-gradient(to right, #eff9fe, #fcfeff);
border: 1px solid #e3e8f0;
padding: 60px 0;
display: flex;
justify-content: center;
width: calc(100vw - 80px);
min-height: 100vh;
background-color: $white;
position: relative;
left: 50%;
transform: translateX(-50%);
margin-top: 40px;
margin-bottom: 60px;
&__inner {
max-width: 1240px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 138px;
padding: 0 37px;
}
&__illust {
.cloud-image-placeholder {
width: 331px;
height: 229px;
background: url('../img/hero_image_1.png') no-repeat center center;
background-size: contain;
}
}
&__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
background: #fff;
border: 1px solid #d1e6f0;
border-radius: 38px;
font-size: 13px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 700;
color: #1b4ab7;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #1b4ab7;
}
}
&__title {
font-size: 36px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 700;
color: #000;
margin: 0;
}
&__desc {
font-size: 16px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
color: #64748b;
line-height: 1.6;
margin: 0;
}
margin-bottom: 20px;
}
// Sidebar Navigation
.api-market-sidebar {
width: 213px;
background-color: #ffffff;
border: 1px solid #e3e8f0;
width: 283px;
background-color: #F8FBFD;
border-radius: 20px;
padding: 28px 30px;
padding: $spacing-2xl $spacing-lg;
position: sticky;
top: 40px;
height: fit-content;
top: $spacing-lg;
height: calc(100vh - 32px);
flex-shrink: 0;
margin-right: 40px;
margin: $spacing-md;
@media (max-width: $breakpoint-md) {
width: 200px;
margin-right: 20px;
width: 240px;
}
@media (max-width: $breakpoint-sm) {
position: fixed;
left: 0;
top: 60px;
top: 60px; // Below mobile header
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: $shadow-lg;
z-index: $z-index-modal;
height: calc(100vh - 60px);
height: calc(100vh - 60px); // Full height minus header
margin: 0;
border-radius: 0;
@@ -139,37 +49,67 @@
}
}
.api-sidebar-nav {
.api-sidebar-header {
display: flex;
flex-direction: column;
gap: 26px;
justify-content: center;
margin-bottom: $spacing-xl;
padding-bottom: $spacing-lg;
img {
max-width: 100%;
height: auto;
}
}
.api-sidebar-nav {
.menu-section {
margin-bottom: 0;
border-bottom: none;
border-bottom: 1px solid #DDDDDD;
&:last-child {
border-bottom: none;
}
}
.menu-title {
padding: 0;
font-size: 16px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 500;
color: #0d0e11;
padding: 16px 10px;
font-size: $font-size-lg;
font-weight: $font-weight-regular;
color: $text-dark;
cursor: pointer;
transition: color 0.2s;
transition: $transition-base;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
&:hover {
color: #2a69de;
background-color: transparent;
background-color: rgba($primary-blue, 0.05);
}
&.active {
font-weight: 700;
color: #2a69de;
font-weight: $font-weight-bold;
color: $primary-blue;
}
.api-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 $spacing-sm;
background-color: rgba($primary-blue, 0.1);
color: $primary-blue;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
border-radius: $border-radius-full;
transition: $transition-base;
}
&.active .api-count {
background-color: $primary-blue;
color: $white;
}
}
@@ -280,8 +220,9 @@
// Main Content Area
.api-market-content {
flex: 1;
// padding-left: $spacing-sm;
// padding-right: $spacing-sm;
padding-top: $spacing-5xl;
padding-left: $spacing-sm;
padding-right: $spacing-sm;
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -370,7 +311,7 @@
// Search Box
.api-market-search {
position: relative;
width: 228px;
width: 300px;
@media (max-width: $breakpoint-sm) {
width: 100%;
@@ -384,41 +325,53 @@
input {
flex: 1;
height: 44px;
padding: 0 40px 0 16px;
border: 1px solid #dadada;
border-radius: 12px;
font-size: 14px;
padding: 12px 48px 12px $spacing-md;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
font-size: $font-size-sm;
outline: none;
transition: $transition-base;
&:focus {
border-color: #0049b4;
box-shadow: 0 0 0 3px rgba(#0049b4, 0.1);
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.1);
}
&::placeholder {
color: #7f8a95;
color: $text-light;
}
}
.search-submit-btn {
position: absolute;
right: 8px;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
width: 36px;
height: 36px;
background: transparent;
border: none;
border-radius: $border-radius-md;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: $transition-base;
color: $text-gray;
svg {
width: 100%;
height: 100%;
&:hover {
background: $light-bg;
color: $primary-blue;
transform: translateY(-50%) scale(1.05);
}
&:active {
transform: translateY(-50%) scale(0.95);
}
.search-icon {
font-size: 18px;
display: block;
}
}
}
@@ -428,131 +381,101 @@
// Card Grid
.api-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
justify-content: flex-start;
grid-template-columns: repeat(3, 320px); // 3 cards per row on desktop (고정폭)
gap: $spacing-lg;
justify-content: center;
// Tablet view
@media (max-width: 1300px) {
grid-template-columns: repeat(2, 312px);
// Tablet view - 2 cards per row (고정폭)
@media (max-width: $breakpoint-md) {
grid-template-columns: repeat(2, 320px);
}
// Mobile view
@media (max-width: $breakpoint-md) {
// Mobile view - 1 card per row (화면에 맞춤)
@media (max-width: $breakpoint-sm) {
grid-template-columns: 1fr;
justify-content: center;
}
}
// API Card
.api-card {
background: #FFFFFF;
border-radius: 20px;
border: 1px solid #DDDDDD;
box-shadow: 0px 4px 2px rgba(194, 194, 194, 0.25);
background: $white;
border-radius: $border-radius-lg;
padding: $spacing-xl;
box-shadow: $shadow-sm;
cursor: pointer;
display: flex;
flex-direction: column;
gap: $spacing-md;
min-height: 180px;
position: relative;
width: 100%;
height: 312px;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
border: 1px solid $border-gray;
&:hover,
&:focus,
&:active {
transform: translateY(-5px);
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
background: linear-gradient(to bottom, #50caff, #2088ff);
border: none;
transform: scale(0.98);
}
}
.api-card-badge {
background-color: #d8f0ff;
}
// Group Info Section (Icon + Name)
.api-card-group {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.api-card-title,
.api-card-desc {
color: #ffffff;
}
.api-card-group-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: $gray-bg;
border-radius: $border-radius-md;
flex-shrink: 0;
img {
width: 20px;
height: 20px;
object-fit: contain;
}
&-badge {
position: absolute;
top: 39px;
left: 32px;
background-color: #f2f2f3;
border-radius: 10px;
padding: 2px 20px;
display: inline-flex;
align-items: center;
justify-content: center;
span {
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 500;
color: #1b4ab7;
white-space: nowrap;
}
i {
font-size: 16px;
color: $primary-blue;
}
}
&-title {
position: absolute;
top: 86px;
left: 36px;
right: 40px;
font-size: 18px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 700;
color: #000000;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
text-align: left;
white-space: normal;
}
.api-card-group-name {
font-size: $font-size-sm;
color: $text-gray;
font-weight: $font-weight-medium;
}
&-desc {
position: absolute;
top: 135px;
left: 36px;
right: 40px;
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 400;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
text-align: left;
white-space: normal;
}
// API Name (Title)
.api-card-name {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-dark;
line-height: $line-height-tight;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: left;
}
&-image {
position: absolute;
bottom: 28px;
right: 20px;
width: 93px;
height: 88px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
// API Description
.api-card-description {
font-size: $font-size-sm;
color: $text-gray;
line-height: $line-height-normal;
margin: 0;
flex-grow: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: left;
}
// Empty State
@@ -909,7 +832,6 @@
// Mobile Responsive Styles (sm: 768px)
// -----------------------------------------------------------------------------
@media (max-width: $breakpoint-sm) {
// API Detail Tabs - Mobile
.api-detail-tabs {
gap: 0;
@@ -1006,8 +928,7 @@
min-width: 500px; // 테이블 최소 너비 보장
margin: $spacing-sm 0;
th,
td {
th, td {
padding: $spacing-xs $spacing-sm;
font-size: $font-size-xs;
white-space: nowrap;
@@ -1082,4 +1003,4 @@
max-width: 100vw;
overflow-x: hidden;
}
}
}
@@ -74,6 +74,7 @@
font-weight: $font-weight-bold;
color: $text-dark;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
}
// Detail Grid - extends common
@@ -616,7 +617,6 @@
// ====================================
@include respond-to('sm') {
// Container mobile padding
.apikey-detail-container {
padding: 0 !important;
@@ -876,441 +876,26 @@
// 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정)
display: none;
}
// 모바일: 앱 수정 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
&.app-modify-btn {
display: none;
}
}
}
}
// =============================================================================
// NEW FIGMA DETAIL WRAP STYLES (.detail-wrap)
// =============================================================================
.detail-wrap {
width: 100%;
font-family: 'Spoqa Han Sans Neo', 'Noto Sans KR', sans-serif;
// PC 환경 안내 문구 - 모바일에서만 노출 (앱 수정)
.app-modify-pc-only-message {
display: none;
margin: 0;
padding: 0 20px 24px;
color: $text-gray;
font-size: 14px;
text-align: center;
line-height: 1.5;
// Title
.dt-title {
font-size: 30px;
font-weight: 700;
color: #000000;
margin: 0 0 28px 0;
text-align: left;
font-family: 'Spoqa Han Sans Neo', 'Noto Sans KR', sans-serif;
@media (max-width: 768px) {
font-size: 24px;
margin-bottom: 20px;
}
@media (max-width: $breakpoint-sm) {
display: block;
}
// App Card Header (Figma 240:642)
.dt-app-card {
display: flex;
align-items: center;
gap: 16px;
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 20px;
box-shadow: 0px 4px 13.45px rgba(192, 192, 192, 0.25);
padding: 20px 24px;
margin-bottom: 24px;
box-sizing: border-box;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 16px;
}
}
.dt-app-icon-box {
width: 68px;
height: 67px;
background: #f9f9f9;
border: 1px solid #dee3e7;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
img,
svg {
width: 46px;
height: 46px;
object-fit: cover;
border-radius: 4px;
}
}
.dt-app-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.dt-app-header-row {
display: flex;
align-items: center;
gap: 10px;
}
.dt-app-name {
font-size: 16px;
font-weight: 500;
color: #000;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dt-status-badge {
height: 20px;
border-radius: 5px;
padding: 0 8px;
font-size: 10px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&.status-approved,
&.status-completed {
background: #1b4ab7;
color: #ffffff;
}
&.status-pending {
background: #fdf4d4;
color: #ef9546;
}
&.status-rejected {
background: #fef2f2;
color: #ef4444;
border: 1px solid #fee2e2;
}
}
.dt-app-desc {
font-size: 14px;
color: #64748b;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Main details card wrapper
.dt-info-container {
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 30px;
box-shadow: 0px 4px 26.9px 2px rgba(192, 192, 192, 0.25);
padding: 40px;
margin-bottom: 35px;
display: flex;
flex-direction: column;
gap: 28px;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 24px 20px;
border-radius: 20px;
gap: 20px;
}
}
// Row of details
.dt-row {
display: flex;
gap: 28px;
width: 100%;
@media (max-width: 768px) {
flex-direction: column;
gap: 20px;
}
}
.dt-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.dt-label {
font-size: 16px;
font-weight: 700;
color: #000000;
margin: 0;
}
.dt-input-group {
display: flex;
gap: 7px;
width: 100%;
}
.dt-input-box {
flex: 1;
height: 47px;
background: #ededed;
border: 1px solid #dfdfdf;
border-radius: 10px;
display: flex;
align-items: center;
padding: 0 20px;
font-size: 16px;
font-weight: 300;
color: #0d0e11;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.text-secret {
letter-spacing: 2px;
font-weight: 700;
}
@media (max-width: 768px) {
padding: 0 14px;
font-size: 14px;
}
}
// Action Buttons inside input-group (Copy, view, etc.)
.dt-btn-copy {
height: 47px;
background: #d5efff;
border: none;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 20px;
font-size: 16px;
font-weight: 500;
color: #2a69de;
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
&:hover {
background: darken(#d5efff, 5%);
}
svg {
color: #2a69de;
flex-shrink: 0;
}
@media (max-width: 768px) {
padding: 0 12px;
font-size: 14px;
}
}
.dt-btn-lookup {
width: 100%;
height: 47px;
background: #2a69de;
border: none;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
color: #ffffff;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #1b4ab7;
}
svg {
color: #ffffff;
flex-shrink: 0;
}
}
// API List scroll box
.dt-api-list-box {
width: 100%;
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 10px;
padding: 0 20px;
box-sizing: border-box;
max-height: 240px;
overflow-y: auto;
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
}
.dt-api-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #efefef;
&:last-child {
border-bottom: none;
}
}
.dt-api-name {
font-size: 16px;
font-weight: 500;
color: #0d0e11;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 20px;
flex: 1;
@media (max-width: 768px) {
font-size: 14px;
}
}
.dt-api-status-badge {
height: 18px;
background: #ecf0fa;
border: 1px solid #4685ef;
border-radius: 17px;
padding: 0 10px;
font-size: 10px;
font-weight: 500;
color: #2a69de;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&.status-pending {
background: #eceff4;
border: 1px solid #dee3e7;
color: #515151;
}
}
.dt-empty-message {
padding: 30px;
text-align: center;
color: #64748b;
font-size: 14px;
margin: 0;
}
// Footer Buttons layout
.dt-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 10px;
@media (max-width: 768px) {
flex-direction: column;
width: 100%;
.dt-btn-gray,
.dt-btn-red,
.dt-btn-blue {
width: 100%;
}
}
}
.dt-btn-gray {
width: 156px;
height: 45px;
background: #bdc7cf;
border: none;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #ffffff;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease;
&:hover {
background: darken(#bdc7cf, 8%);
}
}
.dt-btn-red {
width: 156px;
height: 45px;
background: #ff615c;
border: none;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #ffffff;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: darken(#ff615c, 8%);
}
}
.dt-btn-blue {
width: 156px;
height: 45px;
background: #2a69de;
border: none;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #ffffff;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease;
&:hover {
background: #1b4ab7;
}
}
}
}
@@ -194,6 +194,22 @@
}
}
// Expected Completion Date (pending requests only)
.app-list-expect-date {
margin-left: auto;
font-family: $font-family-primary;
font-size: 14px;
font-weight: $font-weight-regular;
color: #6e7780;
white-space: nowrap;
flex-shrink: 0;
// Figma 모바일: 12px
@include respond-to('sm') {
font-size: 12px;
}
}
// App Description
.app-list-description {
font-family: $font-family-primary;
@@ -246,214 +262,23 @@
font-size: 14px;
border-radius: 8px;
}
}
/* App Card Figma Styles */
.app-management-layout {
display: flex;
gap: 36px;
max-width: 1200px;
margin: 40px auto 100px;
font-family: 'Spoqa Han Sans Neo', 'Noto Sans CJK KR', sans-serif;
}
.app-management-content {
flex: 1;
min-width: 0;
}
.app-management-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 700;
color: #000;
margin: 0;
// 모바일: 앱 생성 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
@include respond-to('sm') {
display: none;
}
}
.btn-app-create-figma {
background-color: #2a69de;
color: #ffffff;
border-radius: 7px;
padding: 0 20px;
width: 122px;
height: 35px;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 700;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s;
&:hover {
background-color: #1b4ab7;
}
}
.app-list-container-figma {
display: flex;
flex-direction: column;
gap: 20px;
}
.app-card-figma {
display: flex;
align-items: center;
gap: 14px;
height: 107px;
padding: 20px 31px 20px 17px;
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 20px;
box-shadow: 0px 4px 5.45px rgba(192, 192, 192, 0.25);
text-decoration: none;
color: inherit;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
}
}
.app-card-icon-box {
width: 68px;
height: 67px;
background: #f9f9f9;
border: 1px solid #dee3e7;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
img,
svg {
width: 46px;
height: 46px;
object-fit: cover;
border-radius: 4px;
}
}
.app-card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.app-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-card-title {
font-size: 16px;
font-weight: 500;
color: #000;
// PC 환경 안내 문구 - 모바일에서만 노출
.app-create-pc-only-message {
display: none;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card-badge-container {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.app-card-badge {
height: 20px;
border-radius: 5px;
padding: 0 8px;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&.badge-pending {
background: #fdf4d4;
color: #ef9546;
}
&.badge-approved {
background: #1b4ab7;
color: #ffffff;
}
&.badge-inactive {
background: #f3f4f6;
color: #6b7280;
}
}
.app-card-desc {
color: $text-gray;
font-size: 14px;
color: #64748b;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.app-card-footer-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 0;
}
.app-card-expected-date {
font-size: 14px;
color: #64748b;
margin-left: 16px;
flex-shrink: 0;
white-space: nowrap;
}
.app-card-expected-date {
font-size: 12px;
color: #64748b;
text-align: right;
}
.app-list-empty-figma {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 20px;
text-align: center;
line-height: 1.5;
h3 {
font-size: 18px;
color: #000;
margin: 16px 0 8px;
@include respond-to('sm') {
display: block;
}
p {
font-size: 14px;
color: #64748b;
margin: 0;
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+111 -282
View File
@@ -26,7 +26,6 @@
.inquiry-header {
margin-bottom: $spacing-3xl;
@media (max-width: $breakpoint-sm) {
margin-bottom: $spacing-2xl;
}
@@ -262,68 +261,13 @@
justify-content: center;
align-items: center;
gap: $spacing-md;
margin-top: 40px;
margin-top: $spacing-xl;
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: stretch;
}
// List Button - Figma Design
.btn-inquiry-new {
display: flex;
align-items: center;
justify-content: center;
width: 247px;
height: 60px;
background: #2a69de;
color: #fff;
border: none;
font-size: 20px;
font-weight: 700;
border-radius: 10px;
cursor: pointer;
text-decoration: none;
transition: $transition-base;
@media (max-width: $breakpoint-md) {
width: 100%;
height: 50px;
font-size: 16px;
}
&:hover {
background: #1d52b5; // Changed from darken(#2a69de, 10%)
transform: translateY(-2px);
}
&--outline {
background: #fff;
color: #2a69de;
border: 1px solid #2a69de;
width: 120px;
height: 50px;
font-size: 16px;
&:hover {
background: #f8faff;
}
}
&--danger {
background: #fff;
color: #bb1026;
border: 1px solid #bb1026;
width: 120px;
height: 50px;
font-size: 16px;
&:hover {
background: #fdf5f6;
}
}
}
// Layout for detail page split actions
.actions-left,
.actions-right {
@@ -363,187 +307,143 @@
}
.inquiry-detail-container {
padding: 0;
background: transparent;
box-shadow: none;
min-height: auto;
margin: 0;
// Header styles
.notice-detail-header {
border-bottom: 1px solid #000;
padding-bottom: 25px;
.inquiry-header-meta {
display: flex;
gap: 6px;
font-size: 16px;
color: #767e89;
margin-bottom: 17px;
}
.notice-detail-title-wrapper {
display: flex;
align-items: flex-end;
gap: 15px;
@media (max-width: $breakpoint-md) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
.notice-detail-title {
margin: 0;
font-size: 28px;
font-weight: 500;
@media (max-width: $breakpoint-md) {
font-size: 22px;
}
}
}
// Detail body
.notice-detail-body {
padding-top: 20px;
border-bottom: 1px solid #dfdfdf;
font-size: 14px;
line-height: 20px;
color: #000;
img {
max-width: 100%;
height: auto;
}
}
// Admin response
.inquiry-response-section {
padding-top: 40px;
padding-bottom: 40px;
border-bottom: 1px solid #dfdfdf;
.inquiry-response-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0px;
border: none;
.response-admin {
display: flex;
gap: 9px;
align-items: center;
.admin-label {
font-size: 14px;
font-weight: 500;
color: #000;
}
.admin-badge {
background: #ecf1ff;
color: #2a69de;
font-size: 9px;
font-weight: 700;
padding: 3px 8px;
border-radius: 10px;
}
}
.response-meta {
color: #767e89;
font-size: 14px;
}
}
.inquiry-response-body {
background: #f4f5f9;
border-radius: 10px;
padding: 24px;
font-size: 14px;
line-height: 20px;
color: #000;
}
}
min-height: 600px;
margin: 0 auto;
}
// Inquiry detail card
.inquiry-detail-card {
background: $white;
border-radius: $border-radius-lg;
box-shadow: $shadow-md;
overflow: hidden;
margin-bottom: $spacing-xl;
// Header section
.detail-header {
padding: $spacing-3xl $spacing-2xl $spacing-xl;
border-bottom: 2px solid $border-gray;
// Attachment section
.detail-attachments {
padding: $spacing-lg $spacing-2xl;
background: $gray-bg;
border-bottom: 1px solid $border-gray;
@media (max-width: $breakpoint-sm) {
padding: $spacing-2xl $spacing-lg $spacing-md;
}
@media (max-width: $breakpoint-sm) {
padding: $spacing-md $spacing-lg;
}
// Status badge header styles now in components/_badges.scss
.attachment-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
.inquiry-title {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-dark;
line-height: $line-height-tight;
margin-bottom: $spacing-lg;
.attachment-item {
@media (max-width: $breakpoint-sm) {
font-size: $font-size-xl;
}
}
.inquiry-meta {
display: flex;
align-items: center;
gap: $spacing-sm;
gap: $spacing-lg;
flex-wrap: wrap;
font-size: $font-size-sm;
color: $text-gray;
a {
color: $text-dark;
text-decoration: none;
font-size: $font-size-sm;
transition: $transition-base;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-xs;
gap: $spacing-md;
}
.meta-item {
display: flex;
align-items: center;
gap: $spacing-xs;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-xs;
}
&:hover {
color: $primary-blue;
i {
transform: translateY(-2px);
}
}
i {
color: $primary-blue;
transition: $transition-base;
}
strong {
color: $text-dark;
font-weight: $font-weight-medium;
}
}
}
}
}
// Content section
.detail-content {
padding: $spacing-3xl $spacing-2xl;
min-height: 200px;
@media (max-width: $breakpoint-sm) {
padding: $spacing-2xl $spacing-lg;
}
.content-body {
font-size: $font-size-base;
color: $text-dark;
line-height: $line-height-loose;
white-space: pre-line;
// Attachment section
.detail-attachments {
padding: $spacing-lg $spacing-2xl;
background: $gray-bg;
border-bottom: 1px solid $border-gray;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-sm;
padding: $spacing-md $spacing-lg;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
.attachment-item {
display: flex;
align-items: center;
gap: $spacing-sm;
a {
color: $text-dark;
text-decoration: none;
font-size: $font-size-sm;
transition: $transition-base;
display: flex;
align-items: center;
gap: $spacing-xs;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-xs;
}
&:hover {
color: $primary-blue;
i {
transform: translateY(-2px);
}
}
i {
color: $primary-blue;
transition: $transition-base;
}
}
}
}
}
// Content section
.detail-content {
padding: $spacing-3xl $spacing-2xl;
min-height: 200px;
@media (max-width: $breakpoint-sm) {
padding: $spacing-2xl $spacing-lg;
}
.content-body {
font-size: $font-size-base;
color: $text-dark;
line-height: $line-height-loose;
white-space: pre-line;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-sm;
}
}
}
}
// Response section (answer from admin)
.inquiry-response-card {
background: $white;
@@ -792,74 +692,3 @@
// Uses the same action button styles from .inquiry-actions above
// Supports: action-btn-primary, action-btn-secondary
}
// -----------------------------------------------------------------------------
// New DJB Board Write Form (피드백/개선요청, Q&A 등록 )
// -----------------------------------------------------------------------------
.djb-board-write-container {
padding: 0;
.djb-board-write-header {
margin-bottom: 30px;
padding-bottom: 20px;
.title {
font-size: 24px;
font-weight: 700;
color: #000;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #7f8a95;
}
}
.djb-board-form {
.form-group {
margin-bottom: 30px;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 40px;
.btn-cancel {
width: 100px;
height: 48px;
background-color: #fff;
border: 1px solid #DFDFDF;
border-radius: 8px;
color: #515961;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: background-color 0.2s;
display: block;
&:hover {
background-color: #f8f9fa;
}
}
.btn-submit {
width: 100px;
height: 48px;
background-color: #2a69de;
border: 1px solid #2a69de;
border-radius: 8px;
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
&:hover {
background-color: darken(#2a69de, 5%);
}
}
}
}
@@ -22,291 +22,3 @@
// -----------------------------------------------------------------------------
// Withdrawal Link styles moved to components/_forms.scss (.form-actions--with-withdrawal)
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Corporate Manager Profile Form Styles
// -----------------------------------------------------------------------------
.corp-manager-wrapper, .password-change-wrapper {
width: 100%;
margin: 0 auto;
padding: 40px 0;
.page-outer-title {
font-size: 36px;
font-weight: 700;
color: #000;
margin-bottom: 24px;
}
.register-form-container {
background: #fff;
border: 1px solid #dfdfdf;
border-radius: 30px;
box-shadow: 0px 4px 26.9px 2px rgba(192, 192, 192, 0.25);
padding: 40px 48px;
}
.common-title-bar {
display: none;
}
.inner-section-title {
display: flex;
align-items: center;
gap: 15px;
border-bottom: 1px solid #000;
padding-bottom: 12px;
margin-bottom: 32px;
h3 {
font-size: 18px;
font-weight: 700;
color: #0d0e11;
margin: 0;
}
svg {
width: 17px;
height: 17px;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 13px;
margin-bottom: 26px;
&.two-columns {
flex-direction: row;
gap: 34px;
> div {
flex: 1;
display: flex;
flex-direction: column;
gap: 13px;
}
}
}
.form-label-wrapper {
margin: 0;
padding: 0;
width: auto;
}
.form-field-wrapper {
width: 100%;
}
#authNumberContainer .auth-input-group {
flex: none !important;
width: 440px !important;
}
#authNumberContainer .input-with-button {
justify-content: space-between;
}
#authNumber {
width: 100% !important;
}
.form-label-text {
font-size: 16px;
font-weight: 700;
color: #000;
}
.required-badge {
visibility: hidden;
position: relative;
width: 10px;
&::after {
content: '*';
visibility: visible;
position: absolute;
left: 4px;
top: 0;
color: #f4253c;
font-size: 16px;
font-weight: 700;
}
}
.form-input,
.form-select,
.input-readonly,
.info-value-box {
height: 47px;
background: #fcfcfc;
border: 1px solid #dfdfdf;
border-radius: 10px;
font-size: 16px;
color: #0d0e11;
padding: 0 20px;
width: 100%;
box-sizing: border-box;
}
.form-input:disabled,
.input-readonly {
background: #ededed;
}
.compound-input {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
.form-input,
.form-select {
flex: 1;
text-align: center;
padding: 0;
}
}
.separator {
font-size: 16px;
color: #000;
}
.input-with-button {
display: flex;
align-items: center;
gap: 9px;
}
.btn-change,
.btn-auth {
height: 47px;
background: #d5efff;
color: #2a69de;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 10px;
padding: 0 20px;
white-space: nowrap;
cursor: pointer;
}
.auth-input-group {
display: flex;
align-items: center;
position: relative;
flex: 1;
.form-input {
padding-right: 70px;
}
}
.auth-timer {
position: absolute;
right: 20px;
color: #f4253c;
font-size: 16px;
font-weight: 500;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
.btn-withdrawal {
height: 45px;
background: #ff615c;
color: #fff;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 10px;
padding: 0 20px;
cursor: pointer;
width: 156px;
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
}
.right-buttons {
display: flex;
gap: 8px;
}
.btn-cancel {
height: 45px;
background: #bdc7cf;
color: #fff;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 10px;
padding: 0 20px;
cursor: pointer;
width: 156px;
justify-content: center;
}
.btn-apply {
height: 45px;
background: #2a69de;
color: #fff;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 10px;
padding: 0 20px;
cursor: pointer;
width: 156px;
}
.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;
}
}
.password-rules-list {
list-style-type: disc;
padding-left: 24px;
margin: 20px 0 0 0;
li {
color: #888;
font-size: 16px;
line-height: 20px;
margin-bottom: 5px;
&:last-child {
margin-bottom: 0;
}
}
}
}
+64 -160
View File
@@ -71,7 +71,6 @@
margin: 0 auto;
padding: $spacing-3xl 0;
min-height: 600px;
@media (max-width: $breakpoint-md) {
padding: $spacing-2xl $spacing-md;
}
@@ -79,91 +78,25 @@
// Notice Detail Header - Title and Date in one row
.notice-detail-header {
padding-bottom: 24px;
border-bottom: 1px solid #212529;
@media (max-width: $breakpoint-md) {
padding-bottom: 16px;
}
}
.notice-detail-date {
font-size: 16px;
color: #767e89;
font-weight: 400;
margin: 0 0 17px 0;
@media (max-width: $breakpoint-md) {
font-size: 14px;
margin: 0 0 12px 0;
}
}
.notice-detail-title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
padding-bottom: $spacing-md;
border-bottom: 1px solid #212529;
margin-bottom: $spacing-xl;
@media (max-width: $breakpoint-md) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
.notice-type-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
width: 50px;
margin-right: 6px;
border-radius: 5px;
font-size: 10px;
font-weight: 500;
box-sizing: border-box;
&--incident {
border: 1px solid #f1c2d6;
background: #fbe7e9;
color: #bb1026;
}
&--maintenance {
background: #fdf4d4;
color: #ef9546;
border: 1px solid #ffcea1;
}
&--completed {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
}
&--closed {
background: #f5f5f5;
color: #888888;
border: 1px solid #dedede;
}
&--pending {
background: #fdf4d4;
color: #ef9546;
border: 1px solid #ffcea1;
}
&--fix {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
gap: $spacing-sm;
}
}
.notice-detail-title {
font-size: 28px;
font-weight: 500;
color: #000;
font-size: 24px;
font-weight: $font-weight-bold;
color: #212529;
line-height: 1;
margin: 0;
@media (max-width: $breakpoint-md) {
@@ -171,20 +104,24 @@
}
}
.notice-detail-date {
font-size: 24px;
font-weight: $font-weight-regular;
color: #515151;
@media (max-width: $breakpoint-md) {
font-size: 16px;
}
}
// Notice Attachment Section
.notice-detail-attachment {
margin-top: 20px;
margin-bottom: 20px;
padding: 15px 20px;
background: #f8fbfe;
border-radius: 8px;
display: flex;
align-items: center;
margin-bottom: $spacing-lg;
.attachment-list {
display: flex;
flex-direction: column;
gap: 8px;
gap: $spacing-sm;
}
.attachment-item {
@@ -194,20 +131,25 @@
}
.notice-attachment-link {
display: flex;
display: inline-flex;
align-items: center;
gap: 8px;
gap: $spacing-xs;
color: #515151;
font-size: 18px;
text-decoration: none;
color: #333;
transition: $transition-base;
@media (max-width: $breakpoint-md) {
font-size: 14px;
}
&:hover {
color: $primary-blue;
}
svg {
flex-shrink: 0;
color: #333;
color: #515151;
}
&:hover svg {
@@ -215,68 +157,32 @@
}
.attachment-label {
font-weight: 500;
font-size: 14px;
margin-right: $spacing-md;
}
.attachment-filename {
font-size: 14px;
color: #666;
}
}
// Incident Info Table
.notice-detail-incident-info {
margin-bottom: 20px;
.detail-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
border-top: none;
border-bottom: 1px solid #e3e8f0;
tr {
border-bottom: 1px solid #e3e8f0;
}
th {
background: #f9f9f9;
height: 43px;
font-weight: 500;
font-size: 14px;
text-align: center;
color: #000;
}
td {
padding-left: 19px;
font-size: 14px;
color: #000;
word-break: break-all;
}
color: #515151;
}
}
// Notice Content Section
.notice-detail-content {
padding: 20px 0 60px 0;
border-bottom: 1px solid #eee;
font-size: 16px;
line-height: 1.6;
color: #333;
margin-bottom: $spacing-3xl;
min-height: 300px;
}
.notice-content-body {
font-size: 16px;
color: #333;
line-height: 1.6;
background: #F6F9FB;
border-radius: 20px;
padding: $spacing-2xl;
min-height: 280px;
font-size: $font-size-base;
color: $text-dark;
line-height: $line-height-loose;
@media (max-width: $breakpoint-md) {
padding: $spacing-lg;
min-height: 200px;
border-radius: 12px;
}
// Rich text content styling
@@ -304,8 +210,7 @@
}
}
ul,
ol {
ul, ol {
margin: $spacing-md 0;
padding-left: $spacing-xl;
}
@@ -313,13 +218,17 @@
li {
margin-bottom: $spacing-sm;
}
@include respond-to('sm') {
background: $white;
padding: 0;
}
}
// Notice Detail Actions
.notice-detail-actions {
display: flex;
justify-content: center;
margin-top: 40px;
}
// List Button - Figma Design
@@ -327,32 +236,32 @@
display: flex;
align-items: center;
justify-content: center;
width: 247px;
width: 200px;
height: 60px;
background: #2a69de;
color: #fff;
background: #E5E7EB;
border: none;
font-size: 20px;
font-weight: 700;
border-radius: 10px;
border-radius: 12px;
font-family: $font-family-primary;
font-size: 18px;
font-weight: $font-weight-bold;
color: #5F666C;
cursor: pointer;
transition: $transition-base;
@media (max-width: $breakpoint-md) {
width: auto;
min-width: 120px;
padding: 0 20px;
height: 48px;
font-size: 16px;
min-width: 80px;
padding: 0 16px;
height: 44px;
font-size: 15px;
border-radius: 8px;
}
&:hover {
background: darken(#2a69de, 10%);
transform: translateY(-2px);
background: darken(#E5E7EB, 5%);
}
&:active {
transform: scale(0.98);
}
}
@@ -493,12 +402,7 @@
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
h1, h2, h3, h4, h5, h6 {
margin-top: $spacing-xl;
margin-bottom: $spacing-md;
font-weight: $font-weight-semibold;
@@ -521,8 +425,7 @@
}
}
ul,
ol {
ul, ol {
margin: $spacing-md 0;
padding-left: $spacing-xl;
}
@@ -797,8 +700,9 @@
}
.inquiry-content-body {
background: #F6F9FB;
border-radius: 20px;
padding: 10px 20px;
padding: $spacing-2xl;
min-height: 200px;
font-size: $font-size-base;
color: $text-dark;
@@ -1251,4 +1155,4 @@
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
// -----------------------------------------------------------------------------
.signup-selection-page {
min-height: calc(100vh - 140px); // Account for header and footer
min-height: calc(100vh - 380px); // Account for header and footer
display: flex;
align-items: center;
justify-content: center;
@@ -13,7 +13,6 @@
margin: 0 auto;
padding: $spacing-3xl 0;
margin-bottom: $spacing-2xl;
min-height: 750px; // Ensure the footer doesn't float when content is short
@media (max-width: $breakpoint-sm) {
padding: $spacing-lg $spacing-md;
@@ -68,25 +67,8 @@
// Extends .list-table from components/_tables.scss
// -----------------------------------------------------------------------------
.user-management-container {
.board-table {
.list-table {
margin-bottom: $spacing-2xl;
overflow-x: auto;
// Allow table rows to expand if buttons wrap
.board-table-row {
height: auto;
min-height: 50px;
.row-cell.row-actions {
white-space: normal;
}
}
.actions-desktop {
flex-wrap: wrap;
justify-content: center;
gap: 6px;
}
// Link styles in table cells
.row-cell a {
@@ -100,39 +82,6 @@
}
}
// Grid layout for user list table to avoid inline styles
.board-table--user-list {
.board-table-header,
.board-table-row {
display: grid;
grid-template-columns: 24px 112px 1fr 100px 100px 287px;
padding: 0 29px; // Match standard board-table padding
gap: 10px;
@media (max-width: $breakpoint-sm) {
display: flex;
padding: 15px;
min-width: auto;
}
}
.header-cell,
.row-cell {
padding: 0;
width: 100%;
box-sizing: border-box;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
.table-controls {
@extend .common-title-bar;
@@ -143,14 +92,7 @@
.search-box {
.btn {
height: 48px;
background-color: #2a69de;
border: 1px solid #2a69de;
border-radius: 9px;
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
height: 44px;
}
}
}
@@ -159,22 +101,15 @@
// Current user badge (used in actions column)
.user-current-badge {
display: inline-flex;
height: 30px;
align-items: center;
justify-content: center;
padding: 0 16px;
height: 28px;
background-color: #ecf0fa;
color: #2a69de;
border: 1px solid #4685ef;
border-radius: 5px;
font-size: 12px;
font-weight: 400;
font-family: 'Spoqa Han Sans Neo', sans-serif;
gap: 4px;
i {
display: none; // Hide the icon to match Figma exactly
}
gap: 6px;
padding: 0 $spacing-sm;
background-color: rgba($accent-purple, 0.1);
color: $accent-purple;
border-radius: $border-radius-sm;
font-size: 13px;
font-weight: $font-weight-medium;
// 모바일에서 숨김
@media (max-width: $breakpoint-sm) {
@@ -404,12 +339,9 @@
}
@keyframes pulse {
0%,
100% {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
@@ -483,7 +415,6 @@
// 사용자 관리 모바일 디자인
// -----------------------------------------------------------------------------
@media (max-width: $breakpoint-sm) {
// Show mobile actions, hide desktop actions
.actions-desktop {
display: none !important;
@@ -728,8 +659,7 @@
width: 20px;
height: 20px;
i,
svg {
i, svg {
font-size: 14px;
width: 14px;
height: 14px;
@@ -763,7 +693,6 @@
.list-table {
.list-table-header {
.header-cell {
// NO 컬럼 - 모바일에서 숨김
&:nth-child(1) {
display: none;
@@ -800,7 +729,6 @@
.list-table-row {
.row-cell {
// NO 컬럼 - 모바일에서 숨김
&:nth-child(1),
&.row-cell--number {
@@ -826,7 +754,7 @@
display: none !important;
}
>span:not(.notice-number):not(.file-icon) {
> span:not(.notice-number):not(.file-icon) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -912,7 +840,6 @@
.list-table {
.list-table-header {
.header-cell {
// NO 컬럼 - 모바일에서 숨김
&:nth-child(1) {
display: none;
@@ -935,7 +862,6 @@
.list-table-row {
.row-cell {
// NO 컬럼 - 모바일에서 숨김
&:nth-child(1),
&.row-cell--number {
@@ -960,7 +886,7 @@
display: none !important;
}
>span:not(.notice-number):not(.file-icon) {
> span:not(.notice-number):not(.file-icon) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1128,7 +1054,7 @@
background: #e0efff;
}
&+.dropdown-item {
& + .dropdown-item {
border-top: 1px solid #eee;
}
@@ -1160,4 +1086,4 @@
transform: translateY(4px);
}
}
}
}
@@ -1,43 +1,42 @@
<!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_title_layout}">
<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_title_layout}">
<body>
<th:block layout:fragment="contentFragment">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">OPEN API 목록</span>
</div>
<h1 class="service-hero__title">OPEN API</h1>
<p class="service-hero__desc">비즈니스 확장을 위한 DJBank의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
발급받아 지금 바로 개발을 시작해 보세요.</p>
</div>
</div>
</section>
<th:block layout:fragment="title">
<div class="page-title-banner" style="margin-bottom: 24px;">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>API 마켓</h1>
</div>
</th:block>
<section class="api-market-container">
<th:block layout:fragment="contentFragment">
<section class="api-market-container">
<!-- Sidebar Navigation -->
<aside class="service-sidebar" id="apiSidebar">
<nav class="service-nav">
<aside class="api-market-sidebar" id="apiSidebar">
<!-- Sidebar Header Image -->
<div class="api-sidebar-header">
<img th:src="@{/img/api_sidebar.png}" alt="API">
</div>
<nav class="api-sidebar-nav">
<!-- All APIs -->
<div class="service-nav__item js-menu-trigger" style="cursor: pointer;"
th:classappend="${selected == '-1' || selected == null} ? 'service-nav__item--active' : ''" data-group="">
전체
<div class="menu-section">
<div class="menu-title" th:classappend="${selected == '-1'} ? 'active'" data-group="">
전체
</div>
</div>
<!-- Service Categories -->
<div class="service-nav__item js-menu-trigger" style="cursor: pointer;" th:each="service : ${services}"
th:classappend="${selected == service.id} ? 'service-nav__item--active' : ''" th:data-group="${service.id}">
[[${service.groupName}]]
<div class="menu-section" th:each="service : ${services}">
<div class="menu-title"
th:classappend="${selected == service.id} ? 'active'"
th:data-group="${service.id}">
[[${service.groupName}]]
</div>
</div>
</nav>
</aside>
@@ -60,15 +59,15 @@
</div>
<div class="api-market-search">
<form id="searchForm" th:action="@{/apis}" method="get">
<input type="hidden" name="groupIds"
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
placeholder="검색어를 입력하세요.">
<input type="hidden" name="groupIds" th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}"/>
<input type="text"
class="search-input"
name="keyword"
th:value="${search.keyword}"
placeholder="어떤 API를 찾고 계신가요?">
<button type="submit" class="search-submit-btn" aria-label="검색">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z"
fill="#515961" />
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</button>
</form>
@@ -77,26 +76,34 @@
<!-- API Cards Grid -->
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}">
<div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button"
tabindex="0">
<!-- Group Badge -->
<div class="api-card-badge">
<span th:text="${api.apiGroupName}">그룹 이름</span>
<div class="api-card"
th:each="api : ${apis}"
th:data-href="@{/apis/detail(id=${api.apiId})}"
role="button"
tabindex="0">
<!-- Group Info -->
<div class="api-card-group">
<div class="api-card-group-icon">
<img th:if="${api.mainIcon}"
th:src="${api.mainIcon}"
th:alt="${api.apiGroupName}"
onerror="this.style.display='none'; this.parentElement.innerHTML='<i class=\'fas fa-cube\'></i>'">
<i th:if="${#strings.isEmpty(api.mainIcon)}" class="fas fa-cube"></i>
</div>
<span class="api-card-group-name" th:text="${api.apiGroupName}">
그룹 이름
</span>
</div>
<!-- API Name -->
<h3 class="api-card-title" th:text="${api.apiName}">API 이름</h3>
<h3 class="api-card-name" th:text="${api.apiName}">
API 이름
</h3>
<!-- API Description -->
<p class="api-card-desc" th:text="${api.apiSimpleDescription ?: 'API 설명이 없습니다.'}">API 설명</p>
<!-- API Image (Bottom Right) -->
<div class="api-card-image">
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}"
onerror="this.style.display='none'">
</div>
<p class="api-card-description" th:text="${api.apiSimpleDescription ?: 'API 설명이 없습니다.'}">
API 설명
</p>
</div>
</div>
@@ -108,14 +115,14 @@
</div>
</main>
</section>
</th:block>
</section>
</th:block>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
@@ -126,9 +133,9 @@
// API Card Click Handlers
const apiCards = document.querySelectorAll('.api-card');
apiCards.forEach(function (card) {
apiCards.forEach(function(card) {
// Click handler
card.addEventListener('click', function () {
card.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
@@ -136,7 +143,7 @@
});
// Keyboard accessibility
card.addEventListener('keypress', function (e) {
card.addEventListener('keypress', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const href = this.getAttribute('data-href');
@@ -148,9 +155,9 @@
});
// Category/Service Selection
const menuTitles = document.querySelectorAll('.js-menu-trigger');
menuTitles.forEach(function (title) {
title.addEventListener('click', function (e) {
const menuTitles = document.querySelectorAll('.menu-title');
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
const groupId = this.getAttribute('data-group');
@@ -168,7 +175,7 @@
});
// Search on Enter key
searchInput.addEventListener('keypress', function (e) {
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchForm.submit();
@@ -177,7 +184,7 @@
// Mobile Menu Toggle
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function (e) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
@@ -195,14 +202,14 @@
});
// Add touch event for better mobile support
mobileToggle.addEventListener('touchend', function (e) {
mobileToggle.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation();
this.click();
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function () {
mobileOverlay.addEventListener('click', function() {
sidebar.classList.remove('mobile-open');
this.classList.remove('active');
mobileToggle.innerHTML = '☰';
@@ -210,8 +217,8 @@
});
// Close menu on category selection (mobile)
menuTitles.forEach(function (title) {
title.addEventListener('click', function () {
menuTitles.forEach(function(title) {
title.addEventListener('click', function() {
if (window.innerWidth <= 768) {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
@@ -229,7 +236,7 @@
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function () {
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
@@ -241,4 +248,4 @@
</script>
</th:block>
</html>
</html>
@@ -1,115 +1,86 @@
<!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_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<!-- TODO: Update icon to faq icon -->
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">FAQ 게시판</span>
</div>
<h1 class="service-hero__title">FAQ</h1>
<p class="service-hero__desc">서비스 이용 중 궁금한 사항에 대한 답변을 빠르게 확인해 보세요<br>카테고리별 분류와 검색을 통해 원하는 정보를 쉽게 찾아보실 수
있습니다.</p>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>FAQ</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<section class="user-management-container">
<!-- Search and Filter Controls -->
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="page" value="1">
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
</h2>
<div class="search-field">
<input type="text"
th:field="*{searchWrd}"
placeholder="질문 또는 답변 내용으로 검색"
onkeypress="if(event.keyCode === 13) { fn_search_faq(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_faq()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 17.5L13.875 13.875M15.8333 9.16667C15.8333 12.8486 12.8486 15.8333 9.16667 15.8333C5.48477 15.8333 2.5 12.8486 2.5 9.16667C2.5 5.48477 5.48477 2.5 9.16667 2.5C12.8486 2.5 15.8333 5.48477 15.8333 9.16667Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="blind">검색</span>
</button>
</div>
</div>
</form>
<!-- FAQ Accordion -->
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
<div class="faq-item" th:each="faq : ${page.content}">
<div class="faq-question">
<span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
<span class="faq-icon">
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
<div class="faq-answer">
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
답변 내용이 여기에 표시됩니다.
</div>
</div>
</section>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('faq')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="notice-list-container"
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
<!-- Search and Filter Controls -->
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="page" value="1">
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
</h2>
<div class="search-field">
<input type="text" th:field="*{searchWrd}" placeholder="질문 또는 답변 내용으로 검색"
onkeypress="if(event.keyCode === 13) { fn_search_faq(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_faq()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17.5 17.5L13.875 13.875M15.8333 9.16667C15.8333 12.8486 12.8486 15.8333 9.16667 15.8333C5.48477 15.8333 2.5 12.8486 2.5 9.16667C2.5 5.48477 5.48477 2.5 9.16667 2.5C12.8486 2.5 15.8333 5.48477 15.8333 9.16667Z"
stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="blind">검색</span>
</button>
</div>
</div>
</form>
<!-- FAQ Accordion -->
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
<div class="faq-item" th:each="faq : ${page.content}">
<div class="faq-question">
<span class="faq-question-q"
style="font-family: 'OneShinhan', sans-serif; font-size: 20px; font-weight: 700; color: #555; margin-right: 15px;">Q</span>
<span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
<span class="faq-icon">
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</span>
</div>
<div class="faq-answer">
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
답변 내용이 여기에 표시됩니다.
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div class="user-empty-state" th:if="${#lists.isEmpty(page.content)}">
<div class="empty-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none"
role="img" aria-label="데이터 없음">
<circle cx="60" cy="60" r="50" fill="#F3F4F6" />
<rect x="30" y="35" width="60" height="50" rx="4" fill="#E5E7EB" stroke="#D1D5DB" stroke-width="2" />
<line x1="40" y1="50" x2="80" y2="50" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
<line x1="40" y1="60" x2="72" y2="60" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
<line x1="40" y1="70" x2="65" y2="70" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
<circle cx="85" cy="80" r="14" fill="#F9FAFB" stroke="#D1D5DB" stroke-width="2" />
<line x1="95" y1="90" x2="102" y2="97" stroke="#D1D5DB" stroke-width="3" stroke-linecap="round" />
<line x1="81" y1="80" x2="89" y2="80" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
</svg>
</div>
<h3>조회된 FAQ가 없습니다</h3>
<p>검색 조건을 변경하여 다시 시도해주세요.</p>
</div>
<!-- Pagination -->
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div>
</div>
</section>
</div>
</div>
</th:block>
<!-- Empty State -->
<div class="user-empty-state" th:if="${#lists.isEmpty(page.content)}">
<div class="empty-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="데이터 없음">
<circle cx="60" cy="60" r="50" fill="#F3F4F6"/>
<rect x="30" y="35" width="60" height="50" rx="4" fill="#E5E7EB" stroke="#D1D5DB" stroke-width="2"/>
<line x1="40" y1="50" x2="80" y2="50" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/>
<line x1="40" y1="60" x2="72" y2="60" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/>
<line x1="40" y1="70" x2="65" y2="70" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/>
<circle cx="85" cy="80" r="14" fill="#F9FAFB" stroke="#D1D5DB" stroke-width="2"/>
<line x1="95" y1="90" x2="102" y2="97" stroke="#D1D5DB" stroke-width="3" stroke-linecap="round"/>
<line x1="81" y1="80" x2="89" y2="80" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<h3>조회된 FAQ가 없습니다</h3>
<p>검색 조건을 변경하여 다시 시도해주세요.</p>
</div>
<!-- Pagination -->
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div>
</section>
</th:block>
</body>
@@ -131,15 +102,15 @@
return textArea.value;
}
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function() {
// Decode HTML entities in FAQ answers
document.querySelectorAll('.faq-answer-content').forEach(function (element) {
document.querySelectorAll('.faq-answer-content').forEach(function(element) {
element.innerHTML = decodeHTMLEntities(element.innerHTML);
});
// FAQ Accordion functionality
document.querySelectorAll('.faq-question').forEach(function (question) {
question.addEventListener('click', function (e) {
document.querySelectorAll('.faq-question').forEach(function(question) {
question.addEventListener('click', function(e) {
e.preventDefault();
const faqItem = this.closest('.faq-item');
@@ -158,4 +129,4 @@
</script>
</th:block>
</html>
</html>
@@ -1,118 +1,78 @@
<!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_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>Q&A</h1>
</div>
</section>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>Q&A</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/q&a_img.svg}" alt="Q&A 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
<section layout:fragment="contentFragment">
<div class="inquiry-detail-container">
<!-- Inquiry Header: Status Badge and Title -->
<div class="inquiry-detail-header">
<div class="inquiry-header-top">
<span class="inquiry-status-badge"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
답변대기
</span>
<div class="inquiry-header-right">
<span class="inquiry-detail-writer"
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">작성자</span>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-date" th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
</div>
</section>
</div>
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
</div>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="inquiry-detail-container">
<!-- Detail Header -->
<div class="notice-detail-header">
<div class="inquiry-header-meta">
<span th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2026.06.24</span>
<span>|</span>
<span
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">EACTIVE</span>
</div>
<div class="notice-detail-title-wrapper">
<span class="notice-type-badge notice-type-badge--completed"
th:if="${inquiry.inquiryStatus == 'RESPONDED'}">답변완료</span>
<span class="notice-type-badge notice-type-badge--closed"
th:if="${inquiry.inquiryStatus == 'CLOSED'}">종료</span>
<span class="notice-type-badge notice-type-badge--pending"
th:if="${inquiry.inquiryStatus != 'RESPONDED' and inquiry.inquiryStatus != 'CLOSED'}">답변대기</span>
<h2 class="notice-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
</div>
</div>
<!-- Detail Body -->
<div class="notice-detail-body">
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
문의 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Admin Response Section -->
<div class="inquiry-response-section"
th:if="${inquiry.inquiryStatus == 'RESPONDED' or inquiry.inquiryStatus == 'CLOSED'}">
<div class="inquiry-response-header">
<div class="response-admin">
<span class="admin-label">ADMIN</span>
<span class="admin-badge">관리자</span>
</div>
<div class="response-meta">
<span th:text="${#temporals.format(inquiry.responseDate, 'yyyy.MM.dd')}">2026-06-24</span>
</div>
</div>
<div id="responseDetail" class="inquiry-response-body" th:utext="${inquiry.responseDetail}">
답변 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Comment Section (DJBank Custom DJPGPT0002) -->
<th:block
th:replace="~{apps/community/djb/fragments-inquiry-comments :: commentSection(${inquiry}, ${commentWritable})}">
</th:block>
<!-- Action Buttons -->
<div class="inquiry-actions">
<button type="button" class="btn-inquiry-new" th:onclick="|location.href='@{/inquiry}'|">목록</button>
<th:block
th:if="${inquiry.inquiryStatus == 'PENDING' && inquiry.inquirer.id == #authentication.principal.id}">
<form id="deleteForm" th:action="@{/inquiry/{id}/delete(id=${inquiry.id})}" method="post"
style="display: none;"></form>
<button type="button" class="btn-inquiry-new btn-inquiry-new--outline"
th:onclick="|location.href='@{/inquiry/edit(id=${inquiry.id})}'|">수정</button>
<button type="button" class="btn-inquiry-new btn-inquiry-new--danger"
onclick="confirmDelete()">삭제</button>
</th:block>
</div>
</div>
</section>
<!-- Inquiry Content Section -->
<div class="inquiry-detail-content">
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
문의 내용이 여기에 표시됩니다.
</div>
</div>
</section>
<!-- Admin Response Section -->
<div class="inquiry-response-section" th:if="${inquiry.inquiryStatus == 'RESPONDED' or inquiry.inquiryStatus == 'CLOSED'}">
<div class="inquiry-response-header">
<span class="response-label">관리자 답변</span>
<span class="response-date" th:text="${#temporals.format(inquiry.responseDate, 'yyyy.MM.dd')}">2025.11.02</span>
</div>
<div class="inquiry-response-body" id="responseDetail" th:utext="${inquiry.responseDetail}">
답변 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Comment Section (DJBank Custom DJPGPT0002) -->
<th:block th:replace="~{apps/community/djb/fragments-inquiry-comments :: commentSection(${inquiry}, ${commentWritable})}"></th:block>
<!-- Action Buttons -->
<div class="inquiry-detail-actions">
<div class="actions-left">
<button type="button" class="btn-inquiry-list" th:onclick="|location.href='@{/inquiry}'|">목록</button>
</div>
<div class="actions-right" th:if="${inquiry.inquiryStatus == 'PENDING' && inquiry.inquirer.id == #authentication.principal.id}">
<form id="deleteForm" th:action="@{/inquiry/{id}/delete(id=${inquiry.id})}" method="post" style="display: none;"></form>
<button type="button" class="btn-inquiry-edit" th:onclick="|location.href='@{/inquiry/edit(id=${inquiry.id})}'|">수정</button>
<button type="button" class="btn-inquiry-delete" onclick="confirmDelete()">삭제</button>
</div>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function () {
var successMsg = [[${ success }]];
$(document).ready(function() {
var successMsg = [[${success}]];
if (successMsg) {
customPopups.showAlert(successMsg);
}
@@ -127,14 +87,14 @@
}
function confirmDelete() {
customPopups.showConfirm('문의글을 삭제하시겠습니까?', function (result) {
customPopups.showConfirm('문의글을 삭제하시겠습니까?', function(result) {
if (result) {
document.getElementById('deleteForm').submit();
}
});
}
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function() {
var inquiryElement = document.getElementById('inquiryDetail');
if (inquiryElement) {
inquiryElement.innerHTML = decodeHTMLEntities(inquiryElement.innerHTML);
@@ -149,4 +109,4 @@
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
</th:block>
</html>
</html>
@@ -11,85 +11,69 @@
</section>
<th:block layout:fragment="contentFragment" th:with="isNew=${inquiry.id == null}">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/q&a_img.svg}" alt="Q&A 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해 드리겠습니다.</p>
</div>
</div>
</section>
<section class="inquiry-form-container">
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="djb-board-write-container">
<!-- Title Bar -->
<div class="djb-board-write-header">
<h2 class="title" th:text="${isNew ? '질문하기' : '질문 수정'}">질문하기</h2>
<p class="subtitle">궁금하신 사항을 작성해 주세요.</p>
</div>
<!-- Form Content -->
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" method="post" class="djb-board-form">
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
<!-- Subject Field -->
<div class="form-group">
<label class="djb-label" for="inquirySubject">제목 <span class="required">*</span></label>
<input type="text"
id="inquirySubject"
th:field="*{inquirySubject}"
class="djb-input"
placeholder="제목을 입력하세요"
required
maxlength="200">
</div>
<!-- Content Field -->
<div class="form-group">
<label class="djb-label" for="inquiryDetail">내용 <span class="required">*</span></label>
<textarea id="inquiryDetail"
th:field="*{inquiryDetail}"
class="djb-textarea"
placeholder="내용을 입력하세요"
required
maxlength="2000"></textarea>
<div class="djb-char-counter-wrapper">
<span class="char-counter">0 / 2000</span>
</div>
</div>
</form>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/inquiry}'|">취소</button>
<button type="button" class="btn-submit btn-submit-form">
<span th:text="${isNew ? '등록' : '수정'}">등록</span>
</button>
</div>
</div>
</section>
<!-- Title Bar -->
<div class="register-title-bar">
<h2 class="register-title" th:text="${isNew ? '질문하기' : '질문 수정'}">질문하기</h2>
<span class="register-subtitle">궁금하신 사항을 작성해 주세요.</span>
</div>
</div>
<!-- Form Content -->
<div class="register-form-container">
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" method="post" class="register-form">
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
<!-- Subject Field -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">제목</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<input type="text"
id="inquirySubject"
th:field="*{inquirySubject}"
class="form-input"
placeholder="제목을 입력하세요"
required
maxlength="200">
</div>
</div>
<!-- Content Field -->
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">내용</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<textarea id="inquiryDetail"
th:field="*{inquiryDetail}"
class="form-textarea"
rows="10"
placeholder="내용을 입력하세요"
required
maxlength="2000"></textarea>
<small class="char-counter-wrapper">
<span class="char-counter">0 / 2000</span>
</small>
</div>
</div>
</form>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/inquiry}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary btn-submit-form">
<span th:text="${isNew ? '등록' : '수정'}">등록</span>
</button>
</div>
</section>
</th:block>
</body>
@@ -1,135 +1,112 @@
<!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_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<!-- Used notice icon as a placeholder, can be updated if inquiry icon exists -->
<img th:src="@{/img/keyimage/notice_img.svg}" alt="Q&A 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">공지사항 목록</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>Q&A</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<section class="user-management-container inquiry-list-container">
<!-- Search and Filter Controls -->
<form name="inquiryForm" th:action="@{/inquiry}" method="get" onsubmit="return false;">
<input type="hidden" name="page" value="1">
<input name="searchCnd" type="hidden" value="0">
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
</h2>
<div class="search-field">
<input type="text"
name="searchWrd"
th:value="${search.searchWrd}"
placeholder="제목 또는 내용으로 검색"
onkeypress="if(event.keyCode === 13) { fn_search_inquiry(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_inquiry()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 17.5L13.875 13.875M15.8333 9.16667C15.8333 12.8486 12.8486 15.8333 9.16667 15.8333C5.48477 15.8333 2.5 12.8486 2.5 9.16667C2.5 5.48477 5.48477 2.5 9.16667 2.5C12.8486 2.5 15.8333 5.48477 15.8333 9.16667Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="blind">검색</span>
</button>
</div>
</section>
</div>
</form>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
<!-- Inquiry Table -->
<div class="list-table" th:if="${!inquiries.isEmpty()}">
<!-- Table Header -->
<div class="list-table-header">
<div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 100px;">작성자</div>
<div class="header-cell" style="width: 120px;">처리상태</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
<!-- Content -->
<section class="service-content">
<section class="user-management-container inquiry-list-container"
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
<!-- Search and Filter Controls -->
<form name="inquiryForm" th:action="@{/inquiry}" method="get" onsubmit="return false;">
<input type="hidden" name="page" value="1">
<input name="searchCnd" type="hidden" value="0">
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
</h2>
<div class="search-field">
<input type="text" name="searchWrd" th:value="${search.searchWrd}" placeholder="제목/내용 검색"
onkeypress="if(event.keyCode === 13) { fn_search_inquiry(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_inquiry()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17.5 17.5L13.875 13.875M15.8333 9.16667C15.8333 12.8486 12.8486 15.8333 9.16667 15.8333C5.48477 15.8333 2.5 12.8486 2.5 9.16667C2.5 5.48477 5.48477 2.5 9.16667 2.5C12.8486 2.5 15.8333 5.48477 15.8333 9.16667Z"
stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="blind">검색</span>
</button>
</div>
</div>
</form>
<!-- Inquiry Table -->
<div class="board-table" th:if="${!inquiries.isEmpty()}">
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
<!-- Table Body -->
<div class="board-table-body">
<div class="board-table-row" th:each="inquiry, status : ${inquiries}"
th:onclick="'location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\''"
style="cursor: pointer;">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="@{/inquiry/detail(id=${inquiry.id})}" class="notice-title-link">
<span class="notice-number"
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-type-badge notice-type-badge--maintenance"
th:if="${inquiry.inquiryStatus == 'PENDING'}">답변대기</span>
<span class="notice-type-badge notice-type-badge--fix"
th:if="${inquiry.inquiryStatus == 'RESPONDED'}">답변완료</span>
<span class="notice-type-badge notice-type-badge--incident"
th:if="${inquiry.inquiryStatus == 'CLOSED'}">종료</span>
<span th:text="${inquiry.inquirySubject}">질문 제목</span>
<span class="inquiry-comment-count"
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
</a>
</div>
<div class="row-cell" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
</div>
<!-- Empty State -->
<div class="user-empty-state" th:if="${inquiries.isEmpty()}">
<h3>조회된 Q&A가 없습니다</h3>
<p style="color: #64748b; margin-top: 8px;">검색 조건을 변경하거나 새로운 질문을 등록해주세요.</p>
</div>
<!-- Pagination -->
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div>
<!-- New Inquiry Button -->
<div style="display: flex; justify-content: center; margin-top: 40px;">
<a th:href="@{/inquiry/new}" class="btn btn-primary"
style="width: 247px; height: 60px; font-size: 20px; font-weight: 700; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: #2a69de; color: #fff; text-decoration: none;">
문의하기
</a>
</div>
</section>
</section>
<!-- Table Body -->
<div class="list-table-body">
<div class="list-table-row"
th:each="inquiry, status : ${inquiries}"
th:onclick="'location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\''"
style="cursor: pointer;">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="@{/inquiry/detail(id=${inquiry.id})}" class="notice-title-link">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span th:text="${inquiry.inquirySubject}">질문 제목</span>
<span class="inquiry-comment-count"
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
</a>
</div>
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
<div class="row-cell" style="width: 120px;" data-label="처리상태">
<span class="inquiry-status-badge"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
답변대기
</span>
</div>
<div class="row-cell" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
</div>
</th:block>
<!-- Empty State -->
<div class="user-empty-state" th:if="${inquiries.isEmpty()}">
<h3>조회된 Q&A가 없습니다</h3>
<p>검색 조건을 변경하거나 새로운 질문을 등록해주세요.</p>
</div>
<!-- Pagination -->
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div>
<!-- New Inquiry Button -->
<div class="inquiry-actions">
<a th:href="@{/inquiry/new}" class="btn btn-primary btn-lg">
<span>질문하기</span>
</a>
</div>
</section>
</th:block>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function () {
var successMsg = [[${ success }]];
var successMsg = [[${success}]];
if (successMsg) {
customPopups.showAlert(successMsg);
}
@@ -156,4 +133,4 @@
</script>
</th:block>
</html>
</html>
@@ -10,107 +10,84 @@
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해 주시기 바랍니다.</p>
<section layout:fragment="contentFragment">
<div class="notice-detail-container">
<!-- Notice Header: Title and Date -->
<div class="notice-detail-header">
<h2 class="notice-detail-title">
<span th:if="${portalNotice.noticeType == '3'}"
style="display:inline-block;padding:2px 10px;margin-right:8px;border-radius:12px;font-size:14px;vertical-align:middle;background:#fde7e9;color:#c0152a;">장애</span>
<span th:if="${portalNotice.noticeType == '2'}"
style="display:inline-block;padding:2px 10px;margin-right:8px;border-radius:12px;font-size:14px;vertical-align:middle;background:#fff4d6;color:#9a6700;">점검</span>
<span th:text="${portalNotice.noticeSubject}">개인정보처리 방침 정정 공지</span>
</h2>
<span class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
</div>
<!-- 장애/점검 정보 영역 -->
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}"
style="margin:16px 0;padding:16px;background:#f7f8fa;border:1px solid #e3e6eb;border-radius:8px;">
<table style="width:100%;border-collapse:collapse;">
<colgroup>
<col style="width:120px;"><col><col style="width:120px;"><col>
</colgroup>
<tr>
<th style="text-align:left;padding:6px 8px;">시작</th>
<td style="padding:6px 8px;"
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy.MM.dd HH:mm') : '-'}">-</td>
<th style="text-align:left;padding:6px 8px;">종료</th>
<td style="padding:6px 8px;"
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy.MM.dd HH:mm') : '진행중'}">-</td>
</tr>
<tr th:if="${portalNotice.incidentType}">
<th style="text-align:left;padding:6px 8px;">상태</th>
<td colspan="3" style="padding:6px 8px;" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
</tr>
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<th style="text-align:left;padding:6px 8px;vertical-align:top;">영향 API</th>
<td colspan="3" style="padding:6px 8px;">
<ul style="margin:0;padding-left:18px;">
<li th:each="api : ${portalNotice.affectedApis}">
<strong th:text="${api.apiId}">API_ID</strong>
<span th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</li>
</ul>
</td>
</tr>
</table>
</div>
<!-- Attachment Section -->
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
</svg>
<span class="attachment-label">첨부파일</span>
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
</a>
</div>
</div>
</section>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="notice-detail-container" style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
<!-- Notice Header: Title and Date -->
<div class="notice-detail-header">
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2026.06.24</p>
<div class="notice-detail-title-wrapper">
<span class="notice-type-badge notice-type-badge--incident" th:if="${portalNotice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${portalNotice.noticeType == '2'}">점검</span>
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
</div>
</div>
<!-- 장애/점검 정보 영역 -->
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
<table class="detail-table">
<colgroup>
<col style="width: 160px;"><col><col style="width: 160px;"><col>
</colgroup>
<tbody>
<tr>
<th>시작</th>
<td th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">-</td>
<th></th>
<td th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">-</td>
</tr>
<tr>
<th>상태</th>
<td th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
<th>영향 API</th>
<td>
<th:block th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<span th:each="api, iterStat : ${portalNotice.affectedApis}">
<strong th:text="${api.apiId}">API_ID</strong><span th:if="${api.apiName != null and !api.apiName.isEmpty()}" th:text="| - ${api.apiName}|"></span><th:block th:if="${!iterStat.last}">, </th:block>
</span>
</th:block>
<th:block th:unless="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">-</th:block>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Attachment Section -->
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
</svg>
<span class="attachment-label">첨부파일</span>
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
</a>
</div>
</div>
</div>
<!-- Content Section -->
<div class="notice-detail-content">
<div id="noticeDetail" class="notice-content-body editor-content" th:utext="${portalNotice.noticeDetail}">
공지사항 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Action Buttons -->
<div class="notice-detail-actions">
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/portalnotice}'|">목록</button>
</div>
</div>
</section>
</div>
<!-- Content Section -->
<div class="notice-detail-content">
<div id="noticeDetail" class="notice-content-body editor-content" th:utext="${portalNotice.noticeDetail}">
공지사항 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Action Buttons -->
<div class="notice-detail-actions">
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/portalnotice}'|">목록</button>
</div>
</div>
</th:block>
</section>
</body>
@@ -1,123 +1,97 @@
<!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_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>공지사항</h1>
</div>
</section>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>공지사항</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다</p>
</div>
<th:block layout:fragment="contentFragment">
<section class="user-management-container notice-list-container">
<!-- Search and Filter Controls -->
<form name="noticeForm" th:action="@{/portalnotice}" method="get" onsubmit="return false;">
<input type="hidden" name="page" value="1">
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
</h2>
<div class="search-field">
<input type="text"
name="searchSubjectOrDetail"
th:value="${search.searchSubjectOrDetail}"
placeholder="제목/내용 검색"
onkeypress="if(event.keyCode === 13) { fn_search_notice(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_notice()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 17.5L13.875 13.875M15.8333 9.16667C15.8333 12.8486 12.8486 15.8333 9.16667 15.8333C5.48477 15.8333 2.5 12.8486 2.5 9.16667C2.5 5.48477 5.48477 2.5 9.16667 2.5C12.8486 2.5 15.8333 5.48477 15.8333 9.16667Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="blind">검색</span>
</button>
</div>
</section>
</div>
</form>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
<!-- Notice Table -->
<div class="list-table" th:if="${!portalNotices.isEmpty()}">
<!-- Table Header -->
<div class="list-table-header">
<div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
<!-- Content -->
<section class="service-content">
<section class="user-management-container notice-list-container"
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
<!-- Search and Filter Controls -->
<form name="noticeForm" th:action="@{/portalnotice}" method="get" onsubmit="return false;">
<input type="hidden" name="page" value="1">
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
</h2>
<div class="search-field">
<input type="text" name="searchSubjectOrDetail" th:value="${search.searchSubjectOrDetail}"
placeholder="제목/내용 검색" onkeypress="if(event.keyCode === 13) { fn_search_notice(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_notice()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17.5 17.5L13.875 13.875M15.8333 9.16667C15.8333 12.8486 12.8486 15.8333 9.16667 15.8333C5.48477 15.8333 2.5 12.8486 2.5 9.16667C2.5 5.48477 5.48477 2.5 9.16667 2.5C12.8486 2.5 15.8333 5.48477 15.8333 9.16667Z"
stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="blind">검색</span>
</button>
</div>
</div>
</form>
<!-- Notice Table -->
<div class="board-table" th:if="${!portalNotices.isEmpty()}">
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
<!-- Table Body -->
<div class="board-table-body">
<div class="board-table-row" th:each="notice, status : ${portalNotices}"
th:onclick="'location.href=\'' + @{/portalnotice/detail(id=${notice.id})} + '\''"
style="cursor: pointer;">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
<span class="notice-number"
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
<span class="notice-type-badge notice-type-badge--incident" th:if="${notice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span>
<span th:text="${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
d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z"
fill="currentColor" />
</svg>
</span>
</a>
</div>
<div class="row-cell" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(notice.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
</div>
<!-- Empty State -->
<div class="user-empty-state" th:if="${portalNotices.isEmpty()}">
<h3>조회된 공지사항이 없습니다</h3>
</div>
<!-- Pagination -->
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div>
</section>
</section>
<!-- Table Body -->
<div class="list-table-body">
<div class="list-table-row"
th:each="notice, status : ${portalNotices}"
th:onclick="'location.href=\'' + @{/portalnotice/detail(id=${notice.id})} + '\''"
style="cursor: pointer;">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-fix-badge"
th:if="${notice.fixYn == 'Y'}"
style="display:inline-block;padding:2px 8px;margin-right:6px;border-radius:10px;font-size:12px;background:#e5f0ff;color:#0a4ea3;font-weight:600;">고정</span>
<span class="notice-type-badge notice-type-badge--incident"
th:if="${notice.noticeType == '3'}"
style="display:inline-block;padding:2px 8px;margin-right:6px;border-radius:10px;font-size:12px;background:#fde7e9;color:#c0152a;">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance"
th:if="${notice.noticeType == '2'}"
style="display:inline-block;padding:2px 8px;margin-right:6px;border-radius:10px;font-size:12px;background:#fff4d6;color:#9a6700;">점검</span>
<span th:text="${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 d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
</svg>
</span>
</a>
</div>
<div class="row-cell" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(notice.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
</div>
</th:block>
<!-- Empty State -->
<div class="user-empty-state" th:if="${portalNotices.isEmpty()}">
<h3>조회된 공지사항이 없습니다</h3>
</div>
<!-- Pagination -->
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div>
</section>
</th:block>
</body>
@@ -137,10 +111,9 @@
document.noticeForm.submit();
}
$(function () {
$(function() {
fn_init();
});
</script>
</th:block>
</html>
</html>
@@ -1,96 +1,132 @@
<!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_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>피드백/개선요청</h1>
</div>
</section>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>피드백/개선요청</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/feedback_img.svg}" alt="피드백 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
<th:block layout:fragment="contentFragment">
<section class="inquiry-form-container">
<!-- Title Bar -->
<div class="register-title-bar">
<h2 class="register-title">피드백/개선요청</h2>
<span class="register-subtitle">DJBank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</span>
</div>
<!-- Form Content -->
<div class="register-form-container">
<form name="partnershipForm" id="partnershipForm" th:action="@{/partnership}" th:object="${partnership}"
enctype="multipart/form-data" method="post" class="register-form">
<!-- 제목 Field -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">제목</span>
<span class="required-badge">필수</span>
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">피드백/개선요청</span>
</div>
<h1 class="service-hero__title">피드백/개선요청</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
<div class="form-field-wrapper">
<input type="text"
id="bizSubject"
th:field="*{bizSubject}"
class="form-input"
placeholder="제안하고자 하는 제목을 입력해 주세요"
required
maxlength="200">
</div>
</div>
</section>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('feedback')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="djb-board-write-container">
<!-- Title Bar -->
<div class="djb-board-write-header">
<h2 class="title">피드백 / 개선 요청</h2>
<p class="subtitle">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</p>
</div>
<!-- Form Content -->
<form name="partnershipForm" id="partnershipForm" th:action="@{/partnership}" th:object="${partnership}"
enctype="multipart/form-data" method="post" class="djb-board-form">
<!-- 제목 Field -->
<div class="form-group">
<label class="djb-label" for="bizSubject">제목 <span class="required">*</span></label>
<input type="text" id="bizSubject" th:field="*{bizSubject}" class="djb-input"
placeholder="제안하고자 하는 제목을 입력해주세요" required maxlength="200">
</div>
<!-- 내용 Field -->
<div class="form-group">
<label class="djb-label" for="bizDetail">내용 <span class="required">*</span></label>
<textarea id="bizDetail" th:field="*{bizDetail}" class="djb-textarea"
placeholder="제안하고자 하는 내용을 상세히 입력해주세요" required maxlength="4000"></textarea>
<div class="djb-char-counter-wrapper">
<span class="char-counter">0 / 4000</span>
</div>
</div>
</form>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn-submit btn-submit-form">작성완료</button>
</div>
<!-- 사업 내용 Field -->
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">내용</span>
<span class="required-badge">필수</span>
</div>
</section>
</div>
<div class="form-field-wrapper">
<textarea id="bizDetail"
th:field="*{bizDetail}"
class="form-textarea"
rows="10"
placeholder="제안하고자 하시는 내용을 상세히 입력해 주세요"
required
maxlength="4000"></textarea>
<small class="char-counter-wrapper">
<span class="char-counter">0 / 4000</span>
</small>
</div>
</div>
<!-- File Upload Field -->
<!--/* 260520 - DJB 에서는 첨부 파일 허용안함. */-->
<!--<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">첨부파일</span>
</div>
<div class="form-field-wrapper">
<div class="file-upload-inline">
<div class="file-input-display" id="fileInputDisplay">
<span class="file-display-text" id="fileDisplayText">선택된 파일이 없습니다</span>
<button type="button" class="btn-remove-file-inline" id="btnRemoveFile" title="파일 삭제" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<button type="button" class="btn-file-attach" onclick="document.getElementById('file').click()">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
</svg>
파일첨부
</button>
<input type="file"
id="file"
name="files"
th:accept="${isInternalUser ? '*' : '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.hwp,.gif,.jpg,.jpeg,.png'}"
style="display: none;">
</div>
<div class="form-help-text">
<p><i class="fas fa-check-circle"></i>
<span th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요.'">첨부파일은 8MB 이내로 등록해 주세요.</span>
</p>
<p>
<i class="fas fa-check-circle"></i>
문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)
</p>
</div>
</div>
</div>-->
</form>
</div>
</th:block>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary btn-submit-form">
<span>작성 완료</span>
</button>
</div>
</section>
</th:block>
</body>
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
const errorMsg = [[${ error }]];
const errorMsg = [[${error}]];
customPopups.showAlert(errorMsg);
});
</script>
<script th:if="${success}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ success }]]);
customPopups.showAlert([[${success}]]);
});
</script>
<script th:inline="javascript">
@@ -110,7 +146,7 @@
charCounter.textContent = currentLength + ' / ' + maxLength;
// Character counter for textarea
bizDetailTextarea.addEventListener('input', function () {
bizDetailTextarea.addEventListener('input', function() {
const currentLength = this.value.length;
const maxLength = this.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
@@ -147,50 +183,41 @@
form.submit();
});
if (fileInput) {
fileInput.addEventListener('change', function () {
const file = fileInput.files[0];
fileInput.addEventListener('change', function () {
const file = fileInput.files[0];
if (file) {
/*[# th:if="${!isInternalUser}"]*/
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png'];
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (file) {
/*[# th:if="${!isInternalUser}"]*/
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png'];
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(fileExt)) {
customPopups.showAlert("허용된 파일 형식이 아닙니다.\n문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다.");
fileInput.value = '';
return;
}
/*[/]*/
if (fileDisplayText && fileInputDisplay && btnRemoveFile) {
fileDisplayText.textContent = file.name;
fileDisplayText.classList.add('has-file');
fileInputDisplay.classList.add('has-file');
btnRemoveFile.style.display = 'flex';
}
if (!allowedExtensions.includes(fileExt)) {
customPopups.showAlert("허용된 파일 형식이 아닙니다.\n문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다.");
fileInput.value = '';
return;
}
});
}
/*[/]*/
fileDisplayText.textContent = file.name;
fileDisplayText.classList.add('has-file');
fileInputDisplay.classList.add('has-file');
btnRemoveFile.style.display = 'flex';
}
});
// File remove handler
if (btnRemoveFile) {
btnRemoveFile.addEventListener('click', function (e) {
e.stopPropagation();
if (fileInput) fileInput.value = '';
if (fileDisplayText) {
fileDisplayText.textContent = '선택된 파일이 없습니다';
fileDisplayText.classList.remove('has-file');
}
if (fileInputDisplay) fileInputDisplay.classList.remove('has-file');
this.style.display = 'none';
});
}
btnRemoveFile.addEventListener('click', function (e) {
e.stopPropagation();
fileInput.value = '';
fileDisplayText.textContent = '선택된 파일이 없습니다';
fileDisplayText.classList.remove('has-file');
fileInputDisplay.classList.remove('has-file');
btnRemoveFile.style.display = 'none';
});
// Focus on subject field
document.getElementById('bizSubject').focus();
});
</script>
</th:block>
</html>
</html>
File diff suppressed because one or more lines are too long
@@ -1,364 +1,162 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<style>
/* App Card Figma Styles */
.app-management-layout {
display: flex;
gap: 36px;
max-width: 1200px;
margin: 40px auto 100px;
font-family: 'Spoqa Han Sans Neo', 'Noto Sans CJK KR', sans-serif;
}
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<section class="app-management-container">
.app-management-content {
flex: 1;
min-width: 0;
}
<!-- App List Container -->
<div class="app-list-container">
.app-management-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
<!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-list-item"
th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}">
.app-management-header h2 {
font-size: 24px;
font-weight: 700;
color: #000;
margin: 0;
}
<!-- App Icon -->
<div class="app-list-icon">
<img th:if="${request.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}"
alt="App Icon"
class="app-icon-image">
.btn-app-create-figma {
background-color: #2a69de;
color: #ffffff;
border-radius: 7px;
padding: 0 20px;
width: 122px;
height: 35px;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 700;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s;
}
.btn-app-create-figma:hover {
background-color: #1b4ab7;
}
.app-list-container-figma {
display: flex;
flex-direction: column;
gap: 20px;
}
.app-card-figma {
display: flex;
align-items: center;
gap: 14px;
height: 107px;
padding: 20px 31px 20px 17px;
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 20px;
box-shadow: 0px 4px 5.45px rgba(192, 192, 192, 0.25);
text-decoration: none;
color: inherit;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.app-card-figma:hover {
transform: translateY(-2px);
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
}
.app-card-icon-box {
width: 68px;
height: 67px;
background: #f9f9f9;
border: 1px solid #dee3e7;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.app-card-icon-box img,
.app-card-icon-box svg {
width: 46px;
height: 46px;
object-fit: cover;
border-radius: 4px;
}
.app-card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.app-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-card-title {
font-size: 16px;
font-weight: 500;
color: #000;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card-badge {
height: 20px;
border-radius: 5px;
padding: 0 8px;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.app-card-badge.badge-pending {
background: #fdf4d4;
color: #ef9546;
}
.app-card-badge.badge-approved {
background: #1b4ab7;
color: #ffffff;
}
.app-card-badge.badge-inactive {
background: #f3f4f6;
color: #6b7280;
}
.app-card-desc {
font-size: 14px;
color: #64748b;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.app-card-footer-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 0;
}
.app-card-expected-date {
font-size: 14px;
color: #64748b;
margin-left: 16px;
flex-shrink: 0;
white-space: nowrap;
}
.app-list-empty-figma {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
background: #ffffff;
border: 1px solid #dfdfdf;
border-radius: 20px;
text-align: center;
}
.app-list-empty-figma h3 {
font-size: 18px;
color: #000;
margin: 16px 0 8px;
}
.app-list-empty-figma p {
font-size: 14px;
color: #64748b;
margin: 0;
}
</style>
<div class="signup-guide-v2">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<!-- Header -->
<div class="app-management-header">
<h2>인증 키 관리</h2>
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-app-create-figma" id="requestApiKey">
앱 생성
</button>
</div>
</div>
<!-- App List Container -->
<div class="app-list-container-figma">
<!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="app-card-icon-box">
<img th:if="${request.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" alt="App Icon">
<svg th:unless="${request.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>
</svg>
</div>
<!-- App Info -->
<div class="app-card-info">
<div class="app-card-header">
<!-- App Name -->
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
<!-- Status Badge -->
<span class="app-card-badge badge-pending"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
승인대기
</span>
</div>
<!-- App Description -->
<div class="app-card-footer-row">
<p class="app-card-desc"
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
<span class="app-card-expected-date"
th:if="${request.approval != null and request.approval.approvalStatus != null and (request.approval.approvalStatus.toString() == 'REQUESTED' or request.approval.approvalStatus.description == '진행중')}"
th:text="${request.createdDate != null ? '예상 완료일 : ' + #temporals.format(request.createdDate.plusDays(14), 'yyyy.MM.dd') + ' (' + #temporals.format(request.createdDate.plusDays(14), 'E') + ')' : '예상 완료일 : 산정 중'}">
예상 완료일 : 2026.07.14 (화)
</span>
</div>
</div>
</a>
</th:block>
<!-- API Keys (Approved/Inactive) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
<!-- App Icon -->
<div class="app-card-icon-box">
<img th:if="${apikey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apikey.appIconFileId})}" alt="App Icon">
<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>
</svg>
</div>
<!-- App Info -->
<div class="app-card-info">
<div class="app-card-header">
<!-- App Name -->
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
<!-- Status Badge -->
<span class="app-card-badge"
th:classappend="${apikey.appstatus == '1' ? 'badge-approved' : apikey.appstatus == '0' ? 'badge-inactive' : 'badge-pending'}"
th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}">
승인
</span>
</div>
<!-- App Description -->
<p class="app-card-desc" th:text="${apikey.appDescription != null ? apikey.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
</div>
</a>
</th:block>
<!-- Empty State -->
<div class="app-list-empty-figma"
th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#dee3e7" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
<div th:unless="${request.appIconFileId != null}" class="app-icon-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
<h3>등록된 앱이 없습니다</h3>
<p>새로운 앱을 생성하여 API를 사용해보세요.</p>
</div>
</div>
</div>
<!-- App Info -->
<div class="app-list-info">
<div class="app-list-header">
<!-- Status Badge -->
<span class="app-status-badge status-pending"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
승인대기
</span>
<!-- App Name -->
<h3 class="app-list-name" th:text="${request.clientName}">앱 이름</h3>
<!-- Expected Completion Date -->
<span class="app-list-expect-date"
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
예상 완료일 : 05월 30일 (토)
</span>
</div>
<!-- App Description -->
<p class="app-list-description"
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
</div>
</a>
</th:block>
<!-- API Keys (Approved) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-list-item"
th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
<!-- App Icon -->
<div class="app-list-icon">
<img th:if="${apikey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apikey.appIconFileId})}"
alt="App Icon"
class="app-icon-image">
<div th:unless="${apikey.appIconFileId != null}" class="app-icon-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
</div>
</div>
<!-- App Info -->
<div class="app-list-info">
<div class="app-list-header">
<!-- Status Badge -->
<span class="app-status-badge"
th:classappend="${apikey.appstatus == '1' ? 'status-approved' : apikey.appstatus == '0' ? 'status-inactive' : 'status-pending'}"
th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}">
승인
</span>
<!-- App Name -->
<h3 class="app-list-name" th:text="${apikey.clientname}">앱 이름</h3>
</div>
<!-- App Description -->
<p class="app-list-description"
th:text="${apikey.appDescription != null ? apikey.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
</div>
</a>
</th:block>
<!-- Empty State -->
<div class="app-list-empty" th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}">
<div class="empty-icon">🔑</div>
<h3>등록된 앱이 없습니다</h3>
<p>새로운 앱을 생성하여 API를 사용해보세요.</p>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
<!-- Create App Button -->
<div class="app-create-button-wrapper" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-app-create" id="requestApiKey">
앱 생성
</button>
<p class="app-create-pc-only-message">
앱 생성은 PC 환경에서 이용해 주세요.
</p>
</div>
// App creation button handler
const requestApiKeyBtn = document.getElementById('requestApiKey');
</section>
</th:block>
function handleApiKeyRequest() {
// Clear any existing registration data from sessionStorage
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('registration_')) {
keysToRemove.push(key);
}
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// App creation button handler
const requestApiKeyBtn = document.getElementById('requestApiKey');
function handleApiKeyRequest() {
// Clear any existing registration data from sessionStorage
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('registration_')) {
keysToRemove.push(key);
}
keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Redirect to the API key registration wizard with clear parameter
window.location.href = '/myapikey/register/step1?clear=true';
}
keysToRemove.forEach(key => sessionStorage.removeItem(key));
if (requestApiKeyBtn) {
requestApiKeyBtn.addEventListener('click', handleApiKeyRequest);
}
});
</script>
</th:block>
// Redirect to the API key registration wizard with clear parameter
window.location.href = '/myapikey/register/step1?clear=true';
}
if (requestApiKeyBtn) {
requestApiKeyBtn.addEventListener('click', handleApiKeyRequest);
}
});
</script>
</th:block>
</body>
</html>
</html>
@@ -11,379 +11,460 @@
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<section class="apikey-register-container">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">앱 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 앱 정보수정 (form/input icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 (chain link icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 완료 (rocket icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">완료</span>
</div>
</div>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">앱 수정</h2>
<span class="common-subtitle">서비스 중인 앱 정보를 수정합니다.</span>
</div>
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<!-- Step 1: 앱 정보수정 (Active) -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보수정" width="36" height="36">
</div>
</div>
<div class="step-label">앱 정보수정</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
<!-- Dots -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div>
<div class="step-label">API 선택</div>
</div>
<!-- Form Card -->
<div class="s1-form-card">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}"
th:object="${apiKeyModification}" enctype="multipart/form-data">
<!-- Dots -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Hidden clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif" style="display:none;">
</div>
</div>
<!-- 앱 이름 -->
<div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
<input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
placeholder="앱 이름을 입력하세요." required maxlength="100">
</div>
<!-- 앱 설명 -->
<div class="s1-field">
<label class="s1-label">앱 설명 <span class="s1-required">*</span></label>
<textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
placeholder="앱 설명을 입력하세요." required maxlength="500"></textarea>
<div class="s1-counter-row">
<span id="charCounter" class="s1-counter">0 / 500</span>
</div>
</div>
<!-- Call Back URL -->
<div class="s1-field">
<label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요.">
</div>
<!-- 화이트 리스트 -->
<div class="s1-field">
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
<!-- 입력행 -->
<div class="s1-ip-row">
<input type="text" id="ipWhitelistInput" class="s1-input" placeholder="서버 IP(111.111.111.111)">
<button type="button" class="s1-ip-btn s1-ip-btn--add" onclick="addIpAddress()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
추가
</button>
</div>
<!-- 추가된 IP 목록 -->
<div id="ipList" style="display:none;">
<div id="ipItems"></div>
</div>
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
</form>
<!-- Step 3: 완료 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="완료" width="36" height="36">
</div>
<!-- 다음 버튼 -->
<div class="s1-actions" style="justify-content: flex-end; gap: 10px;">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<button type="submit" form="modifyStep1Form" class="s1-btn-next" style="flex: none; width: 120px;">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
<div class="step-label">완료</div>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
let ipAddresses = [];
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
<span th:text="${error}"></span>
</div>
function removeAppIcon() {
const fileInput = document.getElementById('appIconFile');
const previewJS = document.getElementById('iconPreviewImageJS');
const preview1 = document.getElementById('iconPreviewImage1');
const preview2 = document.getElementById('iconPreviewImage2');
const placeholder = document.getElementById('iconPlaceholder');
const removeBtn = document.getElementById('btnRemoveIcon');
<!-- Form Content -->
<div class="register-form-container">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" th:object="${apiKeyModification}" class="register-form" enctype="multipart/form-data">
fileInput.value = '';
if(preview1) preview1.style.display = 'none';
if(preview2) preview2.style.display = 'none';
previewJS.style.display = 'none';
previewJS.src = '';
placeholder.style.display = 'flex';
removeBtn.style.display = 'none';
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- App Icon -->
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">앱 아이콘</span>
</div>
<div class="form-field-wrapper">
<div class="icon-upload-box">
<div class="icon-preview-area" id="iconPreview">
<div class="icon-placeholder" id="iconPlaceholder"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<!-- Show image from file service if available -->
<img id="iconPreviewImage"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="Icon Preview"
style="display: block;">
<!-- Show image from data if available -->
<img id="iconPreviewImage"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="Icon Preview"
style="display: block;">
<!-- JS Empty Image Target -->
<img id="iconPreviewImageJS" style="display: none;" alt="Icon Preview">
<button type="button" class="btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()" title="아이콘 삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="icon-upload-info">
<p class="upload-title">앱 아이콘 이미지 파일을 업로드하세요</p>
<p class="upload-hint">권장사이즈는 <strong>128X128픽셀</strong>이며 <strong>JPG, PNG, GIF</strong>파일만 등록할 수 있습니다.</p>
</div>
<button type="button" class="btn-upload" onclick="document.getElementById('appIcon').click()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
이미지 파일 업로드
</button>
<input type="file"
id="appIcon"
name="appIcon"
class="icon-input"
accept="image/png,image/jpeg,image/jpg,image/gif"
style="display: none;">
</div>
</div>
</div>
<!-- App Name -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">앱 이름</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<input type="text"
id="appName"
th:field="*{appName}"
class="form-input"
placeholder="앱 이름을 입력하세요."
required
maxlength="100">
</div>
</div>
<!-- App Description -->
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">앱 설명</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<textarea id="appDescription"
th:field="*{appDescription}"
class="form-textarea"
placeholder="앱 설명을 입력하세요."
rows="5"
required
maxlength="500"></textarea>
<small class="char-counter-wrapper">
<span class="char-counter">0 / 500</span>
</small>
</div>
</div>
<!-- Callback URL -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">Call Back URL</span>
</div>
<div class="form-field-wrapper">
<input type="text"
id="callbackUrl"
th:field="*{callbackUrl}"
class="form-input"
placeholder="URL을 입력해 주세요.">
</div>
</div>
<!-- IP Whitelist -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">화이트리스트</span>
</div>
<div class="form-field-wrapper">
<div class="ip-input-row">
<input type="text"
id="ipWhitelistInput"
class="form-input ip-input"
placeholder="서버 IP (111.111.111.111)">
<button type="button" class="btn-add-ip" onclick="addIpAddress()">
+ 추가
</button>
</div>
<!-- IP List Display -->
<div class="ip-list" id="ipList" style="display: none;">
<div class="ip-items" id="ipItems">
<!-- Dynamically added IP items -->
</div>
</div>
<!-- Hidden input to store IP list -->
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
</div>
</form>
</div>
<!-- Form Actions -->
<div class="form-actions">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn btn-submit btn-secondary">
취소
</a>
<button type="submit" form="modifyStep1Form" class="btn-primary btn-submit">
다음
</button>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
// IP address list management
let ipAddresses = [];
// Remove app icon
function removeAppIcon() {
const appIconInput = document.getElementById('appIcon');
const iconPreviewImages = document.querySelectorAll('img[id^="iconPreviewImage"]'); // Select all preview images
const iconPlaceholder = document.getElementById('iconPlaceholder');
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
// Clear file input
appIconInput.value = '';
// Hide all preview images
iconPreviewImages.forEach(img => {
img.style.display = 'none';
if (img.id === 'iconPreviewImageJS') img.src = '';
});
// Show placeholder
iconPlaceholder.style.display = 'flex';
// Hide delete button
btnRemoveIcon.style.display = 'none';
}
// Add IP address to the list
function addIpAddress() {
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
// Validate IP format
if (!ipValue) {
customPopups.showAlert('IP 주소를 입력해주세요.');
return;
}
function addIpAddress() {
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
if (!ipValue) {
customPopups.showAlert('IP 주소를 입력해주세요.');
return;
}
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) {
customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return;
}
if (ipAddresses.includes(ipValue)) {
customPopups.showAlert('이미 추가된 IP 주소입니다.');
return;
}
ipAddresses.push(ipValue);
renderIpList();
ipInput.value = '';
ipInput.focus();
// Basic IP validation (supports IP and CIDR notation)
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) {
customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return;
}
function removeIpAddress(ip) {
ipAddresses = ipAddresses.filter(function (i) { return i !== ip; });
renderIpList();
// Check for duplicates
if (ipAddresses.includes(ipValue)) {
customPopups.showAlert('이미 추가된 IP 주소입니다.');
return;
}
function renderIpList() {
const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems');
const hiddenInput = document.getElementById('ipWhitelist');
// Add to array
ipAddresses.push(ipValue);
if (ipAddresses.length > 0) {
ipList.style.display = 'block';
ipItems.innerHTML = ipAddresses.map(function (ip) {
return '<div class="s1-ip-item">' +
'<div class="s1-ip-display">' + ip + '</div>' +
'<button type="button" class="s1-ip-btn s1-ip-btn--remove" onclick="removeIpAddress(\'' + ip + '\')">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 11H5v2h14z"/></svg>' +
'제거</button>' +
'</div>';
}).join('');
} else {
ipList.style.display = 'none';
ipItems.innerHTML = '';
}
// Update UI
updateIpList();
hiddenInput.value = ipAddresses.join(',');
// Clear input
ipInput.value = '';
ipInput.focus();
}
// Remove IP address from the list
function removeIpAddress(ip) {
const index = ipAddresses.indexOf(ip);
if (index > -1) {
ipAddresses.splice(index, 1);
updateIpList();
}
}
// Update IP list display
function updateIpList() {
const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems');
const ipWhitelistHidden = document.getElementById('ipWhitelist');
// Show/hide list container
if (ipAddresses.length > 0) {
ipList.style.display = 'block';
// Build IP items HTML
let itemsHTML = '';
ipAddresses.forEach(function(ip) {
itemsHTML += `
<div class="ip-item">
<div class="ip-display">${ip}</div>
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')">
- 제거
</button>
</div>
`;
});
ipItems.innerHTML = itemsHTML;
} else {
ipList.style.display = 'none';
ipItems.innerHTML = '';
}
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('modifyStep1Form');
const textarea = document.getElementById('appDescription');
const counter = document.getElementById('charCounter');
const fileInput = document.getElementById('appIconFile');
const previewJS = document.getElementById('iconPreviewImageJS');
const preview1 = document.getElementById('iconPreviewImage1');
const preview2 = document.getElementById('iconPreviewImage2');
const placeholder = document.getElementById('iconPlaceholder');
const ipInput = document.getElementById('ipWhitelistInput');
// Update hidden input with comma-separated values
ipWhitelistHidden.value = ipAddresses.join(',');
}
// Thymeleaf: Restore IP whitelist
/*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
sessionIpWhitelist.split(',').forEach(function (ip) {
const t = ip.trim();
if (t && !ipAddresses.includes(t)) ipAddresses.push(t);
});
renderIpList();
}
/*]]>*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('modifyStep1Form');
const appDescTextarea = document.getElementById('appDescription');
const charCounter = document.querySelector('.char-counter');
const appIconInput = document.getElementById('appIcon');
const iconPreviewImageJS = document.getElementById('iconPreviewImageJS');
const iconPlaceholder = document.getElementById('iconPlaceholder');
const ipWhitelistInput = document.getElementById('ipWhitelistInput');
// Char counter init
if (textarea && counter) {
const max = textarea.getAttribute('maxlength') || 500;
counter.textContent = textarea.value.length + ' / ' + max;
// Restore existing IP whitelist
/*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
const ips = sessionIpWhitelist.split(',');
ips.forEach(function(ip) {
const trimmedIp = ip.trim();
if (trimmedIp && !ipAddresses.includes(trimmedIp)) {
ipAddresses.push(trimmedIp);
}
});
updateIpList();
}
/*]]>*/
textarea.addEventListener('input', function () {
counter.textContent = this.value.length + ' / ' + max;
counter.style.color = this.value.length > max * 0.9 ? '#f4253c' : '#94a3b8';
});
}
// Initialize character counter
if (appDescTextarea && charCounter) {
const currentLength = appDescTextarea.value.length;
const maxLength = appDescTextarea.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
// File input change
fileInput.addEventListener('change', function (e) {
appDescTextarea.addEventListener('input', function() {
const currentLength = this.value.length;
const maxLength = this.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
if (currentLength > maxLength * 0.9) {
charCounter.style.color = '#FF6B6B';
} else {
charCounter.style.color = '#94A3B8';
}
});
}
// App icon preview
if (appIconInput) {
appIconInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
if (file.size > 2 * 1024 * 1024) {
customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
this.value = '';
return;
}
if (!['image/png', 'image/jpeg', 'image/jpg', 'image/gif'].includes(file.type)) {
customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
this.value = '';
return;
}
const reader = new FileReader();
reader.onload = function (ev) {
if(preview1) preview1.style.display = 'none';
if(preview2) preview2.style.display = 'none';
previewJS.src = ev.target.result;
previewJS.style.display = 'block';
placeholder.style.display = 'none';
document.getElementById('btnRemoveIcon').style.display = 'flex';
};
reader.readAsDataURL(file);
});
// Drag & drop
const dropZone = document.getElementById('iconDropZone');
if (dropZone) {
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
this.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', function () {
this.classList.remove('drag-over');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
this.classList.remove('drag-over');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event('change'));
}
});
}
// IP input Enter key
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
});
// Form submit validation
form.addEventListener('submit', function (e) {
const name = document.getElementById('appName').value.trim();
const desc = textarea.value.trim();
const url = document.getElementById('callbackUrl').value.trim();
if (!name) {
e.preventDefault();
customPopups.showAlert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus();
return;
}
if (!desc) {
e.preventDefault();
customPopups.showAlert('앱 설명을 입력해주세요.');
textarea.focus();
return;
}
if (url) {
try { new URL(url); } catch (_) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
document.getElementById('callbackUrl').focus();
if (file) {
if (file.size > 2 * 1024 * 1024) {
customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
this.value = '';
return;
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
this.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(event) {
// Hide other previews
document.querySelectorAll('img[id^="iconPreviewImage"]').forEach(img => img.style.display = 'none');
iconPreviewImageJS.src = event.target.result;
iconPreviewImageJS.style.display = 'block';
iconPlaceholder.style.display = 'none';
btnRemoveIcon.style.display = 'flex';
};
reader.readAsDataURL(file);
}
});
}
// IP input Enter key
if (ipWhitelistInput) {
ipWhitelistInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addIpAddress();
}
});
}
// Form validation
form.addEventListener('submit', function(e) {
const appName = document.getElementById('appName').value.trim();
const appDescription = document.getElementById('appDescription').value.trim();
const callbackUrl = document.getElementById('callbackUrl').value.trim();
if (!appName) {
e.preventDefault();
customPopups.showAlert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus();
return false;
}
if (!appDescription) {
e.preventDefault();
customPopups.showAlert('앱 설명을 입력해주세요.');
document.getElementById('appDescription').focus();
return false;
}
if (callbackUrl && callbackUrl.trim() !== '') {
try {
new URL(callbackUrl);
} catch (error) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.');
document.getElementById('callbackUrl').focus();
return false;
}
}
});
</script>
</th:block>
});
</script>
</th:block>
</body>
</html>
@@ -6,201 +6,201 @@
<body>
<section layout:fragment="title">
<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>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<section class="apikey-register-container">
<!-- Left Sidebar (Service Sidebar) -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">앱 수정</h2>
<span class="common-subtitle">서비스 중인 앱 정보를 수정합니다.</span>
</div>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">앱 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 앱 정보입력 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2 Active: API 선택 (chain link icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<!-- 체인링크/API 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 로켓 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span>
</div>
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 (Completed) -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" width="36" height="36">
</div>
</div>
<div class="step-label">앱 정보입력</div>
</div>
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
<!-- Dots between Step 1 and 2 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 (Active) -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<div class="step-label">API 선택</div>
</div>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="s2-form">
<!-- Dots between Step 2 and 3 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
<!-- Step 3: 앱 수정 완료 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="앱 수정 완료" width="36" height="36">
</div>
</div>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="modifyStep2Form" class="s2-btn-save">
다음
</button>
</div>
</div><!-- /step2-wrap -->
<div class="step-label">앱 수정 완료</div>
</div>
</div>
</div>
</div>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
<span th:text="${error}"></span>
</div>
<!-- Form Content with Sidebar Layout -->
<div class="register-form-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<!-- Sidebar Title -->
<div class="sidebar-title">API 서비스 목록</div>
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
<span class="menu-text">전체</span>
<span class="api-count"></span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title"
th:data-group="${service.id}">
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
<span class="api-count"></span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="apikey-register-content">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="register-form">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Search Box with Select All -->
<div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
<span class="custom-checkbox"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state (initially hidden) -->
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
<!-- API cards will be dynamically loaded here -->
</div>
</form>
</main>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
이전
</button>
<button type="submit" form="modifyStep2Form" class="btn-submit btn-primary">
저장
</button>
</div>
</section>
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<span class="cart-label">선택된 API</span>
<span class="cart-badge" id="cartBadge">
<span class="cart-count">0</span>
<span class="cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
<!-- Selected APIs Modal - Uses base modal component -->
<div class="modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">선택된 API 목록</h3>
<button type="button" class="modal-close" id="modalCloseBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<div class="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
@@ -212,7 +212,10 @@
// DOM Elements
const form = document.getElementById('modifyStep2Form');
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
@@ -223,7 +226,7 @@
let allLoadedApis = []; // Store ALL APIs from server for modal display
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session
// Restore selected APIs from session (for modification)
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
@@ -233,11 +236,11 @@
// Load all APIs for modal display (used when APIs from different categories are selected)
function loadAllApisForModal() {
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
fetch(baseUrl)
fetch('/apis/for_request')
.then(response => response.json())
.then(apis => {
allLoadedApis = apis;
console.log('Loaded', allLoadedApis.length, 'APIs for modal display');
})
.catch(error => {
console.error('Failed to load all APIs for modal:', error);
@@ -251,11 +254,10 @@
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
@@ -266,11 +268,9 @@
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
@@ -279,7 +279,6 @@
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
@@ -304,7 +303,7 @@
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-card';
card.className = 'api-selection-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
@@ -320,25 +319,26 @@
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<div class="api-card-content">
<div class="api-card-header">
<span class="api-card-category">${api.service || '카테고리'}</span>
<div class="checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="custom-checkbox"></span>
</div>
</div>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
<div class="api-card-icon">
${mainIconHtml}
</div>
</div>
`;
@@ -347,11 +347,23 @@
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
const checkboxes = document.querySelectorAll('.api-checkbox');
const apiCards = document.querySelectorAll('.api-selection-card');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
});
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
// Prevent double toggle if clicking directly on checkbox
if (e.target.classList.contains('api-checkbox')) {
return;
}
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
@@ -359,28 +371,11 @@
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-card');
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
@@ -394,7 +389,7 @@
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.s2-cart-count');
const cartCount = document.querySelector('.cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
@@ -430,7 +425,7 @@
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
@@ -438,7 +433,7 @@
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
@@ -459,21 +454,21 @@
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
return;
}
// Build list using selectedApis Set for consistency
selectedApis.forEach(function(apiId) {
// Try to find the checkbox in the DOM first
const checkbox = document.querySelector('.s2-api-checkbox[value="' + apiId + '"]');
const checkbox = document.querySelector('.api-checkbox[value="' + apiId + '"]');
let apiName = apiId; // Default to ID if we can't find the name
if (checkbox) {
// If checkbox exists in DOM, get the name from the card
const card = checkbox.closest('.s2-api-card');
const card = checkbox.closest('.api-selection-card');
if (card) {
const nameElement = card.querySelector('.s2-api-card-title');
const nameElement = card.querySelector('.api-name');
if (nameElement) {
apiName = nameElement.textContent;
}
@@ -482,7 +477,7 @@
// If checkbox not in DOM (different category is showing),
// try to find the API in our loaded data
let api = allApis.find(a => a.apiId === apiId);
if (!api && allLoadedApis && allLoadedApis.length > 0) {
if (!api && allLoadedApis.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
@@ -491,19 +486,24 @@
}
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}"></button>
<span class="api-pill-name">${apiName}</span>
<button type="button" class="api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
@@ -523,8 +523,7 @@
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
@@ -534,20 +533,17 @@
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab selection
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
@@ -561,7 +557,7 @@
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim();
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
@@ -570,41 +566,52 @@
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
// Mobile menu toggle
function closeMobileMenu() {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
@@ -673,10 +680,10 @@
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
@@ -693,8 +700,9 @@
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/modify/step2/save}]]*/ '/myapikey/modify/step2/save';
// Change form action to save endpoint
const originalAction = form.action;
form.action = '/myapikey/modify/step2/save';
// Submit the form to save selections
form.submit();
@@ -703,6 +711,7 @@
// Form validation
form.addEventListener('submit', function(e) {
// API 선택 필수 검증
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
@@ -711,12 +720,18 @@
});
// Initialize
// Load all APIs for modal display FIRST (async)
loadAllApisForModal();
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
// Log restored selections for debugging
if (selectedApis.size > 0) {
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
}
});
</script>
</th:block>
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
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>
<section layout:fragment="title">
@@ -11,187 +10,113 @@
<h1>앱관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<section class="apikey-register-container">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">앱 수정</h2>
</div>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">앱 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 앱 정보수정 (form/input icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 (chain link icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 체인링크/API 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3 Active: 앱 수정 완료 (rocket icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<!-- 로켓 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span>
</div>
<div class="register-progress">
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" class="step-icon-img">
</div>
</div>
<div class="step-label">앱정보입력</div>
</div>
<!-- Success Result Section -->
<div class="s3-result-container" th:if="${modificationComplete}">
<div class="s3-graphic-wrapper">
<!-- Custom premium isometric server check success SVG design -->
<svg class="s3-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="glow-grad" x1="250" y1="40" x2="250" y2="360" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ba4ed" stop-opacity="0.3"/>
<stop offset="1" stop-color="#2a69de" stop-opacity="0"/>
</linearGradient>
<linearGradient id="cube-grad-1" x1="180" y1="120" x2="250" y2="280" gradientUnits="userSpaceOnUse">
<stop stop-color="#50caff" />
<stop offset="1" stop-color="#2a69de" />
</linearGradient>
<linearGradient id="cube-grad-2" x1="250" y1="120" x2="320" y2="280" gradientUnits="userSpaceOnUse">
<stop stop-color="#2a69de" />
<stop offset="1" stop-color="#1b4ab7" />
</linearGradient>
<filter id="drop-shadow" x="100" y="80" width="300" height="260" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#2a69de" flood-opacity="0.25"/>
</filter>
</defs>
<!-- Outer Glow -->
<circle cx="250" cy="200" r="150" fill="url(#glow-grad)" />
<!-- Main Isometric Success Concept Group -->
<g filter="url(#drop-shadow)" class="floating-slow">
<!-- Server/App isometric block -->
<!-- Top Face -->
<path d="M250 110L340 155L250 200L160 155Z" fill="#E9F5FF" stroke="#3ba4ed" stroke-width="3" stroke-linejoin="round"/>
<!-- Left Face -->
<path d="M160 155V245L250 290V200Z" fill="url(#cube-grad-1)" stroke="#2a69de" stroke-width="3" stroke-linejoin="round"/>
<!-- Right Face -->
<path d="M250 200V290L340 245V155Z" fill="url(#cube-grad-2)" stroke="#1b4ab7" stroke-width="3" stroke-linejoin="round"/>
<!-- Isometric Grid lines / connection details on Top Face -->
<path d="M205 132.5L250 155L295 132.5" stroke="#3ba4ed" stroke-width="2" stroke-linecap="round"/>
<path d="M250 155V200" stroke="#3ba4ed" stroke-width="2" stroke-linecap="round"/>
<!-- Glowing Code/Key lines on Isometric block -->
<path d="M190 190L220 205" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
<path d="M190 215L220 230" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
<path d="M280 205L310 190" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
<path d="M280 230L310 215" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
</g>
<!-- Success Badge orbiting the isometric app block -->
<g class="floating-fast">
<!-- Circle badge outer border -->
<circle cx="250" cy="180" r="42" fill="#ffffff" stroke="#10b981" stroke-width="5" filter="drop-shadow(0px 8px 16px rgba(16, 185, 129, 0.2))" />
<circle cx="250" cy="180" r="32" fill="#10b981" />
<!-- Checkmark path -->
<path d="M238 180L246 188L262 172" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
</g>
</svg>
</div>
<!-- Dots between Step 1 and 2 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1>
<p class="s3-success-desc">
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br>
<span class="s3-highlight">[앱 정보]</span> 화면에서 수정 내역을 확인할 수 있습니다.
</p>
</div>
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
완료
</a>
<!-- Step 2: API 선택 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" class="step-icon-img">
</div>
</div>
<div class="step-label">API 선택</div>
</div>
<!-- Error Result Section (Fallback) -->
<div class="s3-result-container s3-result-container--error" th:unless="${modificationComplete}">
<div class="s3-graphic-wrapper">
<div class="s3-error-icon"></div>
</div>
<div class="s3-message-wrapper">
<h1 class="s3-error-title">앱 수정 실패</h1>
<p class="s3-error-desc">
앱 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p>
</div>
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
목록으로
</a>
<!-- Dots between Step 2 and 3 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 3: 앱 수정 완료 (Active) -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="앱 수정 완료" class="step-icon-img">
</div>
</div>
</div><!-- /step3-wrap -->
<div class="step-label">앱 수정 완료</div>
</div>
</div>
</div>
</div>
<!-- Success Result -->
<div class="register-result success" th:if="${modificationComplete}">
<!-- Result Box (모바일에서 배경 박스) -->
<div class="result-box">
<!-- Success Icon -->
<div class="result-icon-wrapper">
<div class="result-icon success-icon">
<img th:src="@{/img/img_app_complete.png}" alt="완료">
</div>
</div>
<!-- Success Message -->
<div class="result-header">
<h1>앱 수정이 완료되었습니다.</h1>
<p class="result-description">
<span class="text-gray">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br>
<span class="text-dark">[앱 정보]</span>
<span class="text-gray">화면에서 수정 내역을 확인할 수 있습니다.</span>
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="result-actions">
<a href="/myapikey" class="btn btn-primary">
완료
</a>
</div>
</div>
<!-- Error Result (if needed) -->
<div class="register-result error" th:unless="${modificationComplete}" style="display: none;">
<div class="result-icon-wrapper">
<div class="result-icon error-icon">
<span></span>
</div>
</div>
<div class="result-header">
<h1>앱 수정 실패</h1>
<p class="result-description">
앱 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p>
</div>
<div class="result-actions">
<a href="/myapikey" class="btn btn-secondary">
다시 시도
</a>
<a href="/myapikey" class="btn btn-primary">
목록으로
</a>
</div>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
File diff suppressed because one or more lines are too long
@@ -11,194 +11,183 @@
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<section class="apikey-register-container">
<!-- Left Sidebar (Service Sidebar) -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">앱 생성</h2>
<span class="common-subtitle">API를 이용하여 서비스할 앱 정보를 등록해주세요.</span>
</div>
<!-- Content Area -->
<div class="app-management-content">
<!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">앱생성</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 앱 정보 입력 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2 Active: API 선택 (chain link icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<!-- 체인링크/API 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 로켓 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
</div>
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 (Active) -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" width="36" height="36">
</div>
</div>
<div class="step-label">앱 정보입력</div>
</div>
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
<!-- Dots between Step 1 and 2 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<div class="step-label">API 선택</div>
</div>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="s2-form">
<!-- Dots between Step 2 and 3 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
<!-- Step 3: 앱 생성 완료 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="앱 생성 완료" width="36" height="36">
</div>
</div>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="registerStep2Form" class="s2-btn-save">
다음
</button>
</div>
</div><!-- /step2-wrap -->
<div class="step-label">앱 생성 완료</div>
</div>
</div>
</div>
</div>
<!-- Form Content with Sidebar Layout -->
<div class="register-form-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<!-- Sidebar Title -->
<div class="sidebar-title">API 서비스 목록</div>
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
<span class="menu-text">전체</span>
<span class="api-count"></span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title"
th:data-group="${service.id}">
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
<span class="api-count"></span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="apikey-register-content">
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="register-form">
<!-- Search Box with Select All -->
<div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
<span class="custom-checkbox"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state (initially hidden) -->
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
<!-- API cards will be dynamically loaded here -->
</div>
</form>
</main>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
이전
</button>
<button type="submit" form="registerStep2Form" class="btn-submit btn-primary">
저장
</button>
</div>
</section>
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<span class="cart-label">선택된 API</span>
<span class="cart-badge" id="cartBadge">
<span class="cart-count">0</span>
<span class="cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
<!-- Selected APIs Modal - Uses base modal component -->
<div class="modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">선택된 API 목록</h3>
<button type="button" class="modal-close" id="modalCloseBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<div class="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
@@ -210,7 +199,10 @@
// DOM Elements
const form = document.getElementById('registerStep2Form');
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
@@ -235,11 +227,10 @@
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
@@ -250,11 +241,9 @@
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
@@ -263,7 +252,6 @@
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
@@ -282,10 +270,10 @@
attachCardEventListeners();
}
// Create API card element (Matches mainApiList.html exactly, card clicks toggle selection)
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-card';
card.className = 'api-selection-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
@@ -301,25 +289,26 @@
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<div class="api-card-content">
<div class="api-card-header">
<span class="api-card-category">${api.service || '카테고리'}</span>
<div class="checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="custom-checkbox"></span>
</div>
</div>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
<div class="api-card-icon">
${mainIconHtml}
</div>
</div>
`;
@@ -328,11 +317,23 @@
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
const checkboxes = document.querySelectorAll('.api-checkbox');
const apiCards = document.querySelectorAll('.api-selection-card');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
});
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
// Prevent double toggle if clicking directly on checkbox
if (e.target.classList.contains('api-checkbox')) {
return;
}
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
@@ -340,28 +341,11 @@
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-card');
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
@@ -375,7 +359,7 @@
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.s2-cart-count');
const cartCount = document.querySelector('.cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
@@ -411,7 +395,7 @@
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
@@ -419,7 +403,7 @@
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
@@ -440,30 +424,35 @@
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
return;
}
// Get all checked checkboxes
const checkedBoxes = document.querySelectorAll('.s2-api-checkbox:checked');
const checkedBoxes = document.querySelectorAll('.api-checkbox:checked');
checkedBoxes.forEach(function(checkbox) {
const card = checkbox.closest('.s2-api-card');
const apiName = card.querySelector('.s2-api-card-title').textContent;
const card = checkbox.closest('.api-selection-card');
const apiName = card.querySelector('.api-name').textContent;
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}"></button>
<span class="api-pill-name">${apiName}</span>
<button type="button" class="api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
@@ -478,8 +467,7 @@
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
@@ -489,20 +477,17 @@
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab selection
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
@@ -516,7 +501,7 @@
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim();
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
@@ -525,41 +510,52 @@
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
// Mobile menu toggle
function closeMobileMenu() {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
@@ -628,10 +624,10 @@
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
@@ -648,8 +644,9 @@
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/register/step2/save}]]*/ '/myapikey/register/step2/save';
// Change form action to save endpoint
const originalAction = form.action;
form.action = '/myapikey/register/step2/save';
// Submit the form to save selections
form.submit();
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
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>
<section layout:fragment="title">
@@ -11,192 +10,121 @@
<h1>앱관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<section class="apikey-register-container">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">앱 생성</h2>
</div>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">앱생성</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 앱 정보 입력 (form/input icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 (chain link icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 체인링크/API 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3 Active: 앱 생성 완료 (rocket icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<!-- 로켓 아이콘 -->
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
</div>
<div class="register-progress">
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" class="step-icon-img">
</div>
</div>
<div class="step-label">앱정보입력</div>
</div>
<!-- Success Result Section -->
<div class="s3-result-container" th:if="${registrationComplete}">
<div class="s3-graphic-wrapper">
<!-- Custom premium isometric server check success SVG design -->
<svg class="s3-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="glow-grad" x1="250" y1="40" x2="250" y2="360" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ba4ed" stop-opacity="0.3"/>
<stop offset="1" stop-color="#2a69de" stop-opacity="0"/>
</linearGradient>
<linearGradient id="cube-grad-1" x1="180" y1="120" x2="250" y2="280" gradientUnits="userSpaceOnUse">
<stop stop-color="#50caff" />
<stop offset="1" stop-color="#2a69de" />
</linearGradient>
<linearGradient id="cube-grad-2" x1="250" y1="120" x2="320" y2="280" gradientUnits="userSpaceOnUse">
<stop stop-color="#2a69de" />
<stop offset="1" stop-color="#1b4ab7" />
</linearGradient>
<filter id="drop-shadow" x="100" y="80" width="300" height="260" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#2a69de" flood-opacity="0.25"/>
</filter>
</defs>
<!-- Outer Glow -->
<circle cx="250" cy="200" r="150" fill="url(#glow-grad)" />
<!-- Main Isometric Success Concept Group -->
<g filter="url(#drop-shadow)" class="floating-slow">
<!-- Server/App isometric block -->
<!-- Top Face -->
<path d="M250 110L340 155L250 200L160 155Z" fill="#E9F5FF" stroke="#3ba4ed" stroke-width="3" stroke-linejoin="round"/>
<!-- Left Face -->
<path d="M160 155V245L250 290V200Z" fill="url(#cube-grad-1)" stroke="#2a69de" stroke-width="3" stroke-linejoin="round"/>
<!-- Right Face -->
<path d="M250 200V290L340 245V155Z" fill="url(#cube-grad-2)" stroke="#1b4ab7" stroke-width="3" stroke-linejoin="round"/>
<!-- Isometric Grid lines / connection details on Top Face -->
<path d="M205 132.5L250 155L295 132.5" stroke="#3ba4ed" stroke-width="2" stroke-linecap="round"/>
<path d="M250 155V200" stroke="#3ba4ed" stroke-width="2" stroke-linecap="round"/>
<!-- Glowing Code/Key lines on Isometric block -->
<path d="M190 190L220 205" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
<path d="M190 215L220 230" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
<path d="M280 205L310 190" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
<path d="M280 230L310 215" stroke="#ffffff" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
</g>
<!-- Success Badge orbiting the isometric app block -->
<g class="floating-fast">
<!-- Circle badge outer border -->
<circle cx="250" cy="180" r="42" fill="#ffffff" stroke="#10b981" stroke-width="5" filter="drop-shadow(0px 8px 16px rgba(16, 185, 129, 0.2))" />
<circle cx="250" cy="180" r="32" fill="#10b981" />
<!-- Checkmark path -->
<path d="M238 180L246 188L262 172" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
</g>
</svg>
</div>
<!-- Dots between Step 1 and 2 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 생성이 완료되었습니다.</h1>
<p class="s3-success-desc">
담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서
<br>
<span class="s3-highlight">Client ID와 Client Secret을 확인</span>할 수 있습니다.
</p>
</div>
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
완료
</a>
<!-- Step 2: API 선택 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" class="step-icon-img">
</div>
</div>
<div class="step-label">API 선택</div>
</div>
<!-- Error Result Section (Fallback) -->
<div class="s3-result-container s3-result-container--error" th:unless="${registrationComplete}">
<div class="s3-graphic-wrapper">
<div class="s3-error-icon"></div>
</div>
<div class="s3-message-wrapper">
<h1 class="s3-error-title">앱 생성 실패</h1>
<p class="s3-error-desc">
앱 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p>
</div>
<div class="s3-actions">
<a href="/myapikey/register/step1" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
목록으로
</a>
<!-- Dots between Step 2 and 3 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 3: 앱 생성 완료 (Active) -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="앱 생성 완료" class="step-icon-img">
</div>
</div>
</div><!-- /step3-wrap -->
<div class="step-label">앱 생성 완료</div>
</div>
</div>
</div>
</div>
<!-- Success Result -->
<div class="register-result success" th:if="${registrationComplete}">
<!-- Result Box (모바일에서 배경 박스) -->
<div class="result-box">
<!-- Success Icon -->
<div class="result-icon-wrapper">
<div class="result-icon success-icon">
<img th:src="@{/img/img_app_complete.png}" alt="완료">
</div>
</div>
<!-- Success Message -->
<div class="result-header">
<h1>앱 생성이 완료되었습니다.</h1>
<p class="result-description">
<span class="text-gray">담당자 앱 승인 후</span>
<span class="text-dark">[앱 정보]</span>
<span class="text-gray">화면에서</span>
<br>
<span class="text-dark">Client ID와 Client Secret을 확인</span>
<span class="text-gray">할 수 있습니다.</span>
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="result-actions">
<a href="/myapikey" class="btn btn-primary">
완료
</a>
</div>
</div>
<!-- Error Result (if needed) -->
<div class="register-result error" th:unless="${registrationComplete}" style="display: none;">
<div class="result-icon-wrapper">
<div class="result-icon error-icon">
<span></span>
</div>
</div>
<div class="result-header">
<h1>API Key 생성 실패</h1>
<p class="result-description">
API Key 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p>
</div>
<div class="result-actions">
<a href="/myapikey/register/step1" class="btn btn-secondary">
다시 시도
</a>
<a href="/myapikey" class="btn btn-primary">
목록으로
</a>
</div>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Clear session storage after successful registration
if (/*[[${registrationComplete}]]*/ false) {
sessionStorage.removeItem('registration_appName');
@@ -1,294 +1,253 @@
<!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">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">신청 상세</h2>
<span class="common-subtitle">신청하신 API Key의 상세 정보를 확인할 수 있습니다.</span>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Left Sidebar (Service Sidebar) -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<!-- 모든 스타일은 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>
<!-- App Basic Info Card -->
<div class="dt-app-card">
<!-- App Icon -->
<div class="dt-app-icon-box">
<img th:if="${appRequest.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${appRequest.appIconFileId})}" alt="앱 아이콘">
<svg th:unless="${appRequest.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>
</svg>
</div>
<!-- App Info -->
<div class="dt-app-info">
<!-- Status Badge line (Only for Approved or In Progress states) -->
<th:block th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and (appRequest.approval.approvalStatus.toString() == 'APPROVED' or appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중')}">
<div class="dt-app-status-row">
<span th:if="${appRequest.approval.approvalStatus.toString() == 'APPROVED'}" class="dt-status-badge status-approved">답변완료</span>
<span th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중'}" class="dt-status-badge status-pending">진행중</span>
</div>
</th:block>
<h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3>
<!-- App Description -->
<p class="dt-app-desc"
th:text="${appRequest.appDescription != null ? appRequest.appDescription : '앱 설명이 없습니다.'}">
여기에 입력한 앱 설명이 표시됩니다.
</p>
</div>
</div>
<!-- Request Info Section Card -->
<div class="dt-info-container">
<!-- Row 1: Client ID & Client Secret (only if approved) -->
<div class="dt-row" th:if="${appRequest.clientId != null}">
<div class="dt-col">
<label class="dt-label">Client ID</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${appRequest.clientId}">K00000001</div>
<button type="button" class="dt-btn-copy"
th:data-secret="${appRequest.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>
<span>복사</span>
</button>
</div>
</div>
<div class="dt-col">
<label class="dt-label">Client Secret</label>
<div class="dt-input-group">
<div class="dt-input-box text-secret">••••••••••••••••</div>
<button type="button" class="dt-btn-copy"
onclick="alert('보안을 위해 Client Secret은 승인 완료된 인증키 상세 메뉴에서 본인 비밀번호 확인 후 조회가 가능합니다.')">
<span>안내</span>
</button>
</div>
</div>
</div>
<!-- Callback URL -->
<div class="dt-row" th:if="${appRequest.clientId != null}">
<div class="dt-col">
<label class="dt-label">Callback URL</label>
<div class="dt-input-group">
<div class="dt-input-box"
th:text="${appRequest.callbackUrl != null ? appRequest.callbackUrl : '설정되지 않음'}">
http://www.abcd.co.kr
</div>
</div>
</div>
</div>
<!-- 화이트리스트 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">화이트리스트</label>
<div class="dt-input-group">
<div class="dt-input-box"
th:text="${appRequest.ipWhitelist != null and !appRequest.ipWhitelist.isEmpty() ? appRequest.ipWhitelist : '설정되지 않음'}">
설정되지 않음
</div>
</div>
</div>
</div>
<!-- API 목록 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">API 목록</label>
<div class="dt-api-list-box">
<div class="dt-api-item" th:each="api : ${appRequest.apiSpecList}">
<span class="dt-api-name" th:text="${api.apiName}">추가된 API 1</span>
<span class="dt-api-status-badge"
th:classappend="${appRequest.approval != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '' : 'status-pending'}"
th:text="${appRequest.approval != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '승인' : '승인대기'}">
승인대기
</span>
</div>
<div class="dt-empty-message"
th:if="${appRequest.apiSpecList == null or appRequest.apiSpecList.isEmpty()}">
선택된 API가 없습니다.
</div>
</div>
</div>
</div>
<!-- 신청일시 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">신청일시</label>
<div class="dt-input-group">
<div class="dt-input-box"
th:text="${#temporals.format(appRequest.createdDate, 'yyyy.MM.dd HH:mm')}">
2025.11.22 12:00
</div>
</div>
</div>
</div>
<!-- 예상 완료일 (Only if REQUESTED or 진행중) -->
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and (appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중')}">
<div class="dt-col">
<label class="dt-label">예상 완료일</label>
<div class="dt-input-group">
<div class="dt-input-box"
th:text="${appRequest.createdDate != null ? #temporals.format(appRequest.createdDate.plusDays(14), 'yyyy.MM.dd') + ' (' + #temporals.format(appRequest.createdDate.plusDays(14), 'E') + ')' : 'N/A'}">
2026.07.14 (화)
</div>
</div>
</div>
</div>
<!-- 승인일시 (Only if APPROVED) -->
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' and appRequest.approval.approvalDate != null}">
<div class="dt-col">
<label class="dt-label">승인일시</label>
<div class="dt-input-group">
<div class="dt-input-box"
th:text="${#temporals.format(appRequest.approval.approvalDate, 'yyyy.MM.dd HH:mm')}">
2025.11.22 12:00
</div>
</div>
</div>
</div>
<!-- 사유 (if exists) -->
<div class="dt-row"
th:if="${appRequest.approval != null and appRequest.reason != null and !appRequest.reason.isEmpty()}">
<div class="dt-col">
<label class="dt-label">사유</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${appRequest.reason}">
사유 내용
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Navigation Actions -->
<div class="dt-actions">
<!-- Cancel Request Button (danger red) -->
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED' or appRequest.approval.approvalStatus.description == '진행중')}"
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
신청 취소
</button>
<!-- List Button (gray) -->
<a th:href="@{/myapikey}" class="dt-btn-gray">
목록
</a>
</div>
</div><!-- /detail-wrap -->
<!-- App Basic Info Card -->
<div class="app-info-card">
<!-- App Icon -->
<div class="app-info-icon">
<img th:if="${appRequest.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${appRequest.appIconFileId})}"
alt="앱 아이콘"
class="app-icon-image">
<div th:unless="${appRequest.appIconFileId != null}" class="app-icon-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</svg>
</div>
</div>
<!-- App Info -->
<div class="app-info-content">
<!-- Status Badge -->
<span class="app-status-badge"
th:if="${appRequest.approval != null}"
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' :
(appRequest.approval.approvalStatus.toString() == 'REJECTED' ? 'status-rejected' : 'status-pending')}"
th:text="${appRequest.approval.approvalStatus.description}">
대기중
</span>
<!-- App Name -->
<h3 class="app-info-name" th:text="${appRequest.clientName}">앱 이름</h3>
<!-- App Description -->
<p class="app-info-description"
th:text="${appRequest.appDescription != null ? appRequest.appDescription : '앱 설명이 없습니다.'}">
여기에 입력한 앱 설명이 표시됩니다.
</p>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
/*<![CDATA[*/
<!-- Request Info Section -->
<div class="apikey-info-section">
// Copy to clipboard function
function copyToClipboardFromButton(button) {
var text = $(button).data('secret');
<!-- Client ID (if exists) -->
<div class="info-row info-row-with-action" th:if="${appRequest.clientId != null}">
<label class="info-label">Client ID</label>
<div class="info-value">
<div class="info-value-box with-copy" th:text="${appRequest.clientId}">K00000001</div>
<button type="button" class="btn-copy-action" th:data-secret="${appRequest.clientId}" onclick="copyToClipboardFromButton(this)">
+ 복사
</button>
</div>
</div>
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
alert('클립보드에 복사되었습니다.');
}).catch(function (err) {
console.error('Failed to copy:', err);
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
<!-- Callback URL (주석 처리) -->
<!--/*
<div class="info-row">
<label class="info-label">Callback URL</label>
<div class="info-value">
<div class="info-value-box" th:text="${appRequest.callbackUrl != null ? appRequest.callbackUrl : '설정되지 않음'}">
http://www.abcd.co.kr
</div>
</div>
</div>
*/-->
function fallbackCopy(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
<!-- IP Whitelist -->
<div class="info-row">
<label class="info-label">화이트리스트</label>
<div class="info-value">
<div class="info-value-box" th:text="${appRequest.ipWhitelist != null and !appRequest.ipWhitelist.isEmpty() ? appRequest.ipWhitelist : '설정되지 않음'}">
10.0.0.1, 10.0.0.2, 192.168.123.1
</div>
</div>
</div>
<!-- Selected APIs -->
<div class="info-row info-row-vertical">
<label class="info-label">API 목록</label>
<div class="info-value">
<div class="api-list-box">
<div class="api-list-item" th:each="api : ${appRequest.apiSpecList}">
<span class="api-item-name" th:text="${api.apiName}">추가된 API 1</span>
<span class="api-item-status">승인대기</span>
</div>
<!-- Empty State -->
<div class="empty-state" th:if="${appRequest.apiSpecList == null or appRequest.apiSpecList.isEmpty()}" style="padding: 24px;">
<p>선택된 API가 없습니다.</p>
</div>
</div>
</div>
</div>
<!-- Created Date -->
<div class="info-row">
<label class="info-label">신청일시</label>
<div class="info-value">
<div class="info-value-box" th:text="${#temporals.format(appRequest.createdDate, 'yyyy.MM.dd HH:mm')}">
2025.11.22 12:00
</div>
</div>
</div>
<!-- Expected Completion Date (if set by admin) -->
<div class="info-row"
th:if="${appRequest.approval != null and appRequest.approval.expectEndDate != null and #strings.length(appRequest.approval.expectEndDate) >= 8}">
<label class="info-label">예상 완료일</label>
<div class="info-value">
<div class="info-value-box"
th:text="|${#strings.substring(appRequest.approval.expectEndDate,0,4)}.${#strings.substring(appRequest.approval.expectEndDate,4,6)}.${#strings.substring(appRequest.approval.expectEndDate,6,8)} (${appRequest.approval.expectEndDateDayOfWeek})|">
2025.05.30 (토)
</div>
</div>
</div>
<!-- Approval Date (if approved) -->
<div class="info-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<label class="info-label">승인일시</label>
<div class="info-value">
<div class="info-value-box" th:text="${#temporals.format(appRequest.approval.approvalDate, 'yyyy.MM.dd HH:mm')}">
2025.11.22 12:00
</div>
</div>
</div>
<!-- Reason (if exists) -->
<div class="info-row" th:if="${appRequest.approval != null and appRequest.reason != null and !appRequest.reason.isEmpty()}">
<label class="info-label">사유</label>
<div class="info-value">
<div class="info-value-box" th:text="${appRequest.reason}">
Approval reason
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions-center">
<button type="button"
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn btn-submit btn-danger"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}"
th:data-request-id="${appRequest.id}"
onclick="cancelRequestById(this)">
신청 취소
</button>
<a th:href="@{/myapikey}" class="btn btn-submit btn-secondary">
목록
</a>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
/*<![CDATA[*/
// Copy to clipboard function
function copyToClipboardFromButton(button) {
var text = $(button).data('secret');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
alert('클립보드에 복사되었습니다.');
} catch (err) {
alert('복사에 실패했습니다.');
}
document.body.removeChild(textArea);
}
// Cancel request function - called from button with data-request-id attribute
function cancelRequestById(button) {
var requestId = $(button).data('request-id');
if (!confirm('정말로 이 신청을 취소하시겠습니까?')) {
return;
}
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request/cancel',
type: 'POST',
data: { id: requestId },
dataType: 'json',
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
} else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide();
}
}).fail(function (xhr, status, error) {
alert('신청 취소 중 오류가 발생했습니다.');
console.error('Cancel error:', error);
$('.loading-overlay').hide();
}).catch(function(err) {
console.error('Failed to copy:', err);
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
alert('클립보드에 복사되었습니다.');
} catch (err) {
alert('복사에 실패했습니다.');
}
document.body.removeChild(textArea);
}
// Cancel request function - called from button with data-request-id attribute
function cancelRequestById(button) {
var requestId = $(button).data('request-id');
if (!confirm('정말로 이 신청을 취소하시겠습니까?')) {
return;
}
/*]]>*/
</script>
</th:block>
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request/cancel',
type: 'POST',
data: {id: requestId},
dataType: 'json',
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response.success) {
alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
} else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide();
}
}).fail(function(xhr, status, error) {
alert('신청 취소 중 오류가 발생했습니다.');
console.error('Cancel error:', error);
$('.loading-overlay').hide();
});
}
/*]]>*/
</script>
</th:block>
</body>
</html>
</html>
@@ -14,149 +14,163 @@
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<section class="apikey-register-container">
<!-- Left Sidebar (Service Sidebar) -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">인증키 정보</h2>
<span class="common-subtitle">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div>
<!-- Content Area -->
<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>
<!-- App Basic Info Card -->
<div class="app-info-card">
<!-- App Icon -->
<div class="app-info-icon">
<img th:if="${apiKey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}"
alt="앱 아이콘"
class="app-icon-image">
<div th:unless="${apiKey.appIconFileId != null}" class="app-icon-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</svg>
</div>
</div>
<!-- App Info -->
<div class="app-info-content">
<!-- Status Badge -->
<span class="app-status-badge"
th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}"
th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">
승인
</span>
<!-- App Name -->
<h3 class="app-info-name" th:text="${apiKey.clientname}">앱 이름</h3>
<!-- App Description -->
<p class="app-info-description"
th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">
여기에 입력한 앱 설명이 표시됩니다.
</p>
</div>
</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">
<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>
</svg>
</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>
</div>
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
<p class="dt-app-desc" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
</div>
</div>
<!-- Credential Info Section -->
<div class="apikey-info-section">
<!-- Credential Info Section Card -->
<div class="dt-info-container">
<!-- Client ID -->
<div class="dt-row">
<div class="dt-col">
<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">
<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>
</div>
</div>
<!-- Client ID -->
<div class="info-row info-row-with-action">
<label class="info-label">Client ID</label>
<div class="info-value">
<div class="info-value-box with-copy" th:text="${apiKey.clientid}">K00000001</div>
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientid}" onclick="copyToClipboardFromButton(this)">
+ 복사
</button>
</div>
</div>
<!-- Client Secret -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">Client Secret</label>
<!-- 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;">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<span>조회</span>
</button>
</div>
<!-- Client Secret -->
<div class="info-row">
<label class="info-label">Client Secret</label>
<div class="info-value">
<!-- View Button (shown initially) -->
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
<span>조회</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</button>
<!-- Actual Secret (hidden initially) -->
<div id="revealedSecretBox" style="display: none;">
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
<div class="info-value-box with-copy" th:text="${apiKey.clientsecret}">secret-key-67890</div>
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
+ 복사
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Actual Secret (hidden initially) -->
<div id="revealedSecretBox" style="display: none; width: 100%;">
<div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.clientsecret}" style="flex: 1;">secret-key</div>
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientsecret}" 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>
</div>
</div>
</div>
<!-- Callback URL (주석 처리) -->
<!--/*
<div class="info-row">
<label class="info-label">Callback URL</label>
<div class="info-value">
<div class="info-value-box" th:text="${apiKey.redirecturi != null ? apiKey.redirecturi : '설정되지 않음'}">
http://www.abcd.co.kr
</div>
</div>
</div>
*/-->
<!-- Callback URL -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">Callback URL</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.redirecturi != null and !apiKey.redirecturi.isEmpty() ? apiKey.redirecturi : '설정되지않음'}">http://www.abcd.co.kr</div>
</div>
</div>
</div>
<!-- IP Whitelist -->
<div class="info-row">
<label class="info-label">화이트리스트</label>
<div class="info-value">
<div class="info-value-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">
10.0.0.1, 10.0.0.2, 192.168.123.1
</div>
</div>
</div>
<!-- 화이트 리스트 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">화이트 리스트</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.allowedips != null and !apiKey.allowedips.isEmpty() ? apiKey.allowedips : '설정되지않음'}">설정되지않음</div>
</div>
</div>
</div>
<!-- Registered APIs -->
<div class="info-row info-row-vertical">
<label class="info-label">API 목록</label>
<div class="info-value">
<div class="api-list-box">
<div class="api-list-item" th:each="api : ${apiKey.apiList}">
<span class="api-item-name" th:text="${api.apiDesc}">추가된 API 1</span>
<span class="api-item-status">승인</span>
</div>
<!-- API 목록 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">API 목록</label>
<div class="dt-api-list-box">
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
<span class="dt-api-status-badge">승인</span>
</div>
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
등록된 API가 없습니다.
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div class="empty-state" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}" style="padding: 24px;">
<p>등록된 API가 없습니다.</p>
</div>
</div>
</div>
</div>
<!-- 최종 수정일자 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">최종 수정일자</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${apiKey.modifiedon != null ? #temporals.format(apiKey.modifiedon, 'yyyy.MM.dd') : 'N/A'}">2025.11.22</div>
</div>
</div>
</div>
</div>
<!-- Modified Date -->
<div class="info-row">
<label class="info-label">최종 수정일자</label>
<div class="info-value">
<div class="info-value-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">
2025.11.22
</div>
</div>
</div>
<!-- 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>
</div>
</div>
</div><!-- /detail-wrap -->
</div><!-- /app-management-content -->
</div><!-- /service-main -->
</div><!-- /signup-guide-v2 -->
<!-- Action Buttons -->
<div class="form-actions-center">
<a th:href="@{/myapikey}" class="btn btn-submit btn-secondary">
목록
</a>
<button type="button"
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn btn-submit btn-danger"
th:data-client-id="${apiKey.clientid}"
onclick="deleteApiKeyFromButton(this)">
인증키 삭제
</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn btn-submit btn-primary app-modify-btn"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">
수정
</a>
</div>
<p class="app-modify-pc-only-message" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
앱 수정은 PC 환경에서 이용해 주세요.
</p>
</section>
</th:block>
@@ -12,61 +12,49 @@
<h1>비밀번호 변경</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/mypage/change_new_password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">새 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="newPassword" class="form-input"
placeholder="새 비밀번호 입력" required>
</div>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">새 비밀번호 확인</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="confirmPassword" class="form-input"
placeholder="새 비밀번호 확인 입력" required>
</div>
</div>
<ul class="password-rules-list">
<li>영문 대문자, 소문자, 숫자, 특수문자 8~20자</li>
<li>아이디, 휴대전화, 3자리 이상연속, 반복문자 사용불가</li>
</ul>
</div>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
<button type="submit" class="btn-apply btn-primary">변경</button>
</div>
</div>
</form>
</div>
<section layout:fragment="contentFragment" class="content">
<div class="org-register-container">
<div class="org-section-header">
<h3>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</h3>
</div>
<form id="passwordChangeForm" th:action="@{/mypage/change_new_password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="org-form-container">
<div class="org-form-group">
<label class="org-form-label">
<span class="required">새 비밀번호</span>
</label>
<div class="org-form-input-wrapper">
<input type="password" name="newPassword" class="org-form-input"
placeholder="새 비밀번호 입력" required>
</div>
</div>
<div class="org-form-group">
<label class="org-form-label">
<span class="required">새 비밀번호 확인</span>
</label>
<div class="org-form-input-wrapper">
<input type="password" name="confirmPassword" class="org-form-input"
placeholder="새 비밀번호 확인 입력" required>
</div>
</div>
</div>
<div class="org-info-notice">
<ul>
<li>영문 대문자, 소문자, 숫자, 특수문자 포함 8~20자</li>
<li>아이디, 휴대전화, 3자리 이상 연속, 반복문자 사용 불가</li>
</ul>
</div>
<div class="org-action-buttons">
<button type="button" class="btn btn-secondary" onclick="history.back()">취소</button>
<button type="submit" class="btn btn-primary">변경</button>
</div>
</form>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function() {
@@ -9,46 +9,32 @@
<h1>비밀번호 변경</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/verify_current_password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required>
</div>
</div>
</div>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
</form>
</div>
<section layout:fragment="contentFragment" class="content">
<div class="org-register-container">
<div class="org-section-header">
<h3>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</h3>
</div>
<form th:action="@{/verify_current_password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="org-form-container">
<div class="org-form-group">
<label class="org-form-label">
<span class="required">현재 비밀번호</span>
</label>
<div class="org-form-input-wrapper">
<input type="password" name="currentPassword" class="org-form-input"
placeholder="비밀번호 입력" required>
</div>
</div>
</div>
<div class="org-action-buttons">
<button type="button" class="btn btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn btn-primary">확인</button>
</div>
</form>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function() {
File diff suppressed because it is too large Load Diff
@@ -129,6 +129,14 @@
}
};
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
const submitOrgFields = [
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
'orgSectors', 'orgIndustryType'
];
// 이메일 유효성 검사 함수
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -177,7 +185,7 @@
}
});
fields.org.forEach(fieldId => {
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
@@ -375,6 +383,35 @@
return scenarioValidation && commonValidation;
}
// 미입력/미충족 항목을 구체적으로 수집
function collectFormErrors() {
const errors = [];
// 시나리오별 항목
if (registrationScenario === 'new') {
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
}
// 공통 항목
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
return errors;
}
let isSubmitting = false;
// 등록 버튼 클릭 이벤트
@@ -426,7 +463,10 @@
isPasswordMatch
});
let errorMessage = '모든 필수 항목을 입력하고 약관에 동의해주세요.'
const errors = collectFormErrors();
let errorMessage = errors.length > 0
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
// Reset submission state since validation failed
@@ -7,7 +7,7 @@
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
<!-- Agreement Section Header -->
<div class="org-section-header org-section-header--agreement">
<h3>약관 동의</h3>
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
<span class="required-badge">필수 동의</span>
</div>
@@ -32,16 +32,6 @@
</p>
</div>
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
<p class="info-text" style="margin: 0;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
<div class="result-info-box" style="background-color: #e7f3ff; border-color: #1a73e8; margin-top: 16px;">
<p class="info-text" style="margin: 0; color: #1a73e8;">
<i class="fas fa-info-circle"></i>
@@ -43,15 +43,6 @@
</p>
</div>
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
<p class="info-text" style="margin: 0;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
</div>
</div>
@@ -1,221 +1,156 @@
<!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_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2">
<!-- Hero Section -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/signUp_img.png}" alt="회원가입 소개 3D 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">회원가입 소개</span>
</div>
<h1 class="service-hero__title">회원 가입 안내</h1>
<p class="service-hero__desc">DJ Bank API 개발자 포털에 방문해 주셔서 감사합니다.<br>DJBank API 사용을 위해서는 다음과 같은
이용절차로 진행하여야 합니다.</p>
</div>
</div>
</section>
<!-- Main Content -->
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('guide')}"></th:block>
<!-- Content -->
<section class=" service-content">
<!-- Info Box -->
<div class="signup-info-box">
<div class="signup-info-box__icon">
<img th:src="@{/img/icon/icon_check_green.png}" alt="체크 아이콘" />
</div>
<ul class="signup-info-list">
<li>회원은 법인, 개인(개발자)분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
<li>포털 사용은 운영 담당자의 승인 후 가능합니다.</li>
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 직원(개발자)을 추가 등록해 주시기 바랍니다.</li>
</ul>
</div>
<!-- Vertical Timeline Steps -->
<div class="signup-timeline">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M20 21V20C20 18.8954 20.8954 18 22 18C23.1046 18 24 18.8954 24 20V21"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 01</div>
<div class="signup-step__card-title">회원가입</div>
</div>
</div>
<!-- Step 2 -->
<div class="signup-step">
<div class="signup-step__icon-box two">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
stroke="#0049B4" stroke -width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg> -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 02</div>
<div class="signup-step__card-title">승인</div>
</div>
</div>
<!-- Step 3 -->
<div class="signup-step">
<div class="signup-step__icon-box three">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-code" viewBox="0 0 16 16">
<path
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 03</div>
<div class="signup-step__card-title">API/APP 사용 신청</div>
</div>
</div>
<!-- Step 4 -->
<div class="signup-step">
<div class="signup-step__icon-box four">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16">
<path
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 04</div>
<div class="signup-step__card-title">개발/테스트</div>
</div>
</div>
<!-- Step 5 -->
<div class="signup-step">
<div class="signup-step__icon-box five">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"-->
<!-- xmlns="http://www.w3.org/2000/svg">-->
<!-- <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!-- <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />-->
<!-- <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!-- </svg>-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<path
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 05</div>
<div class="signup-step__card-title">운영신청 및 심사</div>
</div>
</div>
<!-- Step 6 -->
<div class="signup-step">
<div class="signup-step__icon-box six">
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-airplane" viewBox="0 0 16 16">
<path
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 06</div>
<div class="signup-step__card-title">서비스 개시(예약)</div>
</div>
</div>
</div>
<div class="signup-action">
<a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide">
<!-- Header Section -->
<div class="signup-guide__header">
<h1 class="signup-guide__title">회원 가입 안내</h1>
<div class="signup-guide__underline"></div>
<div class="signup-guide__desc">
<p>DJBank API 개발자 포털에 방문해 주셔서 감사합니다.</p>
<p>DJBank API 사용을 위해서는 다음과 같은 이용절차로 진행하여야 합니다.</p>
</div>
</div>
</th:block>
<!-- Step Process Card -->
<div class="signup-guide__card">
<div class="signup-guide__steps">
<!-- Step 1 -->
<div class="signup-guide__step">
<div class="signup-guide__step-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 21V20C20 18.8954 20.8954 18 22 18C23.1046 18 24 18.8954 24 20V21" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="signup-guide__step-dot"></div>
<div class="signup-guide__step-label">
<span class="signup-guide__step-num">Step1</span>
<span class="signup-guide__step-text">회원가입</span>
</div>
<div class="signup-guide__step-line"></div>
</div>
<!-- Step 2 -->
<div class="signup-guide__step">
<div class="signup-guide__step-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="signup-guide__step-dot"></div>
<div class="signup-guide__step-label">
<span class="signup-guide__step-num">Step2</span>
<span class="signup-guide__step-text">승인</span>
</div>
<div class="signup-guide__step-line"></div>
</div>
<!-- Step 3 -->
<div class="signup-guide__step">
<div class="signup-guide__step-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</div>
<div class="signup-guide__step-dot"></div>
<div class="signup-guide__step-label">
<span class="signup-guide__step-num">Step3</span>
<span class="signup-guide__step-text">API/APP 사용 신청</span>
</div>
<div class="signup-guide__step-line"></div>
</div>
<!-- Step 4 -->
<div class="signup-guide__step">
<div class="signup-guide__step-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="signup-guide__step-dot"></div>
<div class="signup-guide__step-label">
<span class="signup-guide__step-num">Step4</span>
<span class="signup-guide__step-text">개발/테스트</span>
</div>
<div class="signup-guide__step-line"></div>
</div>
<!-- Step 5 -->
<div class="signup-guide__step">
<div class="signup-guide__step-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="signup-guide__step-dot"></div>
<div class="signup-guide__step-label">
<span class="signup-guide__step-num">Step5</span>
<span class="signup-guide__step-text">운영신청 및 심사</span>
</div>
<div class="signup-guide__step-line"></div>
</div>
<!-- Step 6 -->
<div class="signup-guide__step signup-guide__step--last">
<div class="signup-guide__step-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</div>
<div class="signup-guide__step-dot"></div>
<div class="signup-guide__step-label">
<span class="signup-guide__step-num">Step6</span>
<span class="signup-guide__step-text">서비스 개시(예약)</span>
</div>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="signup-guide__notes">
<p class="signup-guide__note">
<span class="signup-guide__note-mark"></span>
<span>회원은 법인, 개인(개발자)분으로 구분되며, 개발자 포털에서 온라인으로 회원가입 신청을 합니다.</span>
</p>
<p class="signup-guide__note">
<span class="signup-guide__note-mark"></span>
<span>포털 사용은 운영 담당자의 승인 후 가능합니다.</span>
</p>
<p class="signup-guide__note">
<span class="signup-guide__note-mark"></span>
<span>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 직원(개발자)을 추가 등록해 주시기 바랍니다.</span>
</p>
</div>
<!-- Action Button -->
<div class="signup-guide__action">
<a th:href="@{/signup}" class="signup-guide__btn">회원가입 하러가기</a>
</div>
</div>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
</html>

Some files were not shown because too many files have changed in this diff Show More