22 Commits

Author SHA1 Message Date
Rinjae 41391c8df0 - Webhook 신청 및 관리 UI 전면 개편 - 신규 가이드 반영
- Webhook 수정/삭제 기능 UX 개선 - 단계별 디자인 업데이트
- Webhook 등록/수정 CSS 및 JS 로직 최적화 및 재사용 코드 추가
2026-07-20 19:53:54 +09:00
Rinjae 9f4874fe44 Merge branch 'feats/api-status' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
#	src/main/resources/static/css/main.min.css
#	src/main/resources/static/css/main.min.css.map
#	src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html
#	src/main/resources/templates/views/apps/mypage/apiKeyRegisterStep2.html
#	src/main/resources/templates/views/fragment/djbank/header_container.html
2026-07-20 19:13:38 +09:00
Rinjae 43d915342e 모니터링 코드 엔티티 제거 및 API 선택 모듈 추가:
- MonitoringCode 엔티티, 리포지토리, 복합키 클래스 삭제
- API 선택 공용 모듈 JS 및 HTML 파일 추가 (api_selector)
- 웹훅 개발 가이드 페이지 신규 추가 (webhook-dev-guide)
2026-07-20 19:10:05 +09:00
Rinjae ba00b80bfe Webhook 신청/관리 및 EventType 제공 기능 추가:
- Webhook 신청/수정 3단계 UI 및 관련 서비스 구현
- TSEAIRM28 공통코드 기반 EventType 조회 기능 추가
- 신규/수정 세션 관리 및 비밀번호 재인증 로직 포함
2026-07-20 17:36:17 +09:00
Rinjae 87763ba325 CSS 메인 파일 업데이트:
- "main.min.css"에서 디자인 스타일 및 애니메이션 수정 및 최적화.
2026-07-20 16:48:12 +09:00
Rinjae 5b7a4a108d design-merge 강제 병합 - 디자인 반영본 전체 통합
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:32:18 +09:00
Rinjae 0f581cae23 design merge 2026-07-20 16:27:49 +09:00
Rinjae 923bf56e21 design merge 2026-07-20 16:27:38 +09:00
hong 9b9bcb5be4 패딩 수정 2026-07-20 14:05:43 +09:00
Rinjae bdb249b74b Merge branch 'master' into feats/api-status 2026-07-20 10:57:55 +09:00
hong 93f7c210bc 11 2026-07-14 15:52:10 +09:00
hong bb79b55f08 fix: 회원가입 , oauth 반응형 수정 2026-07-14 14:54:53 +09:00
hong 23bd0c1207 fix : 메인페이지 반응형 수정 2026-07-14 10:35:14 +09:00
hong 28a9ff1a7f feat: 마이페이지 퍼블리싱 2026-07-14 10:34:31 +09:00
hong 045f354e5c Q&A 리스트 퍼블리싱 2026-07-09 10:45:20 +09:00
hong 4f577c8c14 Faq , q&a 상세 , 피드백 , 마이페이지 이용자 관리 퍼블리싱 2026-07-09 09:47:16 +09:00
hong cc4c1a6b53 feat : 오픈 API & 공지사항 퍼블리싱 2026-07-06 17:21:58 +09:00
hong 551a2da717 feat: 회원가입 안내 및 oauth 2 페이지 퍼블리싱 2026-07-03 16:41:24 +09:00
hong 1c57d348e6 디자인 시안 적용 및 애니메이션 개선 2026-07-02 17:52:59 +09:00
Rinjae 2c29c8a466 공지사항 목록 정렬 및 UI 개선
- 상단고정(fixYn) 우선 정렬 로직 추가
- 고정 뱃지 UI 템플릿 추가
2026-06-16 14:46:25 +09:00
Rinjae d2052d0e16 Q&A 이벤트 리팩토링
- InquiryCommentCreatedAdminEvent 클래스 제거
- build.gradle 리소스 처리 코드 주석 처리
2026-06-16 10:53:39 +09:00
Rinjae 9c4f3cfb04 공지사항 상세 - 장애/점검 정보 노출 추가
- PortalNoticeDTO 장애/점검 필드 확장
- Facade에서 djb_incident 데이터 결합
- 목록/상세에 유형 뱃지 시작 종료 영향 API 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:11:28 +09:00
104 changed files with 23684 additions and 10083 deletions
+2
View File
@@ -108,3 +108,5 @@ diff
*rinjae* *rinjae*
*gf63* *gf63*
*obsidian*
design-backup
+2 -1
View File
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
) )
; ;
create table DVPOWN.PT_TOKEN create table PT_TOKEN
(
( (
TOKEN VARCHAR2(255) not null TOKEN VARCHAR2(255) not null
primary key, primary key,
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class); private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
public PortalApplication() {
super();
}
public static void main(String[] args) { public static void main(String[] args) {
portal_logger.info("##### PortalApplication Start #####"); portal_logger.info("##### PortalApplication Start #####");
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
persistenceUnit = "gateway"; persistenceUnit = "gateway";
} }
// 개발 환경용 // 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
if (env.matchesProfiles("local")) { if (env.matchesProfiles("local")) {
properties.put("hibernate.hbm2ddl.auto", "validate"); properties.put("hibernate.hbm2ddl.auto", "none");
} }
@@ -0,0 +1,387 @@
package com.eactive.apim.portal.djb.webhook.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
*
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
* 참조: {@code apps/app/controller/MyAppController}.
*/
@Slf4j
@Controller
@RequestMapping("/webhook")
@Secured("ROLE_API_KEY_REQUEST")
@RequiredArgsConstructor
@SessionAttributes({"webhookRegistration", "webhookModification"})
public class WebhookController {
// 클래스 기본: 법인 관리자(ROLE_API_KEY_REQUEST)만 신청/수정/삭제/Secret 관리 가능.
// 조회(index)만 메서드 레벨에서 ROLE_APP 으로 완화(같은 기관 일반 사용자도 열람).
private static final int TOTAL_STEPS = 3;
private final WebhookService webhookService;
private final WebhookEventTypeProvider eventTypeProvider;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
@ModelAttribute("webhookRegistration")
public WebhookRegistrationDTO webhookRegistration() {
return new WebhookRegistrationDTO();
}
@ModelAttribute("webhookModification")
public WebhookRegistrationDTO webhookModification() {
return new WebhookRegistrationDTO();
}
// ============================ 조회 ============================
@Secured("ROLE_APP")
@GetMapping
public ModelAndView index() {
String orgId = currentOrgId();
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
if (webhook.isPresent()) {
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
mav.addObject("webhook", webhook.get());
return mav;
}
return new ModelAndView("apps/webhook/webhookEmpty");
}
// ======================= 신규 신청 플로우 =======================
@GetMapping("/register/step1")
public ModelAndView registerStep1(
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
SessionStatus sessionStatus,
Model model) {
if (webhookService.existsByOrg(currentOrgId())) {
return new ModelAndView("redirect:/webhook");
}
if (clear) {
sessionStatus.setComplete();
registration = new WebhookRegistrationDTO();
model.addAttribute("webhookRegistration", registration);
}
registration.setRequestType("NEW");
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
@PostMapping("/register/step1")
public ModelAndView processStep1(
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
BindingResult bindingResult,
Model model) {
validateStep1(registration, bindingResult);
if (bindingResult.hasErrors()) {
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
return new ModelAndView("redirect:/webhook/register/step2");
}
@GetMapping("/register/step2")
public ModelAndView registerStep2(
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
RedirectAttributes redirectAttributes) {
if (!registration.isStep1Complete()) {
return new ModelAndView("redirect:/webhook/register/step1");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
addStepModel(mav, 2);
return mav;
}
/** Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀 (App 신청 saveStep2 답습). */
@PostMapping("/register/step2/save")
public ModelAndView saveStep2(
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration) {
registration.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
return new ModelAndView("redirect:/webhook/register/step1");
}
@PostMapping("/register/step2")
public ModelAndView processStep2(
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
SessionStatus sessionStatus,
RedirectAttributes redirectAttributes) {
registration.setSelectedApis(selectedApis);
if (!registration.isStep2Complete()) {
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
return new ModelAndView("redirect:/webhook/register/step2");
}
try {
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
webhookService.create(registration, currentOrgId());
sessionStatus.setComplete();
redirectAttributes.addFlashAttribute("registrationSuccess", true);
return new ModelAndView("redirect:/webhook/register/step3");
} catch (RuntimeException e) {
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
redirectAttributes.addFlashAttribute("error", e.getMessage());
return new ModelAndView("redirect:/webhook/register/step2");
}
}
@GetMapping("/register/step3")
public ModelAndView registerStep3(Model model) {
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
return new ModelAndView("redirect:/webhook");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
addStepModel(mav, 3);
return mav;
}
@GetMapping("/register/cancel")
public String cancelRegistration(SessionStatus sessionStatus) {
sessionStatus.setComplete();
return "redirect:/webhook";
}
// ========================= 수정 플로우 =========================
@GetMapping("/modify/step1")
public ModelAndView modifyStep1(
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
Model model) {
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
if (!current.isPresent()) {
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
}
WebhookDTO webhook = current.get();
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
modification.setId(webhook.getId());
modification.setRequestType("MODIFY");
modification.setTargetUrl(webhook.getTargetUrl());
modification.setEventTypes(webhook.getEventTypes().stream()
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
model.addAttribute("webhookModification", modification);
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
@PostMapping("/modify/step1")
public ModelAndView processModifyStep1(
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
BindingResult bindingResult,
Model model) {
validateStep1(modification, bindingResult);
if (bindingResult.hasErrors()) {
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
return new ModelAndView("redirect:/webhook/modify/step2");
}
@GetMapping("/modify/step2")
public ModelAndView modifyStep2(
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
if (modification.getId() == null || !modification.isStep1Complete()) {
return new ModelAndView("redirect:/webhook/modify/step1");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
addStepModel(mav, 2);
return mav;
}
/** 수정 Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀. */
@PostMapping("/modify/step2/save")
public ModelAndView saveModifyStep2(
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
modification.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
return new ModelAndView("redirect:/webhook/modify/step1");
}
@PostMapping("/modify/step2")
public ModelAndView processModifyStep2(
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
SessionStatus sessionStatus,
RedirectAttributes redirectAttributes) {
modification.setSelectedApis(selectedApis);
if (modification.getId() == null || !modification.isStep2Complete()) {
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
return new ModelAndView("redirect:/webhook/modify/step2");
}
try {
webhookService.update(modification.getId(), modification, currentOrgId());
sessionStatus.setComplete();
redirectAttributes.addFlashAttribute("modifySuccess", true);
return new ModelAndView("redirect:/webhook/modify/step3");
} catch (RuntimeException e) {
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
redirectAttributes.addFlashAttribute("error", e.getMessage());
return new ModelAndView("redirect:/webhook/modify/step2");
}
}
@GetMapping("/modify/step3")
public ModelAndView modifyStep3(Model model) {
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
return new ModelAndView("redirect:/webhook");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
addStepModel(mav, 3);
return mav;
}
@GetMapping("/modify/cancel")
public String cancelModification(SessionStatus sessionStatus) {
sessionStatus.setComplete();
return "redirect:/webhook";
}
// ==================== AJAX (비밀번호 재인증) ====================
@PostMapping("/verify-secret")
@ResponseBody
public Map<String, Object> verifySecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
result.put("success", true);
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
return result;
}
@PostMapping("/regenerate-secret")
@ResponseBody
public Map<String, Object> regenerateSecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
result.put("success", true);
result.put("secret", secret);
return result;
}
@PostMapping("/delete")
@ResponseBody
public Map<String, Object> delete(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
webhookService.delete(webhook.get().getId(), currentOrgId());
result.put("success", true);
return result;
}
// ============================ helper ============================
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!bindingResult.hasFieldErrors("targetUrl")
&& !url.startsWith("http://") && !url.startsWith("https://")) {
bindingResult.rejectValue("targetUrl", "invalid.url",
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
}
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
"EventType을 1개 이상 선택해주세요.");
}
}
private boolean verifyPassword(String password) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return appServiceFacade.verifyUserPassword(user, password);
}
private String currentOrgId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
}
private void addStepModel(ModelAndView mav, int currentStep) {
mav.addObject("currentStep", currentStep);
mav.addObject("totalSteps", TOTAL_STEPS);
}
}
@@ -0,0 +1,15 @@
package com.eactive.apim.portal.djb.webhook.dto;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
*/
@Getter
@RequiredArgsConstructor
public class WebhookCreatedResult {
private final Long id;
private final String secret;
}
@@ -0,0 +1,26 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
*/
@Data
public class WebhookDTO implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String targetUrl;
private String secretMasked;
private String createdDate;
/** 구독 API ID 목록. */
private List<String> apiIds = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
}
@@ -0,0 +1,27 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookEventTypeDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String code;
private String name;
private boolean selected;
public WebhookEventTypeDTO(String code, String name) {
this.code = code;
this.name = name;
}
}
@@ -0,0 +1,48 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
/**
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
*
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
*/
@Data
public class WebhookRegistrationDTO implements Serializable {
private static final long serialVersionUID = 1L;
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
private Long id;
/** "NEW" 또는 "MODIFY" */
private String requestType;
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>();
/** Step2: 알림 대상 API ID 목록. */
private List<String> selectedApis = new ArrayList<>();
public boolean isStep1Complete() {
return targetUrl != null && !targetUrl.trim().isEmpty()
&& eventTypes != null && !eventTypes.isEmpty();
}
public boolean isStep2Complete() {
return selectedApis != null && !selectedApis.isEmpty();
}
public boolean isComplete() {
return isStep1Complete() && isStep2Complete();
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.djb.webhook.exception;
/**
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
*/
public class WebhookAlreadyExistsException extends RuntimeException {
private static final long serialVersionUID = 1L;
public WebhookAlreadyExistsException(String orgId) {
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.djb.webhook.exception;
/**
* 대상 Webhook 신청을 찾을 수 없음.
*/
public class WebhookNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
public WebhookNotFoundException(Long id) {
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
}
}
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.webhook.mapper;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* WebhookRequest → WebhookDTO 기본 필드 매핑.
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
*/
@Mapper(componentModel = "spring")
public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity);
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.repository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
/**
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
*/
@EMSDataSource
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
@Transactional
void deleteByWebhookReqId(Long webhookReqId);
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.repository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
/**
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
*/
@EMSDataSource
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
@Transactional
void deleteByWebhookReqId(Long webhookReqId);
}
@@ -0,0 +1,17 @@
package com.eactive.apim.portal.djb.webhook.repository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
*/
@EMSDataSource
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
Optional<WebhookRequest> findByOrgId(String orgId);
boolean existsByOrgId(String orgId);
}
@@ -0,0 +1,66 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
*
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
* {@link #onCreate()} 에서 직접 세팅한다.
*/
@Getter
@Setter
@Entity
@Table(name = "PTL_WEBHOOK_REQ")
public class WebhookRequest implements Serializable {
private static final long serialVersionUID = 1L;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
@Column(name = "ID")
private Long id;
@Column(name = "ORG_ID", length = 36)
private String orgId;
@Column(name = "TARGET_URL", length = 255)
private String targetUrl;
@Column(name = "SECRET", length = 500)
private String secret;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@Column(name = "CREATED_DATE", length = 14)
private String createdDate;
@PrePersist
public void onCreate() {
if (createdBy == null) {
createdBy = SecurityUtil.getCurrentLoginId();
}
if (createdDate == null) {
createdDate = LocalDateTime.now().format(TS);
}
}
}
@@ -0,0 +1,62 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
* 복합키(WEBHOOK_REQ_ID + API_ID).
*/
@Getter
@Setter
@Entity
@Table(name = "PTL_WEBHOOK_REQ_API")
@IdClass(WebhookRequestApiId.class)
public class WebhookRequestApi implements Serializable {
private static final long serialVersionUID = 1L;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Id
@Column(name = "WEBHOOK_REQ_ID")
private Long webhookReqId;
@Id
@Column(name = "API_ID", length = 30)
private String apiId;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@Column(name = "CREATED_DATE", length = 14)
private String createdDate;
public WebhookRequestApi() {
}
public WebhookRequestApi(Long webhookReqId, String apiId) {
this.webhookReqId = webhookReqId;
this.apiId = apiId;
}
@PrePersist
public void onCreate() {
if (createdBy == null) {
createdBy = SecurityUtil.getCurrentLoginId();
}
if (createdDate == null) {
createdDate = LocalDateTime.now().format(TS);
}
}
}
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* {@link WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class WebhookRequestApiId implements Serializable {
private static final long serialVersionUID = 1L;
private Long webhookReqId;
private String apiId;
}
@@ -0,0 +1,63 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
*/
@Getter
@Setter
@Entity
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
@IdClass(WebhookRequestEventId.class)
public class WebhookRequestEvent implements Serializable {
private static final long serialVersionUID = 1L;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Id
@Column(name = "WEBHOOK_REQ_ID")
private Long webhookReqId;
@Id
@Column(name = "EVENT_TYPE", length = 36)
private String eventType;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@Column(name = "CREATED_DATE", length = 14)
private String createdDate;
public WebhookRequestEvent() {
}
public WebhookRequestEvent(Long webhookReqId, String eventType) {
this.webhookReqId = webhookReqId;
this.eventType = eventType;
}
@PrePersist
public void onCreate() {
if (createdBy == null) {
createdBy = SecurityUtil.getCurrentLoginId();
}
if (createdDate == null) {
createdDate = LocalDateTime.now().format(TS);
}
}
}
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* {@link WebhookRequestEvent} 복합키 (WEBHOOK_REQ_ID + EVENT_TYPE).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class WebhookRequestEventId implements Serializable {
private static final long serialVersionUID = 1L;
private Long webhookReqId;
private String eventType;
}
@@ -0,0 +1,76 @@
package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* EventType 코드/한글명 제공자. EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
*
* TSEAIRM28 은 elink-portal-common 의 {@code MonitoringCode} 엔티티가 이미 매핑하고 있어(같은 테이블 중복 @Entity 금지),
* 여기서는 별도 엔티티 없이 EMS EntityManager 네이티브 쿼리로 필요한 두 컬럼만 조회한다.
*/
@Component
public class WebhookEventTypeProvider {
private static final String EVENT_TYPE_SQL =
"SELECT CODE, CODENAME FROM TSEAIRM28 "
+ "WHERE CODEGROUP = 'EVENT_TYPE' AND USEYN = 'Y' "
+ "ORDER BY SEQ, CODE";
/** EMS 데이터소스가 @Primary 이므로 기본 EntityManager 는 EMS 를 가리킨다. */
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public List<WebhookEventTypeDTO> getAll() {
List<WebhookEventTypeDTO> list = new ArrayList<>();
for (Object[] row : rows()) {
list.add(new WebhookEventTypeDTO(asString(row[0]), asString(row[1])));
}
return list;
}
@Transactional(readOnly = true)
public Map<String, String> asMap() {
Map<String, String> map = new LinkedHashMap<>();
for (Object[] row : rows()) {
map.put(asString(row[0]), asString(row[1]));
}
return map;
}
@Transactional(readOnly = true)
public boolean isValid(String code) {
if (code == null) {
return false;
}
for (Object[] row : rows()) {
if (code.equals(asString(row[0]))) {
return true;
}
}
return false;
}
@Transactional(readOnly = true)
public String getName(String code) {
return asMap().getOrDefault(code, code);
}
@SuppressWarnings("unchecked")
private List<Object[]> rows() {
return entityManager.createNativeQuery(EVENT_TYPE_SQL).getResultList();
}
private String asString(Object value) {
return value == null ? null : value.toString();
}
}
@@ -0,0 +1,28 @@
package com.eactive.apim.portal.djb.webhook.service;
import java.security.SecureRandom;
import org.springframework.stereotype.Component;
/**
* Webhook HMAC Secret 생성기.
*
* admin 발송엔진이 이 값을 그대로 HMAC-SHA256 키로 사용하므로(암복호화 없음),
* 128자 영숫자 랜덤 문자열을 평문으로 발급한다(admin 기존 데이터와 동일 형태).
*/
@Component
public class WebhookSecretGenerator {
private static final char[] ALPHANUM =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
private static final int LENGTH = 128;
private final SecureRandom random = new SecureRandom();
public String generate() {
StringBuilder sb = new StringBuilder(LENGTH);
for (int i = 0; i < LENGTH; i++) {
sb.append(ALPHANUM[random.nextInt(ALPHANUM.length)]);
}
return sb.toString();
}
}
@@ -0,0 +1,189 @@
package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
import com.eactive.apim.portal.djb.webhook.exception.WebhookAlreadyExistsException;
import com.eactive.apim.portal.djb.webhook.exception.WebhookNotFoundException;
import com.eactive.apim.portal.djb.webhook.mapper.WebhookMapper;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Webhook 신청/조회/수정/삭제 및 Secret 발급 오케스트레이션.
*
* 단일 EMS 데이터소스만 사용하므로 기본 {@code @Transactional} 로 충분하다(JTA 분산 트랜잭션 불필요).
* 모든 수정/삭제/Secret 조회는 소유 Org 검증({@link #loadOwned})을 거친다.
*/
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class WebhookService {
private static final String SECRET_MASK = "••••••••••••";
private final WebhookRequestRepository requestRepository;
private final WebhookRequestApiRepository apiRepository;
private final WebhookRequestEventRepository eventRepository;
private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper;
@Transactional(readOnly = true)
public boolean existsByOrg(String orgId) {
return requestRepository.existsByOrgId(orgId);
}
@Transactional(readOnly = true)
public Optional<WebhookDTO> getByOrg(String orgId) {
return requestRepository.findByOrgId(orgId).map(this::toDetailDto);
}
/**
* 신규 신청. Org 당 1건 정책 위반 시 예외. 평문 Secret 을 1회 반환한다.
*/
public WebhookCreatedResult create(WebhookRegistrationDTO dto, String orgId) {
if (requestRepository.existsByOrgId(orgId)) {
throw new WebhookAlreadyExistsException(orgId);
}
validate(dto);
String secret = secretGenerator.generate();
WebhookRequest request = new WebhookRequest();
request.setOrgId(orgId);
request.setTargetUrl(dto.getTargetUrl().trim());
request.setSecret(secret);
WebhookRequest saved = requestRepository.save(request);
persistChildren(saved.getId(), dto);
log.info("Webhook 신규 등록 orgId={} id={}", orgId, saved.getId());
return new WebhookCreatedResult(saved.getId(), secret);
}
/**
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
*/
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
validate(dto);
request.setTargetUrl(dto.getTargetUrl().trim());
requestRepository.save(request);
apiRepository.deleteByWebhookReqId(id);
eventRepository.deleteByWebhookReqId(id);
apiRepository.flush();
eventRepository.flush();
persistChildren(id, dto);
log.info("Webhook 수정 orgId={} id={}", orgId, id);
return toDetailDto(request);
}
/**
* Secret 재발급(교체). 새 평문 Secret 반환.
*/
public String regenerateSecret(Long id, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
String secret = secretGenerator.generate();
request.setSecret(secret);
requestRepository.save(request);
log.info("Webhook Secret 재발급 orgId={} id={}", orgId, id);
return secret;
}
/**
* 3개 테이블 HardDelete.
*/
public void delete(Long id, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
apiRepository.deleteByWebhookReqId(id);
eventRepository.deleteByWebhookReqId(id);
requestRepository.delete(request);
log.info("Webhook 삭제 orgId={} id={}", orgId, id);
}
/**
* 평문 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
*/
@Transactional(readOnly = true)
public String getPlainSecret(Long id, String orgId) {
return loadOwned(id, orgId).getSecret();
}
// ----------------------------------------------------------------
private WebhookRequest loadOwned(Long id, String orgId) {
WebhookRequest request = requestRepository.findById(id)
.orElseThrow(() -> new WebhookNotFoundException(id));
if (!Objects.equals(request.getOrgId(), orgId)) {
throw new AccessDeniedException("해당 Webhook에 대한 권한이 없습니다.");
}
return request;
}
private void persistChildren(Long reqId, WebhookRegistrationDTO dto) {
for (String apiId : dedup(dto.getSelectedApis())) {
apiRepository.save(new WebhookRequestApi(reqId, apiId));
}
for (String eventType : dedup(dto.getEventTypes())) {
if (!eventTypeProvider.isValid(eventType)) {
throw new IllegalArgumentException("유효하지 않은 EventType 코드입니다: " + eventType);
}
eventRepository.save(new WebhookRequestEvent(reqId, eventType));
}
}
private void validate(WebhookRegistrationDTO dto) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
}
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
}
if (dto.getSelectedApis() == null || dto.getSelectedApis().isEmpty()) {
throw new IllegalArgumentException("알림 대상 API를 1개 이상 선택해주세요.");
}
}
private List<String> dedup(List<String> values) {
if (values == null) {
return java.util.Collections.emptyList();
}
return new java.util.ArrayList<>(new LinkedHashSet<>(values));
}
private WebhookDTO toDetailDto(WebhookRequest request) {
WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId)
.collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap();
dto.setEventTypes(eventRepository.findByWebhookReqId(request.getId()).stream()
.map(e -> new WebhookEventTypeDTO(e.getEventType(),
names.getOrDefault(e.getEventType(), e.getEventType())))
.collect(Collectors.toList()));
return dto;
}
}
+30
View File
@@ -30,6 +30,8 @@ spring:
default-page-size: '10' default-page-size: '10'
jpa: jpa:
open-in-view: false open-in-view: false
hibernate:
ddl-auto: none
web: web:
resources: resources:
@@ -203,6 +205,10 @@ portal:
method: GET method: GET
view-name: apps/service/oauth2-guide view-name: apps/service/oauth2-guide
- path-pattern: /service/webhook-dev-guide
method: GET
view-name: apps/service/webhook-dev-guide
- path-pattern: /dashboard - path-pattern: /dashboard
method: GET method: GET
view-name: apps/mypage/dashboard view-name: apps/mypage/dashboard
@@ -295,6 +301,9 @@ page:
oauth2_guide: oauth2_guide:
name: "OAuth2 개발가이드" name: "OAuth2 개발가이드"
path: "/service/oauth2-guide" path: "/service/oauth2-guide"
webhook_dev_guide:
name: "웹훅 개발가이드"
path: "/service/webhook-dev-guide"
apis: apis:
name: "API" name: "API"
path: "#" path: "#"
@@ -388,6 +397,27 @@ page:
api_statistics: api_statistics:
name: "이용 통계" name: "이용 통계"
path: "/statistics/api" path: "/statistics/api"
webhook:
name: "Webhook 관리"
path: "/webhook"
webhook_register_step1:
name: "Webhook 신청 (기본 정보)"
path: "/webhook/register/step1"
webhook_register_step2:
name: "Webhook 신청 (API 선택)"
path: "/webhook/register/step2"
webhook_register_step3:
name: "Webhook 신청 완료"
path: "/webhook/register/step3"
webhook_modify_step1:
name: "Webhook 수정 (기본 정보)"
path: "/webhook/modify/step1"
webhook_modify_step2:
name: "Webhook 수정 (API 선택)"
path: "/webhook/modify/step2"
webhook_modify_step3:
name: "Webhook 수정 완료"
path: "/webhook/modify/step3"
# 에디터 이미지 설정 (약관 이미지 표시용) # 에디터 이미지 설정 (약관 이미지 표시용)
editor: editor:
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

After

Width:  |  Height:  |  Size: 380 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 380 KiB

@@ -1,3 +1,4 @@
<svg width="1920" height="314" viewBox="0 0 1920 314" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="1920" height="318" viewBox="0 0 1920 318" 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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 239 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,467 @@
/**
* API 선택 공용 모듈 (fragment/api_selector.html 전용, figma s2 디자인)
*
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
*
* 계약:
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
*
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('apiSelectorForm');
if (!form) {
return; // 모듈 미사용 페이지
}
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체';
let allApis = [];
let selectedApis = new Set();
// Restore selected APIs from session (fragment 인라인 주입)
const sessionSelectedApis = window.API_SELECTOR_SELECTED;
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
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 => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
fragment.appendChild(createApiCard(api));
});
apiCardGrid.appendChild(fragment);
attachCardEventListeners();
}
// Create API card element (figma s2 card)
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="s2-api-card-badge">
<span>${api.apiGroupName || api.service || '카테고리'}</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>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// 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!
});
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');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.s2-cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
updateModalList();
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
selectAllWrapper.style.display = 'none';
} else {
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// 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');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
selectedApis.forEach(function(apiId) {
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiPill = document.createElement('div');
apiPill.className = 's2-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>
`;
modalSelectedList.appendChild(apiPill);
});
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
selectedApis.delete(value);
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
}
updateSelectedCount();
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
});
}
// Category tab selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
menuTitles.forEach(t => t.classList.remove('active'));
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
});
});
// 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);
}
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
function openModal() {
updateModalList(); // 열 때마다 최신 선택 상태로 재빌드 (패치 1)
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden';
}
function closeModal() {
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = '';
}
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
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');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// DOM에 렌더되지 않은 선택분을 hidden input으로 주입 — 전송 유실 방지 (패치 3)
function syncHiddenSelected() {
document.querySelectorAll('input.hidden-selected-api').forEach(el => el.remove());
selectedApis.forEach(function(apiId) {
if (!document.querySelector('.s2-api-checkbox[value="' + apiId + '"]')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selectedApis';
input.value = apiId;
input.className = 'hidden-selected-api';
form.appendChild(input);
}
});
}
// Previous step button — 선택 저장 후 step1 복귀
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
const saveAction = form.getAttribute('data-save-action');
if (saveAction) {
form.action = saveAction;
}
syncHiddenSelected();
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
if (selectedApis.size === 0) {
e.preventDefault();
if (window.customPopups && customPopups.showAlert) {
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
} else {
alert('최소 1개 이상의 API를 선택해주세요.');
}
return false;
}
syncHiddenSelected();
});
// Initialize
updateSelectedCount();
loadApis('');
});
@@ -92,6 +92,9 @@
li.innerHTML = '' li.innerHTML = ''
+ '<div class="djb-comment-meta">' + '<div class="djb-comment-meta">'
+ ' <div class="djb-comment-meta-left">' + ' <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>'
+ writerHtml + writerHtml
+ privateTag + privateTag
+ ' </div>' + ' </div>'
@@ -5,6 +5,7 @@
// Color Palette - Vibrant and Modern // Color Palette - Vibrant and Modern
// Primary colors // Primary colors
$primary-color: #0049B4;
$primary-blue: #0049b4; // Vibrant blue $primary-blue: #0049b4; // Vibrant blue
$secondary-blue: #c3dfea; // Deep blue $secondary-blue: #c3dfea; // Deep blue
$accent-cyan: #00D4FF; // Sky blue accent $accent-cyan: #00D4FF; // Sky blue accent
@@ -100,8 +101,9 @@ $z-index-dropdown: 100;
$z-index-sticky: 200; $z-index-sticky: 200;
$z-index-fixed: 300; $z-index-fixed: 300;
$z-index-header: 350; $z-index-header: 350;
$z-index-modal-backdrop: 400; // 모달은 헤더(.global-header z-index:1000 하드코딩)보다 항상 위에 떠야 한다
$z-index-modal: 500; $z-index-modal-backdrop: 1040;
$z-index-modal: 1050;
$z-index-popover: 600; $z-index-popover: 600;
$z-index-tooltip: 700; $z-index-tooltip: 700;
$z-index-notification: 800; $z-index-notification: 800;
@@ -8,9 +8,12 @@
// Float animation // Float animation
@keyframes float { @keyframes float {
0%, 100% {
0%,
100% {
transform: translateY(0) rotate(0deg); transform: translateY(0) rotate(0deg);
} }
50% { 50% {
transform: translateY(-30px) rotate(10deg); transform: translateY(-30px) rotate(10deg);
} }
@@ -18,9 +21,12 @@
// Bounce animation // Bounce animation
@keyframes bounce { @keyframes bounce {
0%, 100% {
0%,
100% {
transform: translateY(0); transform: translateY(0);
} }
50% { 50% {
transform: translateY(-10px); transform: translateY(-10px);
} }
@@ -32,6 +38,7 @@
opacity: 0; opacity: 0;
transform: translateY(-30px); transform: translateY(-30px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -44,6 +51,7 @@
opacity: 0; opacity: 0;
transform: translateY(30px); transform: translateY(30px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -56,6 +64,7 @@
opacity: 0; opacity: 0;
transform: translateX(-30px); transform: translateX(-30px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -68,6 +77,7 @@
opacity: 0; opacity: 0;
transform: translateX(30px); transform: translateX(30px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -79,6 +89,7 @@
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
@@ -90,6 +101,7 @@
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
@@ -101,6 +113,7 @@
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
@@ -111,9 +124,11 @@
0% { 0% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
transform: scale(1.05); transform: scale(1.05);
} }
100% { 100% {
transform: scale(1); transform: scale(1);
} }
@@ -124,6 +139,7 @@
0% { 0% {
transform: translateX(-100%) rotate(45deg); transform: translateX(-100%) rotate(45deg);
} }
100% { 100% {
transform: translateX(100%) rotate(45deg); transform: translateX(100%) rotate(45deg);
} }
@@ -134,24 +150,31 @@
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
} }
10% { 10% {
transform: rotate(14deg); transform: rotate(14deg);
} }
20% { 20% {
transform: rotate(-8deg); transform: rotate(-8deg);
} }
30% { 30% {
transform: rotate(14deg); transform: rotate(14deg);
} }
40% { 40% {
transform: rotate(-4deg); transform: rotate(-4deg);
} }
50% { 50% {
transform: rotate(10deg); transform: rotate(10deg);
} }
60% { 60% {
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(0deg); transform: rotate(0deg);
} }
@@ -163,9 +186,11 @@
opacity: 0; opacity: 0;
transform: scale(0.5); transform: scale(0.5);
} }
80% { 80% {
transform: scale(1.1); transform: scale(1.1);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
@@ -178,6 +203,7 @@
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
@@ -190,6 +216,7 @@
transform: scale(0); transform: scale(0);
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: scale(4); transform: scale(4);
opacity: 0; opacity: 0;
@@ -201,9 +228,11 @@
0% { 0% {
background-position: 0% 50%; background-position: 0% 50%;
} }
50% { 50% {
background-position: 100% 50%; background-position: 100% 50%;
} }
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
} }
@@ -214,6 +243,27 @@
animation: float 20s ease-in-out infinite; 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 { .animate-bounce {
animation: bounce 2s infinite; animation: bounce 2s infinite;
} }
@@ -222,6 +272,37 @@
animation: spin 1s linear infinite; 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 { .animate-pulse {
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
@@ -9,28 +9,30 @@
// FAQ Accordion - Figma Design // FAQ Accordion - Figma Design
.faq-accordion { .faq-accordion {
background: #F6F9FB; display: flex;
border-radius: $border-radius-lg; flex-direction: column;
overflow: hidden; gap: 6px;
margin-bottom: $spacing-2xl; margin-bottom: $spacing-2xl;
@include respond-to('sm') {
background: #FFFFFF;
border-radius: 0;
}
.faq-item { .faq-item {
border-bottom: 1px solid #DADADA; position: relative;
border-radius: 10px;
background: #fff;
transition: $transition-base; transition: $transition-base;
&:last-child {
border-bottom: none;
}
// Active state - when accordion is open // Active state - when accordion is open
&.active { &.active {
.faq-question { .faq-question {
background: #4685ef;
border-color: #e8dddd;
.faq-question-q,
.faq-question-text {
color: #fff !important;
}
.faq-icon { .faq-icon {
color: #fff;
transform: rotate(180deg); transform: rotate(180deg);
} }
} }
@@ -42,33 +44,48 @@
} }
} }
// Question row - Figma: 18px bold, #212529 // Question row
.faq-question { .faq-question {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; min-height: 47px;
padding: $spacing-md $spacing-lg; height: auto;
font-size: 18px; padding: 8px 35px 8px 21px;
font-weight: $font-weight-bold; background: #fff;
color: #212529; border: 1px solid #dedede;
border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: $transition-base; transition: $transition-base;
gap: 95px;
min-height: 54px;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
padding: $spacing-md $spacing-lg; padding: 12px 16px;
font-size: 16px; min-height: 44px;
gap: $spacing-lg;
} }
&:hover { .faq-question-q {
background: rgba($primary-blue, 0.02); 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;
}
} }
.faq-question-text { .faq-question-text {
flex: 1; flex: 1;
line-height: $line-height-normal; font-size: 14px;
color: #000;
line-height: normal;
word-break: keep-all;
@media (max-width: $breakpoint-md) {
font-size: 13px;
line-height: 1.4;
}
} }
// Chevron icon // Chevron icon
@@ -79,7 +96,7 @@
justify-content: center; justify-content: center;
width: 22px; width: 22px;
height: 22px; height: 22px;
color: #212529; color: #000;
transition: transform 0.3s ease; transition: transform 0.3s ease;
svg { svg {
@@ -89,49 +106,41 @@
} }
} }
// Answer section - white background // Answer section
.faq-answer { .faq-answer {
display: none; display: none;
background: #F6F9FB; padding: 19px 21px 25px 21px;
padding: $spacing-lg 20px $spacing-xl;
border-top: none;
@include respond-to('sm') {
background: #FFFFFF;
}
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
padding: $spacing-md $spacing-lg $spacing-lg; padding: 16px;
} }
.faq-answer-content { .faq-answer-content {
font-size: 18px; font-size: 14px;
font-weight: $font-weight-regular; color: #000;
color: #515151; line-height: 1.6;
background-color: #ffffff;
padding: $spacing-lg;
line-height: $line-height-loose;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
font-size: 14px; font-size: 13px;
} }
// Content formatting // Content formatting
p { p {
margin-bottom: $spacing-md; margin-bottom: 8px;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
} }
ul, ol { ul,
margin: $spacing-sm 0; ol {
padding-left: $spacing-lg; margin: 8px 0;
padding-left: 20px;
} }
li { li {
margin-bottom: $spacing-xs; margin-bottom: 4px;
} }
a { a {
@@ -225,6 +234,7 @@
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -573,3 +573,33 @@
height: 36px; 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,9 +41,7 @@
// ── 댓글 목록 박스 (설계서 ① 영역) ───────────────────────── // ── 댓글 목록 박스 (설계서 ① 영역) ─────────────────────────
.djb-comment-list-box { .djb-comment-list-box {
padding: 24px 28px;
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px; border-radius: 8px;
min-height: 120px; min-height: 120px;
margin-bottom: 28px; margin-bottom: 28px;
@@ -64,16 +62,16 @@
// ── 댓글 한 건 (설계서 작성자 + 날짜 + Contents) ─────────── // ── 댓글 한 건 (설계서 작성자 + 날짜 + Contents) ───────────
.djb-comment-item { .djb-comment-item {
padding: 18px 0; padding: 40px 0;
border-bottom: 1px dashed #E5E7EB; border-bottom: 1px solid #DFDFDF;
&:first-child { &:first-child {
padding-top: 4px; padding-top: 20px;
} }
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
padding-bottom: 4px; padding-bottom: 20px;
} }
&--admin .djb-comment-writer { &--admin .djb-comment-writer {
@@ -94,21 +92,21 @@
} }
.djb-comment-writer { .djb-comment-writer {
font-size: 18px; font-size: 14px;
font-weight: 700; font-weight: 500;
color: #111827; color: #000;
letter-spacing: -0.2px; letter-spacing: -0.2px;
} }
.djb-comment-admin-badge { .djb-comment-admin-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 8px; padding: 3px 8px;
background-color: #DBEAFE; background-color: #ecf1ff;
color: #1D4ED8; color: #2a69de;
border-radius: 10px; border-radius: 10px;
font-size: 11px; font-size: 9px;
font-weight: 600; font-weight: 700;
} }
.djb-comment-meta-right { .djb-comment-meta-right {
@@ -123,29 +121,32 @@
} }
.djb-comment-delete-btn { .djb-comment-delete-btn {
background: none; background: #fbe7e9;
border: 1px solid #E5E7EB; border: 1px solid #f1c2d6;
border-radius: 4px; border-radius: 5px;
padding: 3px 10px; padding: 3px 6px;
font-size: 12px; font-size: 10px;
color: #6B7280; font-weight: 500;
color: #bb1026;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
height: 20px;
&:hover { &:hover {
background-color: #FEE2E2; background-color: #f1c2d6; // Changed from darken(#fbe7e9, 5%) to fix warning
border-color: #FCA5A5;
color: #B91C1C;
} }
} }
.djb-comment-body { .djb-comment-body {
padding: 12px 16px; padding: 24px;
background-color: #F3F4F6; background-color: #f4f5f9;
border-radius: 6px; border-radius: 10px;
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 20px;
color: #1F2937; color: #000;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
@@ -167,31 +168,31 @@ textarea.djb-comment-input {
display: block; display: block;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 16px 18px; padding: 14px 18px;
border: 1px solid #D1D5DB; border: 1px solid #dfdfdf;
border-radius: 8px; border-radius: 10px;
background-color: #F9FAFB; background-color: #ffffff;
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
color: #1F2937; color: #000;
resize: vertical; resize: vertical;
min-height: 120px; min-height: 103px;
font-family: inherit; font-family: inherit;
outline: none; outline: none;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
&::placeholder { &::placeholder {
color: #9CA3AF; color: #7f8a95;
} }
&:hover { &:hover {
border-color: #9CA3AF; border-color: #2a69de;
} }
&:focus { &:focus {
border-color: #1D4ED8; border-color: #2a69de;
background-color: #FFFFFF; background-color: #FFFFFF;
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15); box-shadow: 0 0 0 3px rgba(42, 105, 222, 0.1);
} }
} }
@@ -209,28 +210,22 @@ textarea.djb-comment-input {
} }
button.djb-comment-submit { button.djb-comment-submit {
display: inline-block; display: flex;
min-width: 140px; align-items: center;
padding: 12px 28px; justify-content: center;
background-color: #1D4ED8; width: 149px;
color: #FFFFFF; height: 39px;
border: 1px solid #1D4ED8; background-color: #ffffff;
border-radius: 8px; color: #4685ef;
font-size: 15px; border: 1px solid #2a69de;
border-radius: 10px;
font-size: 14px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.2px;
cursor: pointer; cursor: pointer;
appearance: none; transition: all 0.2s ease;
-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 { &:hover {
background-color: #1E40AF; background-color: #f8faff;
border-color: #1E40AF;
box-shadow: 0 3px 10px rgba(29, 78, 216, 0.28);
} }
&:active { &:active {
@@ -238,9 +233,9 @@ button.djb-comment-submit {
} }
&:disabled { &:disabled {
background-color: #94A3B8; background-color: #f4f5f9;
border-color: #94A3B8; border-color: #dfdfdf;
box-shadow: none; color: #7f8a95;
cursor: not-allowed; cursor: not-allowed;
} }
} }
@@ -254,41 +249,3 @@ button.djb-comment-submit {
color: #6B7280; color: #6B7280;
font-size: 13px; font-size: 13px;
} }
// ── 260710 댓글 공개범위 ──────────────────────────────
// 작성 폼 비공개 토글
.djb-comment-private-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: auto;
color: #6B7280;
font-size: 13px;
cursor: pointer;
}
// 비공개 댓글 태그 (열람 권한 있는 사용자에게 표시)
.djb-comment-private-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 10px;
background-color: #FEF3C7;
color: #92400E;
font-size: 11px;
vertical-align: middle;
}
// 비공개 댓글 (열람 권한 있음) 배경 강조
.djb-comment-item--private {
background-color: #FFFBEB;
}
// 열람 권한 없는 비공개 댓글 (placeholder)
.djb-comment-item--private-hidden {
.djb-comment-body,
.djb-comment-writer {
color: #9CA3AF;
font-style: italic;
}
}
@@ -13,7 +13,7 @@
label { label {
display: block; display: block;
font-size: $font-size-sm; font-size: $font-size-sm;
font-weight: $font-weight-medium; font-weight: $font-weight-semibold;
color: $text-dark; color: $text-dark;
margin-bottom: $spacing-sm; margin-bottom: $spacing-sm;
@@ -453,6 +453,7 @@ select.form-control {
.form-field-wrapper { .form-field-wrapper {
flex: 1; flex: 1;
@include respond-to('sm') { @include respond-to('sm') {
width: 100%; width: 100%;
} }
@@ -757,6 +758,7 @@ select.form-control {
} }
.btn { .btn {
// Figma 모바일 (1291-3170): 144px × 40px, border-radius 8px, 14px bold // Figma 모바일 (1291-3170): 144px × 40px, border-radius 8px, 14px bold
@include respond-to('sm') { @include respond-to('sm') {
width: 144px !important; width: 144px !important;
@@ -960,3 +962,71 @@ select.form-control {
display: flex; display: flex;
align-items: center; 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,47 +18,77 @@
&.authenticated { &.authenticated {
gap: 0; 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 Button
// =========================== // ===========================
.login-btn { .login-btn {
display: inline-flex; font-size: 15px;
align-items: center; font-weight: 500;
color: var(--primary-color);
padding: 10px 20px; padding: 10px 20px;
color: $text-dark; border-radius: 8px;
text-decoration: none; transition: var(--transition-smooth);
border: 2px solid $primary-blue;
border-radius: $border-radius-full;
font-weight: $font-weight-semibold;
font-size: $font-size-sm;
transition: $transition-base;
// Box style for Figma design (node-id: 721-1489) &:hover {
&-box { background-color: var(--accent-light);
height: 32px; }
display: inline-flex; }
align-items: center;
justify-content: center;
gap: 6px; // 아이콘-텍스트 간격 .btn-signup {
padding: 6px 16px; font-size: 15px;
color: #212529; // 강조 텍스트 색상 font-weight: 700;
text-decoration: none; color: #FFFFFF;
border: none; background-color: var(--primary-color);
border-radius: 6px; padding: 10px 24px;
font-family: $font-family-primary; border-radius: 8px;
font-weight: $font-weight-semibold; // 600 box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
font-size: 16px; transition: var(--transition-smooth);
background: transparent;
transition: $transition-base; &: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 { .user-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex-shrink: 0; flex-shrink: 0;
} }
}
} }
// =========================== // ===========================
@@ -20,6 +20,7 @@ $pagination-text-disabled: #adb5bd;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: $pagination-gap; gap: $pagination-gap;
margin-top:50px;
// Page navigation buttons (first, prev, next, last) // Page navigation buttons (first, prev, next, last)
.page-first, .page-first,
@@ -71,12 +71,12 @@
} }
} }
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치. // 가로 여유가 있을 때만 노출.
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px // 헤더 .container(max-width:1280px, 중앙정렬)의 우측 여백에 위젯(폭 ~200px)이 들어갈 만큼
@media (max-width: $breakpoint-sm) { // 넓은 화면(약 1700px 이상)에서만 표시 → 그 외에는 헤더 우측 메뉴(사용자명·로그아웃·마이페이지)를 가리므로 숨김.
// (PC 전용: 모바일 ≤768px 도 당연히 숨김)
@media (max-width: 1699px) {
.session-float { .session-float {
top: 12px; display: none;
right: 72px;
padding: 4px 10px;
} }
} }
@@ -275,10 +275,138 @@
} }
} }
// -----------------------------------------------------------------------------
// 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) // List Table - Modern design with colored header (Figma: 984-2173)
// Used in: User management, API key list, etc. // Used in: User management, API key list, etc.
// Refined: Clean Minimal sizing with original KJB colors
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.list-table { .list-table {
width: 100%; width: 100%;
@@ -459,17 +587,17 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 $spacing-sm; padding: 0 $spacing-md;
height: 30px; height: 28px;
white-space: nowrap; white-space: nowrap;
border-radius: $border-radius-sm; border-radius: 5px;
font-size: 13px; font-size: 12px;
font-weight: $font-weight-regular; font-weight: $font-weight-regular;
font-family: 'Spoqa Han Sans Neo', sans-serif;
color: #000000; color: #000000;
border: none; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
transition: $transition-base; transition: $transition-base;
white-space: nowrap;
&:hover { &:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -482,35 +610,42 @@
// Button variants // Button variants
&--default { &--default {
background-color: #DADADA; background-color: #f5f5f4;
border-color: #bdc7cf;
color: #64748b;
&:hover { &:hover {
background-color: darken(#DADADA, 5%); background-color: darken(#f5f5f4, 5%);
} }
} }
&--primary { &--primary {
background-color: #A4D6EA; background-color: #ecf0fa;
border-color: #4685ef;
color: #2a69de;
&:hover { &:hover {
background-color: darken(#A4D6EA, 5%); background-color: darken(#ecf0fa, 5%);
} }
} }
&--secondary { &--secondary {
background-color: #CDD4F0; background-color: #f5f5f4;
border-color: #bdc7cf;
color: #64748b;
&:hover { &:hover {
background-color: darken(#CDD4F0, 5%); background-color: darken(#f5f5f4, 5%);
} }
} }
&--danger { &--danger {
background-color: #F8D7DA; background-color: #fbe7e9;
color: #B02A37; border-color: #f1c2d6;
color: #bb1026;
&:hover { &:hover {
background-color: darken(#F8D7DA, 5%); background-color: darken(#fbe7e9, 5%);
} }
} }
} }
@@ -10,7 +10,7 @@
.container { .container {
max-width: $container-max-width; max-width: $container-max-width;
margin: 0 auto; margin: 0 auto;
padding: 0 26px; padding: 0 20px;
@include respond-to('sm') { @include respond-to('sm') {
padding: 0 $spacing-md; padding: 0 $spacing-md;
@@ -109,8 +109,7 @@
font-size: $font-size-2xl; font-size: $font-size-2xl;
} }
.title-highlight { .title-highlight {}
}
} }
.section-subtitle { .section-subtitle {
@@ -6,6 +6,7 @@
// Footer Styles - Figma Design Implementation // Footer Styles - Figma Design Implementation
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/* 원래 있던 Footer 스타일 (주석 처리)
.global-footer { .global-footer {
background: #FFFFFF; background: #FFFFFF;
border-top: 1px solid #E5E7EB; border-top: 1px solid #E5E7EB;
@@ -142,6 +143,143 @@
} }
} }
} }
*/
/* 피그마 시안 적용 다크 모드 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;
}
}
}
// Mobile responsive adjustments (sm: 768px) // Mobile responsive adjustments (sm: 768px)
@include respond-to('sm') { @include respond-to('sm') {
@@ -9,6 +9,8 @@
// CSS Variables for modern header styling // CSS Variables for modern header styling
:root { :root {
// Enhanced color palette from design // Enhanced color palette from design
--primary-color: #0049b4;
--secondary-color: #008ae2;
--primary-blue: #4B9BFF; --primary-blue: #4B9BFF;
--secondary-blue: #2E7FF7; --secondary-blue: #2E7FF7;
--accent-cyan: #00D4FF; --accent-cyan: #00D4FF;
@@ -16,6 +18,8 @@
--accent-orange: #FF6B6B; --accent-orange: #FF6B6B;
--accent-green: #6BCF7F; --accent-green: #6BCF7F;
--accent-purple: #A78BFA; --accent-purple: #A78BFA;
--accent-color:#efdcb2;
--accent-light: #E9F9FF;
// Base colors // Base colors
--text-dark: #1A1A2E; --text-dark: #1A1A2E;
@@ -31,8 +35,101 @@
--shadow-md: 0 4px 12px rgba(75, 155, 255, 0.1); --shadow-md: 0 4px 12px rgba(75, 155, 255, 0.1);
--shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15); --shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2); --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);
} }
// ===========================
// Design Survey Bar
// ===========================
.design-survey-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.survey-container {
display: flex;
align-items: center;
gap: 16px;
}
.survey-label {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.survey-buttons {
display: flex;
gap: 8px;
}
.survey-btn {
padding: 6px 16px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 20px;
background: transparent;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
}
&.active {
background: #ffffff;
color: #667eea;
border-color: #ffffff;
}
}
@media (max-width: 768px) {
height: 40px;
.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;
}
@media (max-width: 768px) {
.global-header {
margin-top: 40px;
}
}
}
// 디자인 변형 스타일은 JavaScript에서 동적으로 적용됩니다.
// header_container.html의 DESIGN_OPTIONS 참조
// Blind text for screen readers // Blind text for screen readers
.blind { .blind {
position: absolute; position: absolute;
@@ -50,35 +147,63 @@
// Modern Header Structure // Modern Header Structure
// =========================== // ===========================
.global-header { .global-header {
position: relative; position: fixed;
background-color: #ffffff; top: 0;
z-index: $z-index-header; left: 0;
height: 112px; width: 100%;
transition: all 0.3s ease; height: 80px;
border-bottom: 1px solid #dddddd; 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);
// Index page header with light blue background // Index page header with light blue background
&.index-header { &.index-header {
background-color: #E5F8FF; background-color: white;
} }
.container { .container {
height: 100%; height: 100%;
padding: 0; padding: 0;
width: 100%;
.header-content { .header-content {
height: 100%; 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 { .header-content {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 65px;
max-width: 1920px;
margin: 0 auto;
padding: 0 20px;
} }
// =========================== // ===========================
@@ -92,7 +217,6 @@
.logo { .logo {
height: 32px; height: 32px;
width: 114px;
} }
} }
@@ -101,13 +225,27 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; 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 with modern styling
.logo { .logo {
display: block; display: block;
height: 32px; height: 32px;
width: 114px;
vertical-align: middle; vertical-align: middle;
z-index: 10; z-index: 10;
@@ -307,9 +445,11 @@
&:nth-child(1) { &:nth-child(1) {
transform: rotate(45deg) translateY(5px); transform: rotate(45deg) translateY(5px);
} }
&:nth-child(2) { &:nth-child(2) {
opacity: 0; opacity: 0;
} }
&:nth-child(3) { &:nth-child(3) {
transform: rotate(-45deg) translateY(-5px); transform: rotate(-45deg) translateY(-5px);
} }
@@ -727,14 +867,27 @@
transition: all $transition-fast; transition: all $transition-fast;
} }
&::before { top: -8px; } &::before {
&::after { top: 8px; } top: -8px;
}
&::after {
top: 8px;
}
} }
&.on span { &.on span {
background: transparent; 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);
}
} }
} }
@@ -774,7 +927,7 @@
.nav-item { .nav-item {
border-bottom: 1px solid $border-gray; border-bottom: 1px solid $border-gray;
> a { >a {
display: flex; display: flex;
align-items: center; align-items: center;
padding: $spacing-lg; padding: $spacing-lg;
@@ -785,7 +938,7 @@
position: relative; position: relative;
} }
&.has-submenu > a::after { &.has-submenu>a::after {
content: ''; content: '';
position: absolute; position: absolute;
right: $spacing-lg; right: $spacing-lg;
@@ -798,7 +951,7 @@
transition: transform $transition-fast; transition: transform $transition-fast;
} }
&.expanded > a::after { &.expanded>a::after {
transform: translateY(-25%) rotate(-135deg); transform: translateY(-25%) rotate(-135deg);
} }
@@ -811,7 +964,9 @@
a { a {
display: block; display: block;
padding: $spacing-md $spacing-lg $spacing-md ($spacing-lg * 2); padding: $spacing-md $spacing-lg $spacing-md (
$spacing-lg * 2
);
color: $text-gray; color: $text-gray;
font-size: $font-size-sm; font-size: $font-size-sm;
text-decoration: none; text-decoration: none;
@@ -827,15 +982,15 @@
display: block; display: block;
} }
} }
} }
.info_wrap { .info_wrap {
padding: $spacing-xl $spacing-lg; padding: $spacing-xl $spacing-lg;
background: $white; background: $white;
border-top: 1px solid $border-gray; border-top: 1px solid $border-gray;
margin-top: auto; margin-top: auto;
// styles for tit1, tit2... // styles for tit1, tit2...
} }
} }
// =========================== // ===========================
@@ -844,16 +999,16 @@
.nav-menu { .nav-menu {
display: flex; display: flex;
list-style: none; list-style: none;
gap: 8px; gap: 36px;
margin: 0; margin: 0;
padding: 0; padding: 0;
// Navigation item with submenu // Navigation item with submenu
> li { >li {
position: relative; position: relative;
&.submenu-open { &.submenu-open {
> .nav-link { >.nav-link {
background: var(--light-bg); background: var(--light-bg);
color: var(--primary-blue); color: var(--primary-blue);
} }
@@ -906,16 +1061,12 @@
} }
.nav-link { .nav-link {
display: flex; font-size: 16px;
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--text-dark);
font-weight: 500; font-weight: 500;
font-size: 20px; color: var(--text-gray);
padding: 8px 16px; padding: 8px 0;
border-radius: 8px; position: relative;
transition: all 0.3s ease; transition: var(--transition-smooth);
&:hover { &:hover {
color: var(--primary-blue); color: var(--primary-blue);
@@ -948,10 +1099,17 @@
// Desktop Navigation Styles // Desktop Navigation Styles
// =========================== // ===========================
@include respond-to('desktop') { @include respond-to('desktop') {
.m-only { display: none !important; } .m-only {
.pc-only { display: block !important; } display: none !important;
}
.btn-mobilemenu { display: none; } .pc-only {
display: block !important;
}
.btn-mobilemenu {
display: none;
}
.main-nav.m-nav { .main-nav.m-nav {
position: static; position: static;
@@ -964,8 +1122,13 @@
height: 100%; height: 100%;
flex: 1; flex: 1;
.nav-header { display: none; } .nav-header {
.info_wrap { display: none; } display: none;
}
.info_wrap {
display: none;
}
.nav-list { .nav-list {
display: flex; display: flex;
@@ -977,7 +1140,7 @@
position: relative; position: relative;
height: 100%; height: 100%;
> a { >a {
height: 100%; height: 100%;
padding: 0 24px; padding: 0 24px;
color: var(--text-dark); color: var(--text-dark);
@@ -987,6 +1150,7 @@
&:hover { &:hover {
color: var(--primary-blue); color: var(--primary-blue);
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -1000,7 +1164,9 @@
} }
} }
&.has-submenu > a::after { display: none; } &.has-submenu>a::after {
display: none;
}
} }
} }
} }
+1
View File
@@ -66,6 +66,7 @@
@use 'pages/terms-agreements' as *; @use 'pages/terms-agreements' as *;
@use 'pages/service' as *; @use 'pages/service' as *;
@use 'pages/api-statistics' as *; @use 'pages/api-statistics' as *;
@use 'pages/webhook' as *;
// 6. Themes // 6. Themes
@use 'themes/dark' as *; @use 'themes/dark' as *;
@@ -8,38 +8,157 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.api-market-container { .api-market-container {
max-width: 1240px;
margin: 0 auto;
padding: 60px 0px;
display: flex; display: flex;
min-height: 100vh; gap: 60px;
background-color: $white; align-items: flex-start;
width: 100%;
@media (max-width: 1024px) {
flex-direction: column;
gap: 30px;
padding: 20px;
}
// Sidebar overrides for mobile toggle menu inside market
.service-sidebar {
@media (max-width: 768px) {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 280px;
margin: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #ffffff;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
padding: 80px 24px 30px; // Clear header height if any
&.mobile-open {
transform: translateX(0);
}
.service-nav {
flex-direction: column !important;
gap: 16px !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
}
}
}
// 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);
position: relative; position: relative;
margin-bottom: 20px; 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;
}
} }
// Sidebar Navigation // Sidebar Navigation
.api-market-sidebar { .api-market-sidebar {
width: 283px; width: 213px;
background-color: #F8FBFD; background-color: #ffffff;
border: 1px solid #e3e8f0;
border-radius: 20px; border-radius: 20px;
padding: $spacing-2xl $spacing-lg; padding: 28px 30px;
position: sticky; position: sticky;
top: $spacing-lg; top: 40px;
height: calc(100vh - 32px); height: fit-content;
flex-shrink: 0; flex-shrink: 0;
margin: $spacing-md; margin-right: 40px;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
width: 240px; width: 200px;
margin-right: 20px;
} }
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
position: fixed; position: fixed;
left: 0; left: 0;
top: 60px; // Below mobile header top: 60px;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s ease; transition: transform 0.3s ease;
box-shadow: $shadow-lg; box-shadow: $shadow-lg;
z-index: $z-index-modal; z-index: $z-index-modal;
height: calc(100vh - 60px); // Full height minus header height: calc(100vh - 60px);
margin: 0; margin: 0;
border-radius: 0; border-radius: 0;
@@ -49,67 +168,37 @@
} }
} }
.api-sidebar-header {
display: flex;
justify-content: center;
margin-bottom: $spacing-xl;
padding-bottom: $spacing-lg;
img {
max-width: 100%;
height: auto;
}
}
.api-sidebar-nav { .api-sidebar-nav {
display: flex;
flex-direction: column;
gap: 26px;
.menu-section { .menu-section {
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid #DDDDDD;
&:last-child {
border-bottom: none; border-bottom: none;
} }
}
.menu-title { .menu-title {
padding: 16px 10px; padding: 0;
font-size: $font-size-lg; font-size: 16px;
font-weight: $font-weight-regular; font-family: 'Spoqa Han Sans Neo', sans-serif;
color: $text-dark; font-weight: 500;
color: #0d0e11;
cursor: pointer; cursor: pointer;
transition: $transition-base; transition: color 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
position: relative; position: relative;
&:hover { &:hover {
background-color: rgba($primary-blue, 0.05); color: #2a69de;
background-color: transparent;
} }
&.active { &.active {
font-weight: $font-weight-bold; font-weight: 700;
color: $primary-blue; color: #2a69de;
}
.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;
} }
} }
@@ -220,9 +309,8 @@
// Main Content Area // Main Content Area
.api-market-content { .api-market-content {
flex: 1; flex: 1;
padding-top: $spacing-5xl; // padding-left: $spacing-sm;
padding-left: $spacing-sm; // padding-right: $spacing-sm;
padding-right: $spacing-sm;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -311,7 +399,7 @@
// Search Box // Search Box
.api-market-search { .api-market-search {
position: relative; position: relative;
width: 300px; width: 228px;
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
width: 100%; width: 100%;
@@ -325,53 +413,41 @@
input { input {
flex: 1; flex: 1;
padding: 12px 48px 12px $spacing-md; height: 44px;
border: 1px solid $border-gray; padding: 0 40px 0 16px;
border-radius: $border-radius-lg; border: 1px solid #dadada;
font-size: $font-size-sm; border-radius: 12px;
font-size: 14px;
outline: none; outline: none;
transition: $transition-base; transition: $transition-base;
&:focus { &:focus {
border-color: $primary-blue; border-color: #0049b4;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); box-shadow: 0 0 0 3px rgba(#0049b4, 0.1);
} }
&::placeholder { &::placeholder {
color: $text-light; color: #7f8a95;
} }
} }
.search-submit-btn { .search-submit-btn {
position: absolute; position: absolute;
right: 4px; right: 8px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 36px; width: 20px;
height: 36px; height: 20px;
background: transparent; background: transparent;
border: none; border: none;
border-radius: $border-radius-md;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: $transition-base;
color: $text-gray;
&:hover { svg {
background: $light-bg; width: 100%;
color: $primary-blue; height: 100%;
transform: translateY(-50%) scale(1.05);
}
&:active {
transform: translateY(-50%) scale(0.95);
}
.search-icon {
font-size: 18px;
display: block;
} }
} }
} }
@@ -381,101 +457,120 @@
// Card Grid // Card Grid
.api-card-grid { .api-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 320px); // 3 cards per row on desktop (고정폭) grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: $spacing-lg; gap: 24px;
justify-content: center; width: 100%;
// Tablet view - 2 cards per row (고정폭)
@media (max-width: $breakpoint-md) {
grid-template-columns: repeat(2, 320px);
}
// Mobile view - 1 card per row (화면에 맞춤)
@media (max-width: $breakpoint-sm) {
grid-template-columns: 1fr;
}
} }
// API Card // API Card
.api-card { .api-card {
background: $white; background: #FFFFFF;
border-radius: $border-radius-lg; border-radius: 20px;
padding: $spacing-xl; border: 1px solid #DDDDDD;
box-shadow: $shadow-sm; box-shadow: 0px 4px 2px rgba(194, 194, 194, 0.25);
cursor: pointer; cursor: pointer;
display: flex;
flex-direction: column;
gap: $spacing-md;
min-height: 180px;
position: relative; position: relative;
border: 1px solid $border-gray; width: 100%;
height: 312px;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
&:hover,
&:focus,
&:active { &:active {
transform: scale(0.98); transform: translateY(-5px);
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
background: linear-gradient(to bottom, #50caff, #2088ff);
border: none;
.api-card-badge {
background-color: #d8f0ff;
} }
}
// Group Info Section (Icon + Name) .api-card-title,
.api-card-group { .api-card-desc {
display: flex; color: #ffffff;
align-items: center; }
gap: $spacing-sm; }
}
.api-card-group-icon { &-badge {
width: 32px; position: absolute;
height: 32px; top: 39px;
display: flex; left: 32px;
background-color: #f2f2f3;
border-radius: 10px;
padding: 2px 20px;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: $gray-bg;
border-radius: $border-radius-md;
flex-shrink: 0;
img { span {
width: 20px; font-size: 14px;
height: 20px; font-family: 'Spoqa Han Sans Neo', sans-serif;
object-fit: contain; font-weight: 500;
color: #1b4ab7;
white-space: nowrap;
}
} }
i { &-title {
font-size: 16px; position: absolute;
color: $primary-blue; top: 86px;
} left: 36px;
} right: 40px;
font-size: 18px;
.api-card-group-name { font-family: 'Spoqa Han Sans Neo', sans-serif;
font-size: $font-size-sm; font-weight: 700;
color: $text-gray; color: #000000;
font-weight: $font-weight-medium;
}
// 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; margin: 0;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
text-align: left; text-align: left;
} white-space: normal;
}
// API Description &-desc {
.api-card-description { position: absolute;
font-size: $font-size-sm; top: 135px;
color: $text-gray; left: 36px;
line-height: $line-height-normal; right: 40px;
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 400;
color: #64748b;
line-height: 1.5;
margin: 0; margin: 0;
flex-grow: 1;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
text-align: left; text-align: left;
white-space: normal;
}
&-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;
}
}
} }
// Empty State // Empty State
@@ -602,130 +697,6 @@
} }
} }
// Testbed 앱 선택 패널 (DJPGPT0001)
.testbed-app-panel {
margin: $spacing-sm 0 $spacing-lg;
padding: $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
.testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: $spacing-xs $spacing-sm;
margin-bottom: $spacing-md;
}
.testbed-app-panel__title {
position: relative;
padding-left: $spacing-md;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $text-dark;
letter-spacing: -0.01em;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: $primary-blue;
border-radius: 2px;
}
}
.testbed-app-panel__desc {
font-size: $font-size-sm;
color: $text-gray;
}
.testbed-app-field {
position: relative;
max-width: 380px;
&::after {
content: "";
position: absolute;
right: $spacing-md;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid $text-gray;
border-bottom: 2px solid $text-gray;
transform: rotate(45deg);
pointer-events: none;
}
}
#apps {
width: 100%;
height: 46px;
padding: 0 42px 0 $input-padding-x;
font-family: $font-family-primary;
font-size: $font-size-sm;
color: $text-dark;
background: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: $transition-fast;
&:hover:not(:disabled) {
border-color: $text-light;
background: $white;
}
&:focus {
outline: none;
background: $white;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
}
&:disabled {
color: $text-light;
background: $gray-bg;
cursor: not-allowed;
}
}
.testbed-app-notice {
margin: $spacing-md 0 0;
padding: 11px $spacing-md;
font-size: $font-size-sm;
line-height: $line-height-normal;
color: $text-gray;
background: $light-bg;
border: 1px solid rgba($primary-blue, 0.18);
border-left: 3px solid $primary-blue;
border-radius: $border-radius-sm;
&::before {
content: "";
color: $primary-blue;
font-weight: $font-weight-bold;
}
}
@media (max-width: $breakpoint-sm) {
padding: $spacing-md;
.testbed-app-field {
max-width: 100%;
}
}
}
// API Overview Card (Flat Style) // API Overview Card (Flat Style)
.api-overview-card { .api-overview-card {
background: transparent; background: transparent;
@@ -956,6 +927,7 @@
// Mobile Responsive Styles (sm: 768px) // Mobile Responsive Styles (sm: 768px)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
// API Detail Tabs - Mobile // API Detail Tabs - Mobile
.api-detail-tabs { .api-detail-tabs {
gap: 0; gap: 0;
@@ -1052,7 +1024,8 @@
min-width: 500px; // 테이블 최소 너비 보장 min-width: 500px; // 테이블 최소 너비 보장
margin: $spacing-sm 0; margin: $spacing-sm 0;
th, td { th,
td {
padding: $spacing-xs $spacing-sm; padding: $spacing-xs $spacing-sm;
font-size: $font-size-xs; font-size: $font-size-xs;
white-space: nowrap; white-space: nowrap;
@@ -74,7 +74,6 @@
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
color: $text-dark; color: $text-dark;
margin-bottom: $spacing-lg; margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
} }
// Detail Grid - extends common // Detail Grid - extends common
@@ -617,6 +616,7 @@
// ==================================== // ====================================
@include respond-to('sm') { @include respond-to('sm') {
// Container mobile padding // Container mobile padding
.apikey-detail-container { .apikey-detail-container {
padding: 0 !important; padding: 0 !important;
@@ -876,26 +876,441 @@
// 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정) // 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정)
display: none; display: none;
} }
// 모바일: 앱 수정 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
&.app-modify-btn {
display: none;
}
} }
} }
} }
// PC 환경 안내 문구 - 모바일에서만 노출 (앱 수정) // =============================================================================
.app-modify-pc-only-message { // NEW FIGMA DETAIL WRAP STYLES (.detail-wrap)
display: none; // =============================================================================
.detail-wrap {
width: 100%;
font-family: 'Spoqa Han Sans Neo', 'Noto Sans KR', sans-serif;
// 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;
}
}
// 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; margin: 0;
padding: 0 20px 24px; white-space: nowrap;
color: $text-gray; overflow: hidden;
font-size: 14px; text-overflow: ellipsis;
text-align: center; }
line-height: 1.5;
@media (max-width: $breakpoint-sm) { .dt-status-badge {
display: block; 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,22 +194,6 @@
} }
} }
// 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 Description
.app-list-description { .app-list-description {
font-family: $font-family-primary; font-family: $font-family-primary;
@@ -262,23 +246,214 @@
font-size: 14px; font-size: 14px;
border-radius: 8px; border-radius: 8px;
} }
// 모바일: 앱 생성 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
@include respond-to('sm') {
display: none;
}
} }
// PC 환경 안내 문구 - 모바일에서만 노출 /* App Card Figma Styles */
.app-create-pc-only-message { .app-management-layout {
display: none; 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;
}
}
.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;
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 {
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;
h3 {
font-size: 18px;
color: #000;
margin: 16px 0 8px;
}
p {
font-size: 14px;
color: #64748b;
margin: 0; margin: 0;
color: $text-gray;
font-size: 14px;
text-align: center;
line-height: 1.5;
@include respond-to('sm') {
display: block;
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+227 -154
View File
@@ -26,6 +26,7 @@
.inquiry-header { .inquiry-header {
margin-bottom: $spacing-3xl; margin-bottom: $spacing-3xl;
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
margin-bottom: $spacing-2xl; margin-bottom: $spacing-2xl;
} }
@@ -261,13 +262,68 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: $spacing-md; gap: $spacing-md;
margin-top: $spacing-xl; margin-top: 40px;
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
flex-direction: column; flex-direction: column;
align-items: stretch; 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 // Layout for detail page split actions
.actions-left, .actions-left,
.actions-right { .actions-right {
@@ -307,73 +363,117 @@
} }
.inquiry-detail-container { .inquiry-detail-container {
min-height: 600px; padding: 0;
margin: 0 auto; 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;
}
}
} }
// 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;
@media (max-width: $breakpoint-sm) { // Attachment section
padding: $spacing-2xl $spacing-lg $spacing-md; .detail-attachments {
}
// Status badge header styles now in components/_badges.scss
.inquiry-title {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-dark;
line-height: $line-height-tight;
margin-bottom: $spacing-lg;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-xl;
}
}
.inquiry-meta {
display: flex;
align-items: center;
gap: $spacing-lg;
flex-wrap: wrap;
font-size: $font-size-sm;
color: $text-gray;
@media (max-width: $breakpoint-sm) {
font-size: $font-size-xs;
gap: $spacing-md;
}
.meta-item {
display: flex;
align-items: center;
gap: $spacing-xs;
i {
color: $primary-blue;
}
strong {
color: $text-dark;
font-weight: $font-weight-medium;
}
}
}
}
// Attachment section
.detail-attachments {
padding: $spacing-lg $spacing-2xl; padding: $spacing-lg $spacing-2xl;
background: $gray-bg; background: $gray-bg;
border-bottom: 1px solid $border-gray; border-bottom: 1px solid $border-gray;
@@ -420,10 +520,10 @@
} }
} }
} }
} }
// Content section // Content section
.detail-content { .detail-content {
padding: $spacing-3xl $spacing-2xl; padding: $spacing-3xl $spacing-2xl;
min-height: 200px; min-height: 200px;
@@ -441,9 +541,9 @@
font-size: $font-size-sm; font-size: $font-size-sm;
} }
} }
}
} }
// Response section (answer from admin) // Response section (answer from admin)
.inquiry-response-card { .inquiry-response-card {
background: $white; background: $white;
@@ -693,100 +793,73 @@
// Supports: action-btn-primary, action-btn-secondary // Supports: action-btn-primary, action-btn-secondary
} }
// 260710 공개범위 / 조회수 / 첨부 // -----------------------------------------------------------------------------
// 비공개 게시물(목록) 본인·소속 법인 관리자 외에는 흐리게 + 링크 비활성 // New DJB Board Write Form (피드백/개선요청, Q&A 등록 )
.list-table-row--private { // -----------------------------------------------------------------------------
opacity: 0.55; .djb-board-write-container {
cursor: default; padding: 0;
.inquiry-private-label { .djb-board-write-header {
color: #94A3B8; margin-bottom: 30px;
font-style: italic; padding-bottom: 20px;
.title {
font-size: 24px;
font-weight: 700;
color: #000;
margin-bottom: 8px;
} }
}
// 접근 권한 없는 비공개 제목 클릭 불가 .subtitle {
.notice-title-link--disabled { font-size: 14px;
pointer-events: none; color: #7f8a95;
cursor: default; }
} }
// 상세 조회수 .djb-board-form {
.inquiry-detail-views { .form-group {
color: #94A3B8; margin-bottom: 30px;
font-size: 13px; }
} }
// 상세 첨부 다운로드 링크 (이미지 인라인 노출 없이 강제 다운로드) .form-actions {
.inquiry-detail-attach { display: flex;
margin-top: $spacing-md; justify-content: flex-end;
gap: 10px;
margin-top: 40px;
.inquiry-attach-link { .btn-cancel {
display: inline-flex; width: 100px;
align-items: center; height: 48px;
gap: 6px; background-color: #fff;
padding: 8px 14px; border: 1px solid #DFDFDF;
border: 1px solid #CBD5E1; border-radius: 8px;
border-radius: 6px; color: #515961;
color: #1E40AF; font-size: 15px;
font-size: 13px; font-weight: 700;
text-decoration: none; cursor: pointer;
transition: background-color 0.2s;
display: block;
&:hover { &:hover {
background-color: #F1F5F9; background-color: #f8f9fa;
} }
} }
}
.btn-submit {
// 목록 작성자 : 이름 아래 (법인명) 표기 width: 100px;
// .row-cell display:flex(가로) 이므로 세로 정렬 위해 column 지정 height: 48px;
.row-cell--writer { background-color: #2a69de;
flex-direction: column; border: 1px solid #2a69de;
justify-content: center; border-radius: 8px;
line-height: 1.3; color: #fff;
font-size: 15px;
.inquiry-writer-org { font-weight: 700;
color: #94A3B8; cursor: pointer;
font-size: 12px;
} &:hover {
} background-color: darken(#2a69de, 5%);
}
// 목록 상단 PTL_PROPERTY 공개범위 상한 표기
.inquiry-visibility-notice {
margin-left: 12px;
font-size: 13px;
font-weight: normal;
color: #64748B;
}
// 목록 처리상태 배지 글자/크기 축소
.list-table-body .inquiry-status-badge {
height: 24px;
padding: 0 10px;
font-size: 12px;
}
// 비공개 자물쇠 아이콘 (목록 제목 )
.inquiry-lock-icon {
vertical-align: -2px;
margin-right: 3px;
color: #64748B;
}
// 상세 헤더 공개범위 표기
.inquiry-detail-visibility {
display: inline-flex;
align-items: center;
gap: 2px;
color: #64748B;
font-size: 13px;
&--private {
color: #c0392b;
font-weight: 600;
.inquiry-lock-icon {
color: #c0392b;
} }
} }
} }
@@ -22,3 +22,291 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Withdrawal Link styles moved to components/_forms.scss (.form-actions--with-withdrawal) // 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;
}
}
}
}
+160 -64
View File
@@ -71,6 +71,7 @@
margin: 0 auto; margin: 0 auto;
padding: $spacing-3xl 0; padding: $spacing-3xl 0;
min-height: 600px; min-height: 600px;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
padding: $spacing-2xl $spacing-md; padding: $spacing-2xl $spacing-md;
} }
@@ -78,25 +79,91 @@
// Notice Detail Header - Title and Date in one row // Notice Detail Header - Title and Date in one row
.notice-detail-header { .notice-detail-header {
display: flex; padding-bottom: 24px;
justify-content: space-between;
align-items: center;
padding-bottom: $spacing-md;
border-bottom: 1px solid #212529; border-bottom: 1px solid #212529;
margin-bottom: $spacing-xl;
@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;
align-items: center;
gap: 15px;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: $spacing-sm; 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;
} }
} }
.notice-detail-title { .notice-detail-title {
font-size: 24px; font-size: 28px;
font-weight: $font-weight-bold; font-weight: 500;
color: #212529; color: #000;
line-height: 1;
margin: 0; margin: 0;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
@@ -104,24 +171,20 @@
} }
} }
.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 Attachment Section
.notice-detail-attachment { .notice-detail-attachment {
margin-bottom: $spacing-lg; margin-top: 20px;
margin-bottom: 20px;
padding: 15px 20px;
background: #f8fbfe;
border-radius: 8px;
display: flex;
align-items: center;
.attachment-list { .attachment-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: 8px;
} }
.attachment-item { .attachment-item {
@@ -131,25 +194,20 @@
} }
.notice-attachment-link { .notice-attachment-link {
display: inline-flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-xs; gap: 8px;
color: #515151;
font-size: 18px;
text-decoration: none; text-decoration: none;
color: #333;
transition: $transition-base; transition: $transition-base;
@media (max-width: $breakpoint-md) {
font-size: 14px;
}
&:hover { &:hover {
color: $primary-blue; color: $primary-blue;
} }
svg { svg {
flex-shrink: 0; flex-shrink: 0;
color: #515151; color: #333;
} }
&:hover svg { &:hover svg {
@@ -157,32 +215,68 @@
} }
.attachment-label { .attachment-label {
margin-right: $spacing-md; font-weight: 500;
font-size: 14px;
} }
.attachment-filename { .attachment-filename {
color: #515151; 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;
}
} }
} }
// Notice Content Section // Notice Content Section
.notice-detail-content { .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; margin-bottom: $spacing-3xl;
min-height: 300px;
} }
.notice-content-body { .notice-content-body {
background: #F6F9FB; font-size: 16px;
border-radius: 20px; color: #333;
padding: $spacing-2xl; line-height: 1.6;
min-height: 280px; min-height: 280px;
font-size: $font-size-base;
color: $text-dark;
line-height: $line-height-loose;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
padding: $spacing-lg;
min-height: 200px; min-height: 200px;
border-radius: 12px;
} }
// Rich text content styling // Rich text content styling
@@ -210,7 +304,8 @@
} }
} }
ul, ol { ul,
ol {
margin: $spacing-md 0; margin: $spacing-md 0;
padding-left: $spacing-xl; padding-left: $spacing-xl;
} }
@@ -218,17 +313,13 @@
li { li {
margin-bottom: $spacing-sm; margin-bottom: $spacing-sm;
} }
@include respond-to('sm') {
background: $white;
padding: 0;
}
} }
// Notice Detail Actions // Notice Detail Actions
.notice-detail-actions { .notice-detail-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 40px;
} }
// List Button - Figma Design // List Button - Figma Design
@@ -236,32 +327,32 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 200px; width: 247px;
height: 60px; height: 60px;
background: #E5E7EB; background: #2a69de;
color: #fff;
border: none; border: none;
border-radius: 12px; font-size: 20px;
font-family: $font-family-primary; font-weight: 700;
font-size: 18px; border-radius: 10px;
font-weight: $font-weight-bold;
color: #5F666C;
cursor: pointer; cursor: pointer;
transition: $transition-base; transition: $transition-base;
@media (max-width: $breakpoint-md) { @media (max-width: $breakpoint-md) {
width: auto; width: auto;
min-width: 80px; min-width: 120px;
padding: 0 16px; padding: 0 20px;
height: 44px; height: 48px;
font-size: 15px; font-size: 16px;
border-radius: 8px;
} }
&:hover { &:hover {
background: darken(#E5E7EB, 5%); background: darken(#2a69de, 10%);
transform: translateY(-2px);
} }
&:active { &:active {
transform: scale(0.98); transform: scale(0.98);
} }
} }
@@ -402,7 +493,12 @@
} }
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: $spacing-xl; margin-top: $spacing-xl;
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
@@ -425,7 +521,8 @@
} }
} }
ul, ol { ul,
ol {
margin: $spacing-md 0; margin: $spacing-md 0;
padding-left: $spacing-xl; padding-left: $spacing-xl;
} }
@@ -700,9 +797,8 @@
} }
.inquiry-content-body { .inquiry-content-body {
background: #F6F9FB;
border-radius: 20px; border-radius: 20px;
padding: $spacing-2xl; padding: 10px 20px;
min-height: 200px; min-height: 200px;
font-size: $font-size-base; font-size: $font-size-base;
color: $text-dark; color: $text-dark;
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@
margin: 0 auto; margin: 0 auto;
padding: $spacing-3xl 0; padding: $spacing-3xl 0;
margin-bottom: $spacing-2xl; margin-bottom: $spacing-2xl;
min-height: 750px; // Ensure the footer doesn't float when content is short
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
padding: $spacing-lg $spacing-md; padding: $spacing-lg $spacing-md;
@@ -67,8 +68,25 @@
// Extends .list-table from components/_tables.scss // Extends .list-table from components/_tables.scss
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.user-management-container { .user-management-container {
.list-table { .board-table {
margin-bottom: $spacing-2xl; 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 // Link styles in table cells
.row-cell a { .row-cell a {
@@ -82,6 +100,39 @@
} }
} }
// 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 { .table-controls {
@extend .common-title-bar; @extend .common-title-bar;
@@ -92,7 +143,14 @@
.search-box { .search-box {
.btn { .btn {
height: 44px; height: 48px;
background-color: #2a69de;
border: 1px solid #2a69de;
border-radius: 9px;
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
} }
} }
} }
@@ -101,15 +159,22 @@
// Current user badge (used in actions column) // Current user badge (used in actions column)
.user-current-badge { .user-current-badge {
display: inline-flex; display: inline-flex;
height: 30px;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 0 $spacing-sm; padding: 0 16px;
background-color: rgba($accent-purple, 0.1); height: 28px;
color: $accent-purple; background-color: #ecf0fa;
border-radius: $border-radius-sm; color: #2a69de;
font-size: 13px; border: 1px solid #4685ef;
font-weight: $font-weight-medium; 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
}
// 모바일에서 숨김 // 모바일에서 숨김
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
@@ -339,9 +404,12 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.5; opacity: 0.5;
} }
@@ -384,11 +452,6 @@
background-color: #8c959f; background-color: #8c959f;
} }
// 검토중 - 주황색 배경
&--reviewing {
background-color: #e08e0b;
}
// 종료 - 짙은 회색 배경 // 종료 - 짙은 회색 배경
&--closed { &--closed {
background-color: #4a5560; background-color: #4a5560;
@@ -420,6 +483,7 @@
// 사용자 관리 모바일 디자인 // 사용자 관리 모바일 디자인
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
// Show mobile actions, hide desktop actions // Show mobile actions, hide desktop actions
.actions-desktop { .actions-desktop {
display: none !important; display: none !important;
@@ -664,7 +728,8 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
i, svg { i,
svg {
font-size: 14px; font-size: 14px;
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -698,6 +763,7 @@
.list-table { .list-table {
.list-table-header { .list-table-header {
.header-cell { .header-cell {
// NO 컬럼 - 모바일에서 숨김 // NO 컬럼 - 모바일에서 숨김
&:nth-child(1) { &:nth-child(1) {
display: none; display: none;
@@ -734,6 +800,7 @@
.list-table-row { .list-table-row {
.row-cell { .row-cell {
// NO 컬럼 - 모바일에서 숨김 // NO 컬럼 - 모바일에서 숨김
&:nth-child(1), &:nth-child(1),
&.row-cell--number { &.row-cell--number {
@@ -759,7 +826,7 @@
display: none !important; display: none !important;
} }
> span:not(.notice-number):not(.file-icon) { >span:not(.notice-number):not(.file-icon) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -845,6 +912,7 @@
.list-table { .list-table {
.list-table-header { .list-table-header {
.header-cell { .header-cell {
// NO 컬럼 - 모바일에서 숨김 // NO 컬럼 - 모바일에서 숨김
&:nth-child(1) { &:nth-child(1) {
display: none; display: none;
@@ -867,6 +935,7 @@
.list-table-row { .list-table-row {
.row-cell { .row-cell {
// NO 컬럼 - 모바일에서 숨김 // NO 컬럼 - 모바일에서 숨김
&:nth-child(1), &:nth-child(1),
&.row-cell--number { &.row-cell--number {
@@ -891,7 +960,7 @@
display: none !important; display: none !important;
} }
> span:not(.notice-number):not(.file-icon) { >span:not(.notice-number):not(.file-icon) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -1059,7 +1128,7 @@
background: #e0efff; background: #e0efff;
} }
& + .dropdown-item { &+.dropdown-item {
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
@@ -0,0 +1,341 @@
// Webhook 신청/관리 (DJPGPT0004)
// App API Key 신청 3-step UI 톤을 답습한 자체 완결 스타일.
$wh-primary: #0b5fff;
$wh-primary-dark: #0847c4;
$wh-danger: #e03131;
$wh-border: #e2e6ee;
$wh-muted: #6b7280;
$wh-bg-soft: #f5f7fb;
.webhook-container {
max-width: 880px;
margin: 0 auto;
padding: 24px 16px 60px;
.field-help { color: $wh-muted; font-size: 13px; margin-top: 6px; }
.field-error,
.webhook-field-error { color: $wh-danger; font-size: 13px; margin-top: 6px; }
.req { color: $wh-danger; }
}
// ---------- 상태 ----------
.webhook-empty {
text-align: center;
padding: 64px 20px;
background: $wh-bg-soft;
border: 1px solid $wh-border;
border-radius: 14px;
.empty-icon { font-size: 48px; }
h3 { margin: 16px 0 8px; font-size: 20px; }
p { color: $wh-muted; margin-bottom: 24px; }
}
// ---------- 진행 표시 ----------
.webhook-steps {
display: flex;
gap: 8px;
margin-bottom: 28px;
.webhook-step {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: $wh-bg-soft;
color: $wh-muted;
font-size: 14px;
.step-no {
width: 22px; height: 22px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
background: #d5dbe6; color: #fff; font-size: 12px; font-weight: 700;
}
&.active {
background: rgba($wh-primary, 0.1);
color: $wh-primary;
.step-no { background: $wh-primary; }
}
}
}
// ---------- ----------
.webhook-field {
margin-bottom: 24px;
label { display: block; font-weight: 600; margin-bottom: 8px; }
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px 14px;
border: 1px solid $wh-border;
border-radius: 10px;
font-size: 14px;
&:focus { outline: none; border-color: $wh-primary; }
}
}
.webhook-eventtype-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
.eventtype-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid $wh-border;
border-radius: 10px;
cursor: pointer;
input { width: 18px; height: 18px; }
.eventtype-name { font-weight: 600; }
.eventtype-code { color: $wh-muted; font-size: 12px; margin-left: auto; }
&:hover { border-color: $wh-primary; }
}
}
// ---------- API 선택 ----------
.webhook-api-groups { margin: 16px 0 8px; }
.webhook-api-group {
margin-bottom: 24px;
.group-name { font-size: 16px; margin-bottom: 12px; }
}
.webhook-api-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
.webhook-api-card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border: 1px solid $wh-border;
border-radius: 10px;
cursor: pointer;
input { width: 18px; height: 18px; }
.api-name { font-weight: 600; }
.api-id { color: $wh-muted; font-size: 12px; margin-left: auto; }
&:hover { border-color: $wh-primary; }
}
}
// ---------- 액션 버튼 ----------
.webhook-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 32px;
&.center { justify-content: center; }
}
.btn-webhook-next,
.btn-webhook-create {
display: inline-flex; align-items: center; justify-content: center;
padding: 12px 28px;
background: $wh-primary; color: #fff;
border: none; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
&:hover { background: $wh-primary-dark; }
}
.btn-webhook-cancel {
display: inline-flex; align-items: center; justify-content: center;
padding: 12px 28px;
background: #fff; color: $wh-muted;
border: 1px solid $wh-border; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
&:hover { border-color: $wh-muted; }
}
.btn-webhook-danger {
padding: 12px 28px;
background: #fff; color: $wh-danger;
border: 1px solid $wh-danger; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer;
&:hover { background: $wh-danger; color: #fff; }
}
.btn-mini {
padding: 4px 12px;
background: $wh-bg-soft; color: $wh-primary;
border: 1px solid $wh-border; border-radius: 8px;
font-size: 12px; cursor: pointer;
&:hover { background: rgba($wh-primary, 0.1); }
}
.btn-copy {
padding: 10px 18px;
background: $wh-primary; color: #fff;
border: none; border-radius: 8px; cursor: pointer;
&:hover { background: $wh-primary-dark; }
}
// ---------- 등록 카드 ----------
.webhook-card {
border: 1px solid $wh-border;
border-radius: 14px;
padding: 24px;
.webhook-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
h3 { font-size: 18px; }
.webhook-created { color: $wh-muted; font-size: 13px; }
}
.webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid $wh-border;
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
.row-value { flex: 1; word-break: break-all; }
&.secret-row, .secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
}
.eventtype-badge,
.api-badge {
display: inline-block;
padding: 4px 10px; margin: 2px 4px 2px 0;
border-radius: 999px; font-size: 12px;
background: rgba($wh-primary, 0.1); color: $wh-primary;
}
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
.webhook-card-actions {
display: flex; gap: 12px; justify-content: flex-end;
margin-top: 24px;
}
}
// ---------- 완료 화면 ----------
.webhook-complete {
text-align: center;
padding: 24px;
.complete-icon { font-size: 48px; }
h3 { margin: 16px 0; }
.webhook-secret-box {
text-align: left;
max-width: 640px; margin: 24px auto;
padding: 20px;
background: $wh-bg-soft; border: 1px solid $wh-border; border-radius: 12px;
.secret-warning {
color: #92400e; background: #fef3c7;
border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: 13px;
}
label { display: block; font-weight: 600; margin-bottom: 8px; }
.secret-value-row {
display: flex; gap: 8px;
input { flex: 1; padding: 12px 14px; border: 1px solid $wh-border; border-radius: 10px; font-family: monospace; }
}
}
}
// ---------- figma s1-form-card 내부 재사용 ----------
// step1-wrap(s1 디자인) 카드 안에서 등록 카드 요소를
// 루트 카드 스타일은 .s1-form-card 것을 쓰고 내부 row/배지만 매핑한다.
.step1-wrap .s1-form-card {
.webhook-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
h3 { font-size: 18px; }
.webhook-created { color: $wh-muted; font-size: 13px; }
}
.webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid $wh-border;
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
.row-value { flex: 1; word-break: break-all; }
.secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
}
.eventtype-badge,
.api-badge {
display: inline-block;
padding: 4px 10px; margin: 2px 4px 2px 0;
border-radius: 999px; font-size: 12px;
background: rgba($wh-primary, 0.1); color: $wh-primary;
}
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
// 상태: s1 카드 안에서는 이중 박스 제거
.webhook-empty {
background: none;
border: none;
padding: 24px 0;
}
}
// s1-actions 취소/이전/삭제 버튼: 좌측 배치
.step1-wrap .s1-actions,
.step2-wrap .s1-actions,
.step3-wrap .s1-actions {
.btn-webhook-cancel,
.btn-webhook-danger { margin-right: auto; }
}
// ---------- 개발가이드 링크 ----------
.webhook-guide-link {
margin-top: 20px;
text-align: center;
a {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border: 1px dashed $wh-border;
border-radius: 10px;
color: $wh-primary;
font-size: 14px;
font-weight: 600;
text-decoration: none;
&:hover {
border-color: $wh-primary;
background: rgba($wh-primary, 0.05);
}
}
}
// ---------- 비밀번호 모달 ----------
.webhook-modal {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
.webhook-modal-box {
background: #fff; border-radius: 14px;
padding: 28px; width: 360px; max-width: 90vw;
h4 { font-size: 18px; margin-bottom: 8px; }
p { color: $wh-muted; font-size: 14px; margin-bottom: 16px; }
input {
width: 100%; padding: 12px 14px;
border: 1px solid $wh-border; border-radius: 10px; margin-bottom: 8px;
&:focus { outline: none; border-color: $wh-primary; }
}
.webhook-modal-actions {
display: flex; gap: 12px; justify-content: flex-end; margin-top: 12px;
.btn-webhook-next, .btn-webhook-cancel { padding: 10px 20px; font-size: 14px; }
}
}
}
@@ -1,43 +1,44 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
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> <body>
<th:block layout:fragment="title"> <th:block layout:fragment="contentFragment">
<div class="page-title-banner" style="margin-bottom: 24px;"> <!-- Hero Section -->
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <section class="service-hero">
<h1>API 마켓</h1> <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>
</th:block> <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="contentFragment"> <section class="container api-market-container">
<section class="api-market-container">
<!-- Sidebar Navigation --> <!-- Sidebar Navigation -->
<aside class="api-market-sidebar" id="apiSidebar"> <aside class="service-sidebar" id="apiSidebar">
<!-- Sidebar Header Image --> <nav class="service-nav">
<div class="api-sidebar-header">
<img th:src="@{/img/api_sidebar.png}" alt="API">
</div>
<nav class="api-sidebar-nav">
<!-- All APIs --> <!-- All APIs -->
<div class="menu-section"> <div class="service-nav__item js-menu-trigger" style="cursor: pointer;"
<div class="menu-title" th:classappend="${selected == '-1'} ? 'active'" data-group=""> th:classappend="${selected == '-1' || selected == null} ? 'service-nav__item--active' : ''" data-group="">
전체 전체
</div> </div>
</div>
<!-- Service Categories --> <!-- Service Categories -->
<div class="menu-section" th:each="service : ${services}"> <div class="service-nav__item js-menu-trigger" style="cursor: pointer;" th:each="service : ${services}"
<div class="menu-title" th:classappend="${selected == service.id} ? 'service-nav__item--active' : ''" th:data-group="${service.id}">
th:classappend="${selected == service.id} ? 'active'"
th:data-group="${service.id}">
[[${service.groupName}]] [[${service.groupName}]]
</div> </div>
</div>
</nav> </nav>
</aside> </aside>
@@ -59,15 +60,15 @@
</div> </div>
<div class="api-market-search"> <div class="api-market-search">
<form id="searchForm" th:action="@{/apis}" method="get"> <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="hidden" name="groupIds"
<input type="text" th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
class="search-input" <input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
name="keyword" placeholder="검색어를 입력하세요.">
th:value="${search.keyword}"
placeholder="어떤 API를 찾고 계신가요?">
<button type="submit" class="search-submit-btn" aria-label="검색"> <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"> <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> </svg>
</button> </button>
</form> </form>
@@ -76,34 +77,26 @@
<!-- API Cards Grid --> <!-- API Cards Grid -->
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}"> <div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}">
<div class="api-card" <div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button"
th:each="api : ${apis}"
th:data-href="@{/apis/detail(id=${api.apiId})}"
role="button"
tabindex="0"> tabindex="0">
<!-- Group Info -->
<div class="api-card-group"> <!-- Group Badge -->
<div class="api-card-group-icon"> <div class="api-card-badge">
<img th:if="${api.mainIcon}" <span th:text="${api.apiGroupName}">그룹 이름</span>
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> </div>
<!-- API Name --> <!-- API Name -->
<h3 class="api-card-name" th:text="${api.apiName}"> <h3 class="api-card-title" th:text="${api.apiName}">API 이름</h3>
API 이름
</h3>
<!-- API Description --> <!-- API Description -->
<p class="api-card-description" th:text="${api.apiSimpleDescription ?: 'API 설명이 없습니다.'}"> <p class="api-card-desc" th:text="${api.apiSimpleDescription ?: 'API 설명이 없습니다.'}">API 설명</p>
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>
</div> </div>
</div> </div>
@@ -116,13 +109,13 @@
</main> </main>
</section> </section>
</th:block> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// DOM Elements // DOM Elements
const sidebar = document.getElementById('apiSidebar'); const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle'); const mobileToggle = document.getElementById('mobileToggle');
@@ -133,9 +126,9 @@
// API Card Click Handlers // API Card Click Handlers
const apiCards = document.querySelectorAll('.api-card'); const apiCards = document.querySelectorAll('.api-card');
apiCards.forEach(function(card) { apiCards.forEach(function (card) {
// Click handler // Click handler
card.addEventListener('click', function() { card.addEventListener('click', function () {
const href = this.getAttribute('data-href'); const href = this.getAttribute('data-href');
if (href) { if (href) {
window.location.href = href; window.location.href = href;
@@ -143,7 +136,7 @@
}); });
// Keyboard accessibility // Keyboard accessibility
card.addEventListener('keypress', function(e) { card.addEventListener('keypress', function (e) {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
const href = this.getAttribute('data-href'); const href = this.getAttribute('data-href');
@@ -155,9 +148,9 @@
}); });
// Category/Service Selection // Category/Service Selection
const menuTitles = document.querySelectorAll('.menu-title'); const menuTitles = document.querySelectorAll('.js-menu-trigger');
menuTitles.forEach(function(title) { menuTitles.forEach(function (title) {
title.addEventListener('click', function(e) { title.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
const groupId = this.getAttribute('data-group'); const groupId = this.getAttribute('data-group');
@@ -175,7 +168,7 @@
}); });
// Search on Enter key // Search on Enter key
searchInput.addEventListener('keypress', function(e) { searchInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
searchForm.submit(); searchForm.submit();
@@ -184,7 +177,7 @@
// Mobile Menu Toggle // Mobile Menu Toggle
if (mobileToggle && sidebar && mobileOverlay) { if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) { mobileToggle.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -202,14 +195,14 @@
}); });
// Add touch event for better mobile support // Add touch event for better mobile support
mobileToggle.addEventListener('touchend', function(e) { mobileToggle.addEventListener('touchend', function (e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.click(); this.click();
}); });
// Close menu on overlay click // Close menu on overlay click
mobileOverlay.addEventListener('click', function() { mobileOverlay.addEventListener('click', function () {
sidebar.classList.remove('mobile-open'); sidebar.classList.remove('mobile-open');
this.classList.remove('active'); this.classList.remove('active');
mobileToggle.innerHTML = '☰'; mobileToggle.innerHTML = '☰';
@@ -217,8 +210,8 @@
}); });
// Close menu on category selection (mobile) // Close menu on category selection (mobile)
menuTitles.forEach(function(title) { menuTitles.forEach(function (title) {
title.addEventListener('click', function() { title.addEventListener('click', function () {
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
sidebar.classList.remove('mobile-open'); sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active'); mobileOverlay.classList.remove('active');
@@ -236,7 +229,7 @@
} }
// Close mobile menu on resize to desktop // Close mobile menu on resize to desktop
window.addEventListener('resize', function() { window.addEventListener('resize', function () {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) { if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
sidebar.classList.remove('mobile-open'); sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active'); mobileOverlay.classList.remove('active');
@@ -3,15 +3,37 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <th:block layout:fragment="contentFragment">
<div class="page-title-banner"> <div class="signup-guide-v2">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경"> <!-- Hero Section -->
<h1>FAQ</h1> <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>
</section> <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>
</div>
</div>
</section>
<th:block layout:fragment="contentFragment"> <!-- Main Content -->
<section class="user-management-container notice-list-container"> <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 --> <!-- Search and Filter Controls -->
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;"> <form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
@@ -24,13 +46,13 @@
</h2> </h2>
<div class="search-field"> <div class="search-field">
<input type="text" <input type="text" th:field="*{searchWrd}" placeholder="질문 또는 답변 내용으로 검색"
th:field="*{searchWrd}"
placeholder="질문 또는 답변 내용으로 검색"
onkeypress="if(event.keyCode === 13) { fn_search_faq(); return false; }"> onkeypress="if(event.keyCode === 13) { fn_search_faq(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_faq()"> <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"> <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"/> <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> </svg>
<span class="blind">검색</span> <span class="blind">검색</span>
</button> </button>
@@ -38,36 +60,24 @@
</div> </div>
</form> </form>
<!-- FAQ Table --> <!-- FAQ Accordion -->
<div class="list-table" th:if="${!#lists.isEmpty(page.content)}"> <div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
<!-- Table Header --> <div class="faq-item" th:each="faq : ${page.content}">
<div class="list-table-header"> <div class="faq-question">
<div class="header-cell" style="width: 80px;">NO</div> <span class="faq-question-q"
<div class="header-cell" style="flex: 1; min-width: 200px;">질문</div> style="font-family: 'OneShinhan', sans-serif; font-size: 20px; font-weight: 700; color: #555; margin-right: 15px;">Q</span>
<div class="header-cell" style="width: 120px;">등록일</div> <span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
</div> <span class="faq-icon">
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Table Body --> <path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round"
<div class="list-table-body"> stroke-linejoin="round" />
<div class="list-table-row"
th:each="faq, status : ${page.content}"
th:onclick="'location.href=\'' + @{/faq_view(id=${faq.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="@{/faq_view(id=${faq.id})}" class="notice-title-link">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
<span class="file-icon" th:if="${faq.fileId != null and !faq.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> </svg>
</span> </span>
</a>
</div> </div>
<div class="row-cell" style="width: 120px;" data-label="등록일" <div class="faq-answer">
th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.01.01</div> <div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
답변 내용이 여기에 표시됩니다.
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -75,15 +85,16 @@
<!-- Empty State --> <!-- Empty State -->
<div class="user-empty-state" th:if="${#lists.isEmpty(page.content)}"> <div class="user-empty-state" th:if="${#lists.isEmpty(page.content)}">
<div class="empty-icon"> <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="데이터 없음"> <svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none"
<circle cx="60" cy="60" r="50" fill="#F3F4F6"/> role="img" aria-label="데이터 없음">
<rect x="30" y="35" width="60" height="50" rx="4" fill="#E5E7EB" stroke="#D1D5DB" stroke-width="2"/> <circle cx="60" cy="60" r="50" fill="#F3F4F6" />
<line x1="40" y1="50" x2="80" y2="50" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/> <rect x="30" y="35" width="60" height="50" rx="4" fill="#E5E7EB" stroke="#D1D5DB" stroke-width="2" />
<line x1="40" y1="60" x2="72" y2="60" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/> <line x1="40" y1="50" x2="80" y2="50" 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"/> <line x1="40" y1="60" x2="72" y2="60" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
<circle cx="85" cy="80" r="14" fill="#F9FAFB" stroke="#D1D5DB" stroke-width="2"/> <line x1="40" y1="70" x2="65" y2="70" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" />
<line x1="95" y1="90" x2="102" y2="97" stroke="#D1D5DB" stroke-width="3" stroke-linecap="round"/> <circle cx="85" cy="80" r="14" fill="#F9FAFB" stroke="#D1D5DB" stroke-width="2" />
<line x1="81" y1="80" x2="89" y2="80" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round"/> <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> </svg>
</div> </div>
<h3>조회된 FAQ가 없습니다</h3> <h3>조회된 FAQ가 없습니다</h3>
@@ -94,8 +105,11 @@
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"> <div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
</div> </div>
</div>
</section> </section>
</th:block> </div>
</div>
</th:block>
</body> </body>
@@ -110,6 +124,37 @@
document.faqForm.page.value = 1; document.faqForm.page.value = 1;
document.faqForm.submit(); document.faqForm.submit();
} }
function decodeHTMLEntities(text) {
var textArea = document.createElement('textarea');
textArea.innerHTML = text;
return textArea.value;
}
document.addEventListener('DOMContentLoaded', function () {
// Decode HTML entities in FAQ answers
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) {
e.preventDefault();
const faqItem = this.closest('.faq-item');
const isActive = faqItem.classList.contains('active');
// Close all other items (optional - for single open behavior)
// document.querySelectorAll('.faq-item.active').forEach(function(item) {
// item.classList.remove('active');
// });
// Toggle active state
faqItem.classList.toggle('active');
});
});
});
</script> </script>
</th:block> </th:block>
@@ -3,91 +3,130 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경"> <img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>Q&A</h1> <h1>Q&A</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <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>
</div>
</section>
<!-- 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" th:attr="data-inquiry-id=${inquiry.id}"> <div class="inquiry-detail-container" th:attr="data-inquiry-id=${inquiry.id}">
<!-- Inquiry Header: Status Badge and Title --> <!-- Detail Header -->
<div class="inquiry-detail-header"> <div class="notice-detail-header">
<div class="inquiry-header-top"> <div class="inquiry-header-meta">
<span class="inquiry-status-badge" <span th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2026.06.24</span>
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}" <span>|</span>
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}"> <span th:text="${inquiry.inquirerDisplay}">EACTIVE</span>
답변대기 <span>|</span>
</span> <span th:text="|조회 ${inquiry.viewCount}|">조회 0</span>
<div class="inquiry-header-right"> <span>|</span>
<span class="inquiry-detail-writer" th:text="${inquiry.inquirerDisplay}">작성자 (법인명)</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>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-views" th:text="|조회 ${inquiry.viewCount}|">조회 0</span>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-visibility" <span class="inquiry-detail-visibility"
th:classappend="${inquiry.visibility == 'PRIVATE'} ? ' inquiry-detail-visibility--private'"> th:classappend="${inquiry.visibility == 'PRIVATE'} ? ' inquiry-detail-visibility--private'">
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개"> <svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="13" height="13"
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor"/> viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none"/> <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
</svg> </svg>
<span th:text="${inquiry.visibilityLabel}">법인공개</span> <span th:text="${inquiry.visibilityLabel}">법인공개</span>
</span> </span>
</div> </div>
<div class="notice-detail-title-wrapper">
<span class="inquiry-status-badge"
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">답변대기</span>
<h2 class="notice-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
</div> </div>
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
</div> </div>
<!-- Inquiry Content Section --> <!-- Detail Body -->
<div class="inquiry-detail-content"> <div class="notice-detail-body">
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}"> <div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
문의 내용이 여기에 표시됩니다. 문의 내용이 여기에 표시됩니다.
</div> </div>
<!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) --> <!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) -->
<div class="inquiry-detail-attach" th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"> <div class="inquiry-detail-attach" th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}">
<a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link" download> <a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link"
download>
첨부 이미지 다운로드 첨부 이미지 다운로드
</a> </a>
</div> </div>
</div> </div>
<!-- Admin Response Section --> <!-- Admin Response Section -->
<div class="inquiry-response-section" th:if="${inquiry.inquiryStatus == 'RESPONDED' or inquiry.inquiryStatus == 'CLOSED'}"> <div class="inquiry-response-section"
th:if="${inquiry.inquiryStatus == 'RESPONDED' or inquiry.inquiryStatus == 'CLOSED'}">
<div class="inquiry-response-header"> <div class="inquiry-response-header">
<span class="response-label">관리자 답변</span> <div class="response-admin">
<span class="response-date" th:text="${#temporals.format(inquiry.responseDate, 'yyyy.MM.dd')}">2025.11.02</span> <span class="admin-label">ADMIN</span>
<span class="admin-badge">관리자</span>
</div> </div>
<div class="inquiry-response-body" id="responseDetail" th:utext="${inquiry.responseDetail}"> <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>
</div> </div>
<!-- Comment Section (DJBank Custom DJPGPT0002) --> <!-- Comment Section (DJBank Custom DJPGPT0002) -->
<th:block th:replace="~{apps/community/djb/fragments-inquiry-comments :: commentSection(${inquiry}, ${commentWritable})}"></th:block> <th:block
th:replace="~{apps/community/djb/fragments-inquiry-comments :: commentSection(${inquiry}, ${commentWritable})}">
</th:block>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="inquiry-detail-actions"> <div class="inquiry-actions">
<div class="actions-left"> <button type="button" class="btn-inquiry-new" th:onclick="|location.href='@{/inquiry}'|">목록</button>
<button type="button" class="btn-inquiry-list" th:onclick="|location.href='@{/inquiry}'|">목록</button> <th:block
</div> th:if="${inquiry.inquiryStatus == 'PENDING' && inquiry.inquirer.id == #authentication.principal.id}">
<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"
<form id="deleteForm" th:action="@{/inquiry/{id}/delete(id=${inquiry.id})}" method="post" style="display: none;"></form> 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-new btn-inquiry-new--outline"
<button type="button" class="btn-inquiry-delete" onclick="confirmDelete()">삭제</button> 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>
</div> </div>
</section>
</div> </div>
</section> </div>
</section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
var successMsg = [[${success}]]; var successMsg = [[${success}]];
if (successMsg) { if (successMsg) {
customPopups.showAlert(successMsg); customPopups.showAlert(successMsg);
@@ -103,14 +142,14 @@
} }
function confirmDelete() { function confirmDelete() {
customPopups.showConfirm('문의글을 삭제하시겠습니까?', function(result) { customPopups.showConfirm('문의글을 삭제하시겠습니까?', function (result) {
if (result) { if (result) {
document.getElementById('deleteForm').submit(); document.getElementById('deleteForm').submit();
} }
}); });
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
var inquiryElement = document.getElementById('inquiryDetail'); var inquiryElement = document.getElementById('inquiryDetail');
if (inquiryElement) { if (inquiryElement) {
inquiryElement.innerHTML = decodeHTMLEntities(inquiryElement.innerHTML); inquiryElement.innerHTML = decodeHTMLEntities(inquiryElement.innerHTML);
@@ -11,98 +11,106 @@
</section> </section>
<th:block layout:fragment="contentFragment" th:with="isNew=${inquiry.id == null}"> <th:block layout:fragment="contentFragment" th:with="isNew=${inquiry.id == null}">
<section class="inquiry-form-container"> <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>
<!-- 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 --> <!-- Title Bar -->
<div class="register-title-bar"> <div class="djb-board-write-header">
<h2 class="register-title" th:text="${isNew ? '질문하기' : '질문 수정'}">질문하기</h2> <h2 class="title" th:text="${isNew ? '질문하기' : '질문 수정'}">질문하기</h2>
<span class="register-subtitle">궁금하신 사항을 작성해 주세요.</span> <p class="subtitle">궁금하신 사항을 작성해 주세요.</p>
</div> </div>
<!-- Form Content --> <!-- Form Content -->
<div class="register-form-container">
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}" <form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="register-form"> th:object="${inquiry}" method="post" enctype="multipart/form-data" class="djb-board-form">
<input type="hidden" th:field="*{id}" th:if="${!isNew}"> <input type="hidden" th:field="*{id}" th:if="${!isNew}">
<!-- Subject Field --> <!-- Subject Field -->
<div class="form-row"> <div class="form-group">
<div class="form-label-wrapper label-offset"> <label class="djb-label" for="inquirySubject">제목 <span class="required">*</span></label>
<span class="form-label-text">제목</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<input type="text" <input type="text"
id="inquirySubject" id="inquirySubject"
th:field="*{inquirySubject}" th:field="*{inquirySubject}"
class="form-input" class="djb-input"
placeholder="제목을 입력하세요" placeholder="제목을 입력하세요"
required required
maxlength="200"> maxlength="200">
</div> </div>
</div>
<!-- Content Field --> <!-- Content Field -->
<div class="form-row"> <div class="form-group">
<div class="form-label-wrapper"> <label class="djb-label" for="inquiryDetail">내용 <span class="required">*</span></label>
<span class="form-label-text">내용</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<textarea id="inquiryDetail" <textarea id="inquiryDetail"
th:field="*{inquiryDetail}" th:field="*{inquiryDetail}"
class="form-textarea" class="djb-textarea"
rows="10"
placeholder="내용을 입력하세요" placeholder="내용을 입력하세요"
required required
maxlength="2000"></textarea> maxlength="2000"></textarea>
<small class="char-counter-wrapper"> <div class="djb-char-counter-wrapper">
<span class="char-counter">0 / 2000</span> <span class="char-counter">0 / 2000</span>
</small>
</div> </div>
</div> </div>
<!-- Visibility Field --> <!-- Visibility Field -->
<div class="form-row"> <div class="form-group">
<div class="form-label-wrapper label-offset"> <label class="djb-label" for="inquiryVisibility">공개범위</label>
<span class="form-label-text">공개범위</span> <select id="inquiryVisibility" th:field="*{visibility}" class="djb-input">
</div>
<div class="form-field-wrapper">
<select id="inquiryVisibility" th:field="*{visibility}" class="form-input">
<option th:if="${allowAll}" value="ALL">전체공개</option> <option th:if="${allowAll}" value="ALL">전체공개</option>
<option th:if="${allowOrg}" value="ORG">법인공개</option> <option th:if="${allowOrg}" value="ORG">법인공개</option>
<option value="PRIVATE">비공개</option> <option value="PRIVATE">비공개</option>
</select> </select>
<small class="form-help-text">비공개 글은 본인과 소속 법인 관리자만 열람할 수 있습니다.</small> <small class="form-help-text">비공개 글은 본인과 소속 법인 관리자만 열람할 수 있습니다.</small>
</div> </div>
</div>
<!-- Image Attach Field --> <!-- Image Attach Field -->
<div class="form-row"> <div class="form-group">
<div class="form-label-wrapper label-offset"> <label class="djb-label" for="inquiryImage">이미지 첨부</label>
<span class="form-label-text">이미지 첨부</span> <input type="file" id="inquiryImage" name="image" class="djb-input"
</div>
<div class="form-field-wrapper">
<input type="file" id="inquiryImage" name="image" class="form-input"
accept="image/png,image/jpeg,image/gif"> accept="image/png,image/jpeg,image/gif">
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small> <small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small>
<small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}" <small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small> class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
</div> </div>
</div>
</form> </form>
</div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="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-cancel" th:onclick="|location.href='@{/inquiry}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary btn-submit-form"> <button type="button" class="btn-submit btn-submit-form">
<span th:text="${isNew ? '등록' : '수정'}">등록</span> <span th:text="${isNew ? '등록' : '수정'}">등록</span>
</button> </button>
</div> </div>
</div>
</section> </section>
</div>
</div>
</th:block> </th:block>
</body> </body>
@@ -3,15 +3,37 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <th:block layout:fragment="contentFragment">
<div class="page-title-banner"> <div class="signup-guide-v2">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경"> <!-- Hero Section -->
<h1>Q&A</h1> <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>
</section> <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>
</div>
</section>
<th:block layout:fragment="contentFragment"> <!-- Main Content -->
<section class="user-management-container inquiry-list-container"> <div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
<!-- 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 --> <!-- Search and Filter Controls -->
<form name="inquiryForm" th:action="@{/inquiry}" method="get" onsubmit="return false;"> <form name="inquiryForm" th:action="@{/inquiry}" method="get" onsubmit="return false;">
@@ -27,14 +49,13 @@
</h2> </h2>
<div class="search-field"> <div class="search-field">
<input type="text" <input type="text" name="searchWrd" th:value="${search.searchWrd}" placeholder="제목/내용 검색"
name="searchWrd"
th:value="${search.searchWrd}"
placeholder="제목 또는 내용으로 검색"
onkeypress="if(event.keyCode === 13) { fn_search_inquiry(); return false; }"> onkeypress="if(event.keyCode === 13) { fn_search_inquiry(); return false; }">
<button type="button" class="search-field-btn" onclick="fn_search_inquiry()"> <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"> <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"/> <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> </svg>
<span class="blind">검색</span> <span class="blind">검색</span>
</button> </button>
@@ -43,9 +64,9 @@
</form> </form>
<!-- Inquiry Table --> <!-- Inquiry Table -->
<div class="list-table" th:if="${!inquiries.isEmpty()}"> <div class="board-table" th:if="${!inquiries.isEmpty()}">
<!-- Table Header --> <!-- Table Header -->
<div class="list-table-header"> <div class="board-table-header">
<div class="header-cell" style="width: 80px;">NO</div> <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="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 100px;">작성자</div> <div class="header-cell" style="width: 100px;">작성자</div>
@@ -55,10 +76,9 @@
</div> </div>
<!-- Table Body --> <!-- Table Body -->
<div class="list-table-body"> <div class="board-table-body">
<div class="list-table-row" <div class="board-table-row" th:each="inquiry, status : ${inquiries}"
th:each="inquiry, status : ${inquiries}" th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
th:classappend="${inquiry.privatePlaceholder} ? ' list-table-row--private'"
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')" th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'"> th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO" <div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
@@ -67,10 +87,12 @@
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}" <a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
class="notice-title-link" class="notice-title-link"
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'"> th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span> <span class="notice-number"
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개"> th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor"/> <svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="14" height="14"
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
</svg> </svg>
<span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span> <span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span>
<span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}">질문 제목</span> <span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}">질문 제목</span>
@@ -102,7 +124,7 @@
<!-- Empty State --> <!-- Empty State -->
<div class="user-empty-state" th:if="${inquiries.isEmpty()}"> <div class="user-empty-state" th:if="${inquiries.isEmpty()}">
<h3>조회된 Q&A가 없습니다</h3> <h3>조회된 Q&A가 없습니다</h3>
<p>검색 조건을 변경하거나 새로운 질문을 등록해주세요.</p> <p style="color: #64748b; margin-top: 8px;">검색 조건을 변경하거나 새로운 질문을 등록해주세요.</p>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
@@ -110,21 +132,25 @@
</div> </div>
<!-- New Inquiry Button --> <!-- New Inquiry Button -->
<div class="inquiry-actions"> <div style="display: flex; justify-content: center; margin-top: 40px;">
<a th:href="@{/inquiry/new}" class="btn btn-primary btn-lg"> <a th:href="@{/inquiry/new}" class="btn btn-primary"
<span>질문하기</span> 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> </a>
</div> </div>
</section> </section>
</th:block> </section>
</div>
</div>
</th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
var successMsg = [[${success}]]; var successMsg = [[${ success }]];
if (successMsg) { if (successMsg) {
customPopups.showAlert(successMsg); customPopups.showAlert(successMsg);
} }
@@ -10,52 +10,72 @@
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="notice-detail-container"> <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>
</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 --> <!-- Notice Header: Title and Date -->
<div class="notice-detail-header"> <div class="notice-detail-header">
<h2 class="notice-detail-title"> <p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2026.06.24</p>
<span th:if="${portalNotice.noticeType == '3'}" <div class="notice-detail-title-wrapper">
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 class="notice-type-badge notice-type-badge--incident" th:if="${portalNotice.noticeType == '3'}">장애</span>
<span th:if="${portalNotice.noticeType == '2'}" <span class="notice-type-badge notice-type-badge--maintenance" th:if="${portalNotice.noticeType == '2'}">점검</span>
style="display:inline-block;padding:2px 10px;margin-right:8px;border-radius:12px;font-size:14px;vertical-align:middle;background:#fff4d6;color:#9a6700;">점검</span> <h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
<span th:text="${portalNotice.noticeSubject}">개인정보처리 방침 정정 공지</span> </div>
</h2>
<span class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
</div> </div>
<!-- 장애/점검 정보 영역 --> <!-- 장애/점검 정보 영역 -->
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}" <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 class="detail-table">
<table style="width:100%;border-collapse:collapse;">
<colgroup> <colgroup>
<col style="width:120px;"><col><col style="width:120px;"><col> <col style="width: 160px;"><col><col style="width: 160px;"><col>
</colgroup> </colgroup>
<tbody>
<tr> <tr>
<th style="text-align:left;padding:6px 8px;">시작</th> <th>시작</th>
<td style="padding:6px 8px;" <td th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">-</td>
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy.MM.dd HH:mm') : '-'}">-</td> <th></th>
<th style="text-align:left;padding:6px 8px;">종료</th> <td th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">-</td>
<td style="padding:6px 8px;"
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy.MM.dd HH:mm') : '진행중'}">-</td>
</tr> </tr>
<tr th:if="${portalNotice.incidentType}"> <tr>
<th style="text-align:left;padding:6px 8px;">상태</th> <th>상태</th>
<td colspan="3" style="padding:6px 8px;" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td> <td th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
</tr> <th>영향 API</th>
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}"> <td>
<th style="text-align:left;padding:6px 8px;vertical-align:top;">영향 API</th> <th:block th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<td colspan="3" style="padding:6px 8px;"> <span th:each="api, iterStat : ${portalNotice.affectedApis}">
<ul style="margin:0;padding-left:18px;"> <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>
<li th:each="api : ${portalNotice.affectedApis}"> </span>
<strong th:text="${api.apiId}">API_ID</strong> </th:block>
<span th:if="${api.apiName != null and !api.apiName.isEmpty()}" <th:block th:unless="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">-</th:block>
th:text="| - ${api.apiName}|"></span>
</li>
</ul>
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
@@ -64,7 +84,7 @@
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}"> <div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}"> <div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link"> <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"> <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"/> <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> </svg>
<span class="attachment-label">첨부파일</span> <span class="attachment-label">첨부파일</span>
@@ -87,7 +107,10 @@
</div> </div>
</div> </div>
</section> </section>
</div>
</div>
</th:block>
</body> </body>
@@ -3,15 +3,43 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경"> <img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>공지사항</h1> <h1>공지사항</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="user-management-container notice-list-container"> <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>
</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">
<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 --> <!-- Search and Filter Controls -->
<form name="noticeForm" th:action="@{/portalnotice}" method="get" onsubmit="return false;"> <form name="noticeForm" th:action="@{/portalnotice}" method="get" onsubmit="return false;">
@@ -23,14 +51,13 @@
</h2> </h2>
<div class="search-field"> <div class="search-field">
<input type="text" <input type="text" name="searchSubjectOrDetail" th:value="${search.searchSubjectOrDetail}"
name="searchSubjectOrDetail" placeholder="제목/내용 검색" onkeypress="if(event.keyCode === 13) { fn_search_notice(); return false; }">
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()"> <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"> <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"/> <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> </svg>
<span class="blind">검색</span> <span class="blind">검색</span>
</button> </button>
@@ -39,38 +66,34 @@
</form> </form>
<!-- Notice Table --> <!-- Notice Table -->
<div class="list-table" th:if="${!portalNotices.isEmpty()}"> <div class="board-table" th:if="${!portalNotices.isEmpty()}">
<!-- Table Header --> <!-- Table Header -->
<div class="list-table-header"> <div class="board-table-header">
<div class="header-cell" style="width: 80px;">NO</div> <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="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 120px;">등록일</div> <div class="header-cell" style="width: 120px;">등록일</div>
</div> </div>
<!-- Table Body --> <!-- Table Body -->
<div class="list-table-body"> <div class="board-table-body">
<div class="list-table-row" <div class="board-table-row" th:each="notice, status : ${portalNotices}"
th:each="notice, status : ${portalNotices}"
th:onclick="'location.href=\'' + @{/portalnotice/detail(id=${notice.id})} + '\''" th:onclick="'location.href=\'' + @{/portalnotice/detail(id=${notice.id})} + '\''"
style="cursor: pointer;"> style="cursor: pointer;">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO" <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> 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="제목"> <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"> <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-number"
<span class="notice-fix-badge" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
th:if="${notice.fixYn == 'Y'}" <span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
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'}">장애</span>
<span class="notice-type-badge notice-type-badge--incident" <span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span>
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 th:text="${notice.noticeSubject}">공지사항 제목</span>
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}"> <span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path 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"/> <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> </svg>
</span> </span>
</a> </a>
@@ -91,7 +114,10 @@
</div> </div>
</section> </section>
</th:block> </section>
</div>
</div>
</th:block>
</body> </body>
@@ -111,9 +137,10 @@
document.noticeForm.submit(); document.noticeForm.submit();
} }
$(function() { $(function () {
fn_init(); fn_init();
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -3,37 +3,46 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경"> <img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>피드백/개선요청</h1> <h1>피드백/개선요청</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<style> <div class="signup-guide-v2">
.recent-apps { margin: 0 0 24px; } <!-- Hero Section -->
.recent-apps-title { font-size: 15px; font-weight: 600; color: #334155; margin: 0 0 10px; } <section class="service-hero">
.recent-apps-list { list-style: none; margin: 0; padding: 0; border: 1px solid #E2E8F0; border-radius: 8px; overflow: hidden; } <div class="service-hero__inner">
.recent-apps-item + .recent-apps-item { border-top: 1px solid #E2E8F0; } <div class="service-hero__icon-wrapper">
.recent-apps-head { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 16px; <img th:src="@{/img/keyimage/feedback_img.svg}" alt="피드백 아이콘"
background: #F8FAFC; border: 0; cursor: pointer; text-align: left; font: inherit; } style="width: 100%; height: 100%; object-fit: contain;" />
.recent-apps-head:hover { background: #F1F5F9; } </div>
.recent-apps-subject { flex: 1; min-width: 0; font-size: 14px; font-weight: 500; color: #1E293B; <div class="service-hero__content">
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } <div class="service-hero__badge">
.recent-apps-date { flex-shrink: 0; font-size: 12px; color: #94A3B8; } <span class="service-hero__badge-dot"></span>
.recent-apps-arrow { flex-shrink: 0; color: #94A3B8; transition: transform .2s ease; } <span class="service-hero__badge-text">피드백/개선요청</span>
.recent-apps-item.is-open .recent-apps-arrow { transform: rotate(180deg); } </div>
.recent-apps-body { display: none; padding: 14px 16px; background: #fff; } <h1 class="service-hero__title">피드백/개선요청</h1>
.recent-apps-item.is-open .recent-apps-body { display: block; } <p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
.recent-apps-detail { margin: 0; font-size: 13px; line-height: 1.6; color: #475569; white-space: pre-wrap; word-break: break-word; } </div>
</style> </div>
<section class="inquiry-form-container"> </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 --> <!-- Title Bar -->
<div class="register-title-bar"> <div class="djb-board-write-header">
<h2 class="register-title">피드백/개선요청</h2> <h2 class="title">피드백 / 개선 요청</h2>
<span class="register-subtitle">DJBank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</span> <p class="subtitle">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</p>
</div> </div>
<!-- 내가 작성한 최근 글(최대 3건) 아코디언 --> <!-- 내가 작성한 최근 글(최대 3건) 아코디언 -->
@@ -58,124 +67,55 @@
</div> </div>
<!-- Form Content --> <!-- Form Content -->
<div class="register-form-container">
<form name="partnershipForm" id="partnershipForm" th:action="@{/partnership}" th:object="${partnership}" <form name="partnershipForm" id="partnershipForm" th:action="@{/partnership}" th:object="${partnership}"
enctype="multipart/form-data" method="post" class="register-form"> enctype="multipart/form-data" method="post" class="djb-board-form">
<!-- 제목 Field --> <!-- 제목 Field -->
<div class="form-row"> <div class="form-group">
<div class="form-label-wrapper label-offset"> <label class="djb-label" for="bizSubject">제목 <span class="required">*</span></label>
<span class="form-label-text">제목</span> <input type="text" id="bizSubject" th:field="*{bizSubject}" class="djb-input"
<span class="required-badge">필수</span> placeholder="제안하고자 하는 제목을 입력해주세요" required maxlength="200">
</div>
<div class="form-field-wrapper">
<input type="text"
id="bizSubject"
th:field="*{bizSubject}"
class="form-input"
placeholder="제안하고자 하는 제목을 입력해 주세요"
required
maxlength="200">
</div>
</div> </div>
<!-- 사업 내용 Field --> <!-- 내용 Field -->
<div class="form-row"> <div class="form-group">
<div class="form-label-wrapper"> <label class="djb-label" for="bizDetail">내용 <span class="required">*</span></label>
<span class="form-label-text">내용</span> <textarea id="bizDetail" th:field="*{bizDetail}" class="djb-textarea"
<span class="required-badge">필수</span> placeholder="제안하고자 하는 내용을 상세히 입력해주세요" required maxlength="4000"></textarea>
</div> <div class="djb-char-counter-wrapper">
<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> <span class="char-counter">0 / 4000</span>
</small>
</div> </div>
</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> </form>
</div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="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-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary btn-submit-form"> <button type="button" class="btn-submit btn-submit-form">작성완료</button>
<span>작성 완료</span>
</button>
</div> </div>
</div>
</section> </section>
</th:block> </div>
</div>
</th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
const errorMsg = [[${error}]]; const errorMsg = [[${ error }]];
customPopups.showAlert(errorMsg); customPopups.showAlert(errorMsg);
}); });
</script> </script>
<script th:if="${success}" th:inline="javascript"> <script th:if="${success}" th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
customPopups.showAlert([[${success}]]); customPopups.showAlert([[${ success }]]);
}); });
</script> </script>
<script th:inline="javascript"> <script th:inline="javascript">
$(function () { $(function () {
// 최근 글 아코디언 토글
document.querySelectorAll('.recent-apps-head').forEach(function (head) {
head.addEventListener('click', function () {
head.closest('.recent-apps-item').classList.toggle('is-open');
});
});
const btnSubmit = document.querySelector('.btn-submit-form'); const btnSubmit = document.querySelector('.btn-submit-form');
const fileInput = document.getElementById('file'); const fileInput = document.getElementById('file');
const fileDisplayText = document.getElementById('fileDisplayText'); const fileDisplayText = document.getElementById('fileDisplayText');
@@ -191,7 +131,7 @@
charCounter.textContent = currentLength + ' / ' + maxLength; charCounter.textContent = currentLength + ' / ' + maxLength;
// Character counter for textarea // Character counter for textarea
bizDetailTextarea.addEventListener('input', function() { bizDetailTextarea.addEventListener('input', function () {
const currentLength = this.value.length; const currentLength = this.value.length;
const maxLength = this.getAttribute('maxlength'); const maxLength = this.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength; charCounter.textContent = currentLength + ' / ' + maxLength;
@@ -228,6 +168,7 @@
form.submit(); form.submit();
}); });
if (fileInput) {
fileInput.addEventListener('change', function () { fileInput.addEventListener('change', function () {
const file = fileInput.files[0]; const file = fileInput.files[0];
@@ -243,26 +184,34 @@
} }
/*[/]*/ /*[/]*/
if (fileDisplayText && fileInputDisplay && btnRemoveFile) {
fileDisplayText.textContent = file.name; fileDisplayText.textContent = file.name;
fileDisplayText.classList.add('has-file'); fileDisplayText.classList.add('has-file');
fileInputDisplay.classList.add('has-file'); fileInputDisplay.classList.add('has-file');
btnRemoveFile.style.display = 'flex'; btnRemoveFile.style.display = 'flex';
} }
}
}); });
}
// File remove handler // File remove handler
if (btnRemoveFile) {
btnRemoveFile.addEventListener('click', function (e) { btnRemoveFile.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
fileInput.value = ''; if (fileInput) fileInput.value = '';
if (fileDisplayText) {
fileDisplayText.textContent = '선택된 파일이 없습니다'; fileDisplayText.textContent = '선택된 파일이 없습니다';
fileDisplayText.classList.remove('has-file'); fileDisplayText.classList.remove('has-file');
fileInputDisplay.classList.remove('has-file'); }
btnRemoveFile.style.display = 'none'; if (fileInputDisplay) fileInputDisplay.classList.remove('has-file');
this.style.display = 'none';
}); });
}
// Focus on subject field // Focus on subject field
document.getElementById('bizSubject').focus(); document.getElementById('bizSubject').focus();
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
File diff suppressed because one or more lines are too long
@@ -1,138 +1,339 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1> <h1>앱관리</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="app-management-container"> <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;
}
.app-management-content {
flex: 1;
min-width: 0;
}
.app-management-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.app-management-header h2 {
font-size: 24px;
font-weight: 700;
color: #000;
margin: 0;
}
.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.status-pending {
background: #fdf4d4;
color: #ef9546;
}
.app-card-badge.status-approved {
background: #1b4ab7;
color: #ffffff;
}
.app-card-badge.status-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 --> <!-- App List Container -->
<div class="app-list-container"> <div class="app-list-container-figma">
<!-- App Requests (Pending) --> <!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}"> <th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-list-item" <a class="app-card-figma" th:each="request : ${appRequests}"
th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}"> th:href="@{/myapikey/app_request_detail(id=${request.id})}">
<!-- App Icon --> <!-- App Icon -->
<div class="app-list-icon"> <div class="app-card-icon-box">
<img th:if="${request.appIconFileId != null}" <img th:if="${request.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" alt="App Icon">
alt="App Icon" <svg th:unless="${request.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24"
class="app-icon-image"> fill="none" stroke="#64748b" stroke-width="1.5">
<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> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle> <circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline> <polyline points="21 15 16 10 5 21"></polyline>
</svg> </svg>
</div> </div>
</div>
<!-- App Info --> <!-- App Info -->
<div class="app-list-info"> <div class="app-card-info">
<div class="app-list-header"> <div class="app-card-header">
<!-- App Name -->
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
<!-- Status Badge --> <!-- Status Badge -->
<span class="app-status-badge status-pending" <span class="app-card-badge status-pending"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}"> th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
승인대기 승인대기
</span> </span>
<!-- App Name --> </div>
<h3 class="app-list-name" th:text="${request.clientName}">앱 이름</h3> <!-- App Description & Expected Completion Date -->
<!-- Expected Completion Date --> <div class="app-card-footer-row">
<span class="app-list-expect-date" <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.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}" 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})|"> th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
예상 완료일 : 05월 30일 (토) 예상 완료일 : 05월 30일 (토)
</span> </span>
</div> </div>
<!-- App Description -->
<p class="app-list-description"
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
</div> </div>
</a> </a>
</th:block> </th:block>
<!-- API Keys (Approved) --> <!-- API Keys (Approved/Inactive) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}"> <th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-list-item" <a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}"> th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
<!-- App Icon --> <!-- App Icon -->
<div class="app-list-icon"> <div class="app-card-icon-box">
<img th:if="${apikey.appIconFileId != null}" <img th:if="${apikey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apikey.appIconFileId})}" th:src="@{/file/download(fileSn=1,fileId=${apikey.appIconFileId})}" alt="App Icon">
alt="App Icon" <svg th:unless="${apikey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24"
class="app-icon-image"> fill="none" stroke="#64748b" stroke-width="1.5">
<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> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle> <circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline> <polyline points="21 15 16 10 5 21"></polyline>
</svg> </svg>
</div> </div>
</div>
<!-- App Info --> <!-- App Info -->
<div class="app-list-info"> <div class="app-card-info">
<div class="app-list-header"> <div class="app-card-header">
<!-- App Name -->
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
<!-- Status Badge --> <!-- Status Badge -->
<span class="app-status-badge" <span class="app-card-badge"
th:classappend="${apikey.appstatus == '1' ? 'status-approved' : apikey.appstatus == '0' ? 'status-inactive' : 'status-pending'}" th:classappend="${apikey.appstatus == '1' ? 'status-approved' : apikey.appstatus == '0' ? 'status-inactive' : 'status-pending'}"
th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}"> th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}">
승인 승인
</span> </span>
<!-- App Name -->
<h3 class="app-list-name" th:text="${apikey.clientname}">앱 이름</h3>
</div> </div>
<!-- App Description --> <!-- App Description -->
<p class="app-list-description" <p class="app-card-desc" th:text="${apikey.appDescription != null ? apikey.appDescription : '설명 없음'}">
th:text="${apikey.appDescription != null ? apikey.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다. 앱 설명이 여기에 표시됩니다.
</p> </p>
</div> </div>
</a> </a>
</th:block> </th:block>
<!-- Empty State --> <!-- Empty State -->
<div class="app-list-empty" th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}"> <div class="app-list-empty-figma"
<div class="empty-icon">🔑</div> 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>
</svg>
<h3>등록된 앱이 없습니다</h3> <h3>등록된 앱이 없습니다</h3>
<p>새로운 앱을 생성하여 API를 사용해보세요.</p> <p>새로운 앱을 생성하여 API를 사용해보세요.</p>
</div> </div>
</div> </div>
<!-- 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> </div>
</section> </div>
</th:block> </div>
</th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// App creation button handler // App creation button handler
const requestApiKeyBtn = document.getElementById('requestApiKey'); const requestApiKeyBtn = document.getElementById('requestApiKey');
@@ -157,6 +358,7 @@
} }
}); });
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -11,460 +11,379 @@
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar --> <!-- Sidebar -->
<div class="common-title-bar"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<h2 class="common-title">앱 수정</h2>
<span class="common-subtitle">서비스 중인 앱 정보를 수정합니다.</span>
</div>
<!-- Progress Indicator --> <!-- Content Area -->
<div class="register-progress"> <div class="app-management-content">
<div class="progress-steps"> <div class="step1-wrap">
<!-- 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>
<!-- Dots --> <!-- Title -->
<div class="step-dots"> <h2 class="s1-title">앱 수정</h2>
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 --> <!-- Progress Steps Card -->
<div class="progress-step-item"> <div class="s1-progress-card">
<div class="step-icon-wrapper"> <div class="s1-steps">
<div class="step-icon"> <!-- Step 1 Active: 앱 정보수정 (form/input icon) -->
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36"> <div class="s1-step s1-step--active">
</div> <div class="s1-step-circle">
</div> <svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
<div class="step-label">API 선택</div> xmlns="http://www.w3.org/2000/svg">
</div> <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" />
<!-- Dots --> <line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<div class="step-dots"> <line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<span></span><span></span><span></span>
</div>
<!-- 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>
<div class="step-label">완료</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> </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>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span> <span th:text="${error}"></span>
</div> </div>
<!-- Form Content --> <!-- Form Card -->
<div class="register-form-container"> <div class="s1-form-card">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" th:object="${apiKeyModification}" class="register-form" enctype="multipart/form-data"> <form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}"
th:object="${apiKeyModification}" enctype="multipart/form-data">
<!-- Hidden field for clientId --> <!-- Hidden clientId -->
<input type="hidden" th:field="*{clientId}"/> <input type="hidden" th:field="*{clientId}"/>
<!-- App Icon --> <!-- 앱 아이콘 -->
<div class="form-row"> <div class="s1-field">
<div class="form-label-wrapper"> <label class="s1-label">앱 아이콘</label>
<span class="form-label-text">앱 아이콘</span> <div class="s1-upload-box" id="iconDropZone">
</div>
<div class="form-field-wrapper"> <!-- 기존 파일 ID로 이미지 노출 -->
<div class="icon-upload-box"> <img class="s1-preview-img" id="iconPreviewImage1"
<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:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}" th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="Icon Preview" alt="미리보기" style="display: block;">
style="display: block;">
<!-- Show image from data if available --> <!-- 데이터에서 이미지 노출 -->
<img id="iconPreviewImage" <img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}" th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}" th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="Icon Preview" alt="미리보기" style="display: block;">
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" <!-- JS용 이미지 타겟 -->
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}" <img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
onclick="removeAppIcon()" title="아이콘 삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<line x1="18" y1="6" x2="6" y2="18"></line> <img th:src="@{/img/icon/img_icon.png}">
<line x1="6" y1="6" x2="18" y2="18"></line> <p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
</svg> <p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
</button> <button type="button" class="s1-btn-upload"
</div> onclick="document.getElementById('appIconFile').click()">
<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> </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>
<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> </div>
<!-- App Name --> <!-- 앱 이름 -->
<div class="form-row"> <div class="s1-field">
<div class="form-label-wrapper label-offset"> <label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
<span class="form-label-text">앱 이름</span> <input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
<span class="required-badge">필수</span> placeholder="앱 이름을 입력하세요." required maxlength="100">
</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>
<div class="form-field-wrapper">
<textarea id="appDescription" <!-- 앱 설명 -->
th:field="*{appDescription}" <div class="s1-field">
class="form-textarea" <label class="s1-label">앱 설명 <span class="s1-required">*</span></label>
placeholder="앱 설명을 입력하세요." <textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
rows="5" placeholder="앱 설명을 입력하세요." required maxlength="500"></textarea>
required <div class="s1-counter-row">
maxlength="500"></textarea> <span id="charCounter" class="s1-counter">0 / 500</span>
<small class="char-counter-wrapper">
<span class="char-counter">0 / 500</span>
</small>
</div> </div>
</div> </div>
<!-- Callback URL --> <!-- Call Back URL -->
<div class="form-row"> <div class="s1-field">
<div class="form-label-wrapper label-offset"> <label class="s1-label">Call Back URL</label>
<span class="form-label-text">Call Back URL</span> <input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
</div>
<div class="form-field-wrapper">
<input type="text"
id="callbackUrl"
th:field="*{callbackUrl}"
class="form-input"
placeholder="URL을 입력해 주세요."> placeholder="URL을 입력해 주세요.">
</div> </div>
</div>
<!-- IP Whitelist --> <!-- 화이트 리스트 -->
<div class="form-row"> <div class="s1-field">
<div class="form-label-wrapper label-offset"> <label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
<span class="form-label-text">화이트리스트</span>
</div> <!-- 입력행 -->
<div class="form-field-wrapper"> <div class="s1-ip-row">
<div class="ip-input-row"> <input type="text" id="ipWhitelistInput" class="s1-input" placeholder="서버 IP(111.111.111.111)">
<input type="text" <button type="button" class="s1-ip-btn s1-ip-btn--add" onclick="addIpAddress()">
id="ipWhitelistInput" <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
class="form-input ip-input" stroke-width="2.5">
placeholder="서버 IP (111.111.111.111)"> <line x1="12" y1="5" x2="12" y2="19" />
<button type="button" class="btn-add-ip" onclick="addIpAddress()"> <line x1="5" y1="12" x2="19" y2="12" />
+ 추가 </svg>
추가
</button> </button>
</div> </div>
<!-- IP List Display --> <!-- 추가된 IP 목록 -->
<div class="ip-list" id="ipList" style="display: none;"> <div id="ipList" style="display:none;">
<div class="ip-items" id="ipItems"> <div id="ipItems"></div>
<!-- Dynamically added IP items -->
</div>
</div> </div>
<!-- Hidden input to store IP list -->
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value=""> <input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div> </div>
</div>
</form> </form>
</div> </div>
<!-- Form Actions --> <!-- 다음 버튼 -->
<div class="form-actions"> <div class="s1-actions" style="justify-content: flex-end; gap: 10px;">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn btn-submit btn-secondary"> <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>
</a>
<button type="submit" form="modifyStep1Form" class="btn-primary btn-submit">
다음
</button>
</div> </div>
</section> </div><!-- /step1-wrap -->
</th:block> </div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
// IP address list management
let ipAddresses = []; let ipAddresses = [];
// Remove app icon
function removeAppIcon() { function removeAppIcon() {
const appIconInput = document.getElementById('appIcon'); const fileInput = document.getElementById('appIconFile');
const iconPreviewImages = document.querySelectorAll('img[id^="iconPreviewImage"]'); // Select all preview images const previewJS = document.getElementById('iconPreviewImageJS');
const iconPlaceholder = document.getElementById('iconPlaceholder'); const preview1 = document.getElementById('iconPreviewImage1');
const btnRemoveIcon = document.getElementById('btnRemoveIcon'); const preview2 = document.getElementById('iconPreviewImage2');
const placeholder = document.getElementById('iconPlaceholder');
const removeBtn = document.getElementById('btnRemoveIcon');
// Clear file input fileInput.value = '';
appIconInput.value = ''; if(preview1) preview1.style.display = 'none';
if(preview2) preview2.style.display = 'none';
// Hide all preview images previewJS.style.display = 'none';
iconPreviewImages.forEach(img => { previewJS.src = '';
img.style.display = 'none'; placeholder.style.display = 'flex';
if (img.id === 'iconPreviewImageJS') img.src = ''; removeBtn.style.display = 'none';
});
// Show placeholder
iconPlaceholder.style.display = 'flex';
// Hide delete button
btnRemoveIcon.style.display = 'none';
} }
// Add IP address to the list
function addIpAddress() { function addIpAddress() {
const ipInput = document.getElementById('ipWhitelistInput'); const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim(); const ipValue = ipInput.value.trim();
// Validate IP format
if (!ipValue) { if (!ipValue) {
customPopups.showAlert('IP 주소를 입력해주세요.'); customPopups.showAlert('IP 주소를 입력해주세요.');
return; return;
} }
// Basic IP validation (supports IP and CIDR notation)
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) { if (!ipPattern.test(ipValue)) {
customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24'); customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return; return;
} }
// Check for duplicates
if (ipAddresses.includes(ipValue)) { if (ipAddresses.includes(ipValue)) {
customPopups.showAlert('이미 추가된 IP 주소입니다.'); customPopups.showAlert('이미 추가된 IP 주소입니다.');
return; return;
} }
// Add to array
ipAddresses.push(ipValue); ipAddresses.push(ipValue);
renderIpList();
// Update UI
updateIpList();
// Clear input
ipInput.value = ''; ipInput.value = '';
ipInput.focus(); ipInput.focus();
} }
// Remove IP address from the list
function removeIpAddress(ip) { function removeIpAddress(ip) {
const index = ipAddresses.indexOf(ip); ipAddresses = ipAddresses.filter(function (i) { return i !== ip; });
if (index > -1) { renderIpList();
ipAddresses.splice(index, 1);
updateIpList();
}
} }
// Update IP list display function renderIpList() {
function updateIpList() {
const ipList = document.getElementById('ipList'); const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems'); const ipItems = document.getElementById('ipItems');
const ipWhitelistHidden = document.getElementById('ipWhitelist'); const hiddenInput = document.getElementById('ipWhitelist');
// Show/hide list container
if (ipAddresses.length > 0) { if (ipAddresses.length > 0) {
ipList.style.display = 'block'; ipList.style.display = 'block';
ipItems.innerHTML = ipAddresses.map(function (ip) {
// Build IP items HTML return '<div class="s1-ip-item">' +
let itemsHTML = ''; '<div class="s1-ip-display">' + ip + '</div>' +
ipAddresses.forEach(function(ip) { '<button type="button" class="s1-ip-btn s1-ip-btn--remove" onclick="removeIpAddress(\'' + ip + '\')">' +
itemsHTML += ` '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 11H5v2h14z"/></svg>' +
<div class="ip-item"> '제거</button>' +
<div class="ip-display">${ip}</div> '</div>';
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')"> }).join('');
- 제거
</button>
</div>
`;
});
ipItems.innerHTML = itemsHTML;
} else { } else {
ipList.style.display = 'none'; ipList.style.display = 'none';
ipItems.innerHTML = ''; ipItems.innerHTML = '';
} }
// Update hidden input with comma-separated values hiddenInput.value = ipAddresses.join(',');
ipWhitelistHidden.value = ipAddresses.join(',');
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('modifyStep1Form'); const form = document.getElementById('modifyStep1Form');
const appDescTextarea = document.getElementById('appDescription'); const textarea = document.getElementById('appDescription');
const charCounter = document.querySelector('.char-counter'); const counter = document.getElementById('charCounter');
const appIconInput = document.getElementById('appIcon'); const fileInput = document.getElementById('appIconFile');
const iconPreviewImageJS = document.getElementById('iconPreviewImageJS'); const previewJS = document.getElementById('iconPreviewImageJS');
const iconPlaceholder = document.getElementById('iconPlaceholder'); const preview1 = document.getElementById('iconPreviewImage1');
const ipWhitelistInput = document.getElementById('ipWhitelistInput'); const preview2 = document.getElementById('iconPreviewImage2');
const placeholder = document.getElementById('iconPlaceholder');
const ipInput = document.getElementById('ipWhitelistInput');
// Restore existing IP whitelist // Thymeleaf: Restore IP whitelist
/*<![CDATA[*/ /*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ ''; const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') { if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
const ips = sessionIpWhitelist.split(','); sessionIpWhitelist.split(',').forEach(function (ip) {
ips.forEach(function(ip) { const t = ip.trim();
const trimmedIp = ip.trim(); if (t && !ipAddresses.includes(t)) ipAddresses.push(t);
if (trimmedIp && !ipAddresses.includes(trimmedIp)) {
ipAddresses.push(trimmedIp);
}
}); });
updateIpList(); renderIpList();
} }
/*]]>*/ /*]]>*/
// Initialize character counter // Char counter init
if (appDescTextarea && charCounter) { if (textarea && counter) {
const currentLength = appDescTextarea.value.length; const max = textarea.getAttribute('maxlength') || 500;
const maxLength = appDescTextarea.getAttribute('maxlength'); counter.textContent = textarea.value.length + ' / ' + max;
charCounter.textContent = currentLength + ' / ' + maxLength;
appDescTextarea.addEventListener('input', function() { textarea.addEventListener('input', function () {
const currentLength = this.value.length; counter.textContent = this.value.length + ' / ' + max;
const maxLength = this.getAttribute('maxlength'); counter.style.color = this.value.length > max * 0.9 ? '#f4253c' : '#94a3b8';
charCounter.textContent = currentLength + ' / ' + maxLength;
if (currentLength > maxLength * 0.9) {
charCounter.style.color = '#FF6B6B';
} else {
charCounter.style.color = '#94A3B8';
}
}); });
} }
// App icon preview // File input change
if (appIconInput) { fileInput.addEventListener('change', function (e) {
appIconInput.addEventListener('change', function(e) {
const file = e.target.files[0]; const file = e.target.files[0];
const btnRemoveIcon = document.getElementById('btnRemoveIcon'); if (!file) return;
if (file) {
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.'); customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
this.value = ''; this.value = '';
return; return;
} }
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']; if (!['image/png', 'image/jpeg', 'image/jpg', 'image/gif'].includes(file.type)) {
if (!allowedTypes.includes(file.type)) {
customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.'); customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
this.value = ''; this.value = '';
return; return;
} }
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(event) { reader.onload = function (ev) {
// Hide other previews if(preview1) preview1.style.display = 'none';
document.querySelectorAll('img[id^="iconPreviewImage"]').forEach(img => img.style.display = 'none'); if(preview2) preview2.style.display = 'none';
previewJS.src = ev.target.result;
iconPreviewImageJS.src = event.target.result; previewJS.style.display = 'block';
iconPreviewImageJS.style.display = 'block'; placeholder.style.display = 'none';
iconPlaceholder.style.display = 'none'; document.getElementById('btnRemoveIcon').style.display = 'flex';
btnRemoveIcon.style.display = 'flex';
}; };
reader.readAsDataURL(file); 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 // IP input Enter key
if (ipWhitelistInput) { ipInput.addEventListener('keypress', function (e) {
ipWhitelistInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
if (e.key === 'Enter') {
e.preventDefault();
addIpAddress();
}
}); });
}
// Form validation // Form submit validation
form.addEventListener('submit', function(e) { form.addEventListener('submit', function (e) {
const appName = document.getElementById('appName').value.trim(); const name = document.getElementById('appName').value.trim();
const appDescription = document.getElementById('appDescription').value.trim(); const desc = textarea.value.trim();
const callbackUrl = document.getElementById('callbackUrl').value.trim(); const url = document.getElementById('callbackUrl').value.trim();
if (!appName) { if (!name) {
e.preventDefault(); e.preventDefault();
customPopups.showAlert('앱 이름을 입력해주세요.'); customPopups.showAlert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus(); document.getElementById('appName').focus();
return false; return;
} }
if (!appDescription) { if (!desc) {
e.preventDefault(); e.preventDefault();
customPopups.showAlert('앱 설명을 입력해주세요.'); customPopups.showAlert('앱 설명을 입력해주세요.');
document.getElementById('appDescription').focus(); textarea.focus();
return false; return;
} }
if (callbackUrl && callbackUrl.trim() !== '') { if (url) {
try { try { new URL(url); } catch (_) {
new URL(callbackUrl);
} catch (error) {
e.preventDefault(); e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.'); customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
document.getElementById('callbackUrl').focus(); document.getElementById('callbackUrl').focus();
return false; return;
} }
} }
}); });
}); });
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -11,729 +11,111 @@
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar --> <!-- Left Sidebar (Service Sidebar) -->
<div class="common-title-bar"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<h2 class="common-title">앱 수정</h2>
<span class="common-subtitle">서비스 중인 앱 정보를 수정합니다.</span>
</div>
<!-- Progress Indicator --> <!-- Content Area -->
<div class="register-progress"> <div class="app-management-content">
<div class="progress-steps"> <!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
<!-- Step 1: 앱 정보입력 (Completed) --> <div class="step2-wrap">
<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>
<!-- Dots between Step 1 and 2 --> <!-- Title -->
<div class="step-dots"> <h2 class="s2-title">앱 수정</h2>
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 (Active) --> <!-- Progress Steps Card -->
<div class="progress-step-item active"> <div class="s1-progress-card">
<div class="step-icon-wrapper"> <div class="s1-steps">
<div class="step-icon"> <!-- Step 1: 앱 정보수정 (completed) -->
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36"> <div class="s1-step">
</div> <div class="s1-step-circle">
</div> <!-- 입력폼 아이콘 -->
<div class="step-label">API 선택</div> <svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
</div> xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<!-- Dots between Step 2 and 3 --> <line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
<div class="step-dots"> stroke-linecap="round" />
<span></span><span></span><span></span> <line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
</div> stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
<!-- Step 3: 앱 수정 완료 --> stroke-linecap="round" />
<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>
<div class="step-label">앱 수정 완료</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> </svg>
</div> </div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보수정</span>
</div> </div>
<!-- API Cards Grid --> <div class="s1-step-line"></div>
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state --> <!-- Step 2 Active: API 선택 (chain link icon) -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;"> <div class="s1-step s1-step--active">
<div class="loading-spinner"></div> <div class="s1-step-circle">
<p>API 목록을 불러오는 중...</p> <!-- 체인링크/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>
<!-- Empty state (initially hidden) --> <div class="s1-step-line"></div>
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div> <!-- Step 3: 앱 수정 완료 (rocket icon) -->
<h3>API를 선택해주세요</h3> <div class="s1-step">
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p> <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>
</div> </div>
<!-- API cards will be dynamically loaded here --> <!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
</div> <!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
</form> <!-- Bottom Navigation Actions -->
</main> <div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
이전 이전
</button> </button>
<button type="submit" form="modifyStep2Form" class="btn-submit btn-primary"> <button type="submit" form="apiSelectorForm" class="s2-btn-save">
저장 저장
</button> </button>
</div> </div>
</section> </div><!-- /step2-wrap -->
<!-- Floating Cart Button -->
<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 - 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="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<script th:inline="javascript"> <section layout:fragment="pagePopups">
document.addEventListener('DOMContentLoaded', function() { <th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
// DOM Elements </section>
const form = document.getElementById('modifyStep2Form');
const searchInput = document.getElementById('apiSearch');
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');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let allLoadedApis = []; // Store ALL APIs from server for modal display
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session (for modification)
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load all APIs for modal display (used when APIs from different categories are selected)
function loadAllApisForModal() {
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);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
return;
}
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
// Update modal list after cards are rendered
updateModalList();
}
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
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());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<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="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-card-icon">
${mainIconHtml}
</div>
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
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) {
// 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);
updateSelectedCount();
}
});
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
// Update select all checkbox state
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
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('.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('.api-selection-card');
if (card) {
const nameElement = card.querySelector('.api-name');
if (nameElement) {
apiName = nameElement.textContent;
}
}
} else {
// 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.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
apiName = api.apiName;
}
}
const apiPill = document.createElement('div');
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<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('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
} else {
// If checkbox not found (maybe different category is showing),
// still remove from selection
selectedApis.delete(value);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// 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');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'block';
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Previous step button - save current selections before going back
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint
const originalAction = form.action;
form.action = '/myapikey/modify/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
// API 선택 필수 검증
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
return false;
}
});
// 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>
</body> </body>
</html> </html>
@@ -1,7 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" 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> <body>
<section layout:fragment="title"> <section layout:fragment="title">
@@ -10,113 +11,187 @@
<h1>앱관리</h1> <h1>앱관리</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar --> <!-- Sidebar -->
<div class="common-title-bar"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<h2 class="common-title">앱 수정</h2>
<!-- 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>
<div class="register-progress"> <div class="s1-step-line"></div>
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 --> <!-- Step 2: API 선택 (chain link icon) -->
<div class="progress-step-item"> <div class="s1-step">
<div class="step-icon-wrapper"> <div class="s1-step-circle">
<div class="step-icon"> <!-- 체인링크/API 아이콘 -->
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" class="step-icon-img"> <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> </div>
</div> <span class="s1-step-num">2단계</span>
<div class="step-label">앱정보입력</div> <span class="s1-step-name">API 선택</span>
</div> </div>
<!-- Dots between Step 1 and 2 --> <div class="s1-step-line"></div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 --> <!-- Step 3 Active: 앱 수정 완료 (rocket icon) -->
<div class="progress-step-item"> <div class="s1-step s1-step--active">
<div class="step-icon-wrapper"> <div class="s1-step-circle">
<div class="step-icon"> <!-- 로켓 아이콘 -->
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" class="step-icon-img"> <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> </div>
</div> <span class="s1-step-num">3단계</span>
<div class="step-label">API 선택</div> <span class="s1-step-name">앱 수정 완료</span>
</div>
<!-- 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 class="step-label">앱 수정 완료</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Success Result --> <!-- Success Result Section -->
<div class="register-result success" th:if="${modificationComplete}"> <div class="s3-result-container" th:if="${modificationComplete}">
<!-- Result Box (모바일에서 배경 박스) --> <div class="s3-graphic-wrapper">
<div class="result-box"> <!-- Custom premium isometric server check success SVG design -->
<!-- Success Icon --> <svg class="s3-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<div class="result-icon-wrapper"> <defs>
<div class="result-icon success-icon"> <linearGradient id="glow-grad" x1="250" y1="40" x2="250" y2="360" gradientUnits="userSpaceOnUse">
<img th:src="@{/img/img_app_complete.png}" alt="완료"> <stop stop-color="#3ba4ed" stop-opacity="0.3"/>
</div> <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> </div>
<!-- Success Message --> <div class="s3-message-wrapper">
<div class="result-header"> <h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1>
<h1>앱 수정이 완료되었습니다.</h1> <p class="s3-success-desc">
<p class="result-description"> <span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<span class="text-gray">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br> <br>
<span class="text-dark">[앱 정보]</span> <span class="s3-highlight">[앱 정보]</span> 화면에서 수정 내역을 확인할 수 있습니다.
<span class="text-gray">화면에서 수정 내역을 확인할 수 있습니다.</span>
</p> </p>
</div> </div>
</div>
<!-- Action Buttons --> <!-- Bottom Actions -->
<div class="result-actions"> <div class="s3-actions">
<a href="/myapikey" class="btn btn-primary"> <a href="/myapikey" class="s3-btn-complete">
완료 완료
</a> </a>
</div> </div>
</div> </div>
<!-- Error Result (if needed) --> <!-- Error Result Section (Fallback) -->
<div class="register-result error" th:unless="${modificationComplete}" style="display: none;"> <div class="s3-result-container s3-result-container--error" th:unless="${modificationComplete}">
<div class="result-icon-wrapper"> <div class="s3-graphic-wrapper">
<div class="result-icon error-icon"> <div class="s3-error-icon"></div>
<span></span>
</div> </div>
</div> <div class="s3-message-wrapper">
<div class="result-header"> <h1 class="s3-error-title">앱 수정 실패</h1>
<h1>앱 수정 실패</h1> <p class="s3-error-desc">
<p class="result-description">
앱 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요. 앱 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p> </p>
</div> </div>
<div class="result-actions"> <div class="s3-actions">
<a href="/myapikey" class="btn btn-secondary"> <a href="/myapikey" class="s3-btn-retry">
다시 시도 다시 시도
</a> </a>
<a href="/myapikey" class="btn btn-primary"> <a href="/myapikey" class="s3-btn-list">
목록으로 목록으로
</a> </a>
</div> </div>
</div> </div>
</section> </div><!-- /step3-wrap -->
</div>
</div>
</div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
File diff suppressed because one or more lines are too long
@@ -11,678 +11,108 @@
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar --> <!-- Left Sidebar (Service Sidebar) -->
<div class="common-title-bar"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<h2 class="common-title">앱 생성</h2>
<span class="common-subtitle">API를 이용하여 서비스할 앱 정보를 등록해주세요.</span>
</div>
<!-- Progress Indicator --> <!-- Content Area -->
<div class="register-progress"> <div class="app-management-content">
<div class="progress-steps"> <!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
<!-- Step 1: 앱 정보입력 (Active) --> <div class="step2-wrap">
<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>
<!-- Dots between Step 1 and 2 --> <!-- Title -->
<div class="step-dots"> <h2 class="s2-title">앱생성</h2>
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 --> <!-- Progress Steps Card -->
<div class="progress-step-item active"> <div class="s1-progress-card">
<div class="step-icon-wrapper"> <div class="s1-steps">
<div class="step-icon"> <!-- Step 1: 앱 정보 입력 (completed) -->
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36"> <div class="s1-step">
</div> <div class="s1-step-circle">
</div> <!-- 입력폼 아이콘 -->
<div class="step-label">API 선택</div> <svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
</div> xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<!-- Dots between Step 2 and 3 --> <line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
<div class="step-dots"> stroke-linecap="round" />
<span></span><span></span><span></span> <line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
</div> stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
<!-- Step 3: 앱 생성 완료 --> stroke-linecap="round" />
<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>
<div class="step-label">앱 생성 완료</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> </svg>
</div> </div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보 입력</span>
</div> </div>
<!-- API Cards Grid --> <div class="s1-step-line"></div>
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state --> <!-- Step 2 Active: API 선택 (chain link icon) -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;"> <div class="s1-step s1-step--active">
<div class="loading-spinner"></div> <div class="s1-step-circle">
<p>API 목록을 불러오는 중...</p> <!-- 체인링크/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>
<!-- Empty state (initially hidden) --> <div class="s1-step-line"></div>
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div> <!-- Step 3: 앱 생성 완료 (rocket icon) -->
<h3>API를 선택해주세요</h3> <div class="s1-step">
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p> <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>
</div> </div>
<!-- API cards will be dynamically loaded here --> <!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}"/>
</div> <!-- Bottom Navigation Actions -->
<div class="s2-actions">
</form> <button type="button" id="btnPrevStep" class="s2-btn-prev">
</main>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
이전 이전
</button> </button>
<button type="submit" form="registerStep2Form" class="btn-submit btn-primary"> <button type="submit" form="apiSelectorForm" class="s2-btn-save">
저장 다음
</button> </button>
</div> </div>
</section> </div><!-- /step2-wrap -->
<!-- Floating Cart Button -->
<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 - 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="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<script th:inline="javascript"> <section layout:fragment="pagePopups">
document.addEventListener('DOMContentLoaded', function() { <th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
// DOM Elements </section>
const form = document.getElementById('registerStep2Form');
const searchInput = document.getElementById('apiSearch');
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');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session
const sessionSelectedApis = /*[[${apiKeyRegistration.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
return;
}
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
}
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
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());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<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="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-card-icon">
${mainIconHtml}
</div>
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
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) {
// 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);
updateSelectedCount();
}
});
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
// Update select all checkbox state
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
return;
}
// Get all checked checkboxes
const checkedBoxes = document.querySelectorAll('.api-checkbox:checked');
checkedBoxes.forEach(function(checkbox) {
const card = checkbox.closest('.api-selection-card');
const apiName = card.querySelector('.api-name').textContent;
const apiPill = document.createElement('div');
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<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('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// 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');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'block';
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Previous step button - save current selections before going back
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint
const originalAction = form.action;
form.action = '/myapikey/register/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
return false;
}
// Store selected APIs in sessionStorage
sessionStorage.setItem('registration_selectedApis', JSON.stringify(Array.from(selectedApis)));
});
// Initialize
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
// Load data from step 1 (if needed for display)
const appName = sessionStorage.getItem('registration_appName');
if (appName) {
console.log('App Name from Step 1:', appName);
}
// Log restored selections for debugging
if (selectedApis.size > 0) {
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
}
});
</script>
</th:block>
</body> </body>
</html> </html>
@@ -1,7 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" 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> <body>
<section layout:fragment="title"> <section layout:fragment="title">
@@ -10,121 +11,192 @@
<h1>앱관리</h1> <h1>앱관리</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar --> <!-- Sidebar -->
<div class="common-title-bar"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<h2 class="common-title">앱 생성</h2>
<!-- 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>
<div class="register-progress"> <div class="s1-step-line"></div>
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 --> <!-- Step 2: API 선택 (chain link icon) -->
<div class="progress-step-item"> <div class="s1-step">
<div class="step-icon-wrapper"> <div class="s1-step-circle">
<div class="step-icon"> <!-- 체인링크/API 아이콘 -->
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" class="step-icon-img"> <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> </div>
</div> <span class="s1-step-num">2단계</span>
<div class="step-label">앱정보입력</div> <span class="s1-step-name">API 선택</span>
</div> </div>
<!-- Dots between Step 1 and 2 --> <div class="s1-step-line"></div>
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 --> <!-- Step 3 Active: 앱 생성 완료 (rocket icon) -->
<div class="progress-step-item"> <div class="s1-step s1-step--active">
<div class="step-icon-wrapper"> <div class="s1-step-circle">
<div class="step-icon"> <!-- 로켓 아이콘 -->
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" class="step-icon-img"> <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> </div>
</div> <span class="s1-step-num">3단계</span>
<div class="step-label">API 선택</div> <span class="s1-step-name">앱 생성 완료</span>
</div>
<!-- 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 class="step-label">앱 생성 완료</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Success Result --> <!-- Success Result Section -->
<div class="register-result success" th:if="${registrationComplete}"> <div class="s3-result-container" th:if="${registrationComplete}">
<!-- Result Box (모바일에서 배경 박스) --> <div class="s3-graphic-wrapper">
<div class="result-box"> <!-- Custom premium isometric server check success SVG design -->
<!-- Success Icon --> <svg class="s3-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<div class="result-icon-wrapper"> <defs>
<div class="result-icon success-icon"> <linearGradient id="glow-grad" x1="250" y1="40" x2="250" y2="360" gradientUnits="userSpaceOnUse">
<img th:src="@{/img/img_app_complete.png}" alt="완료"> <stop stop-color="#3ba4ed" stop-opacity="0.3"/>
</div> <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> </div>
<!-- Success Message --> <div class="s3-message-wrapper">
<div class="result-header"> <h1 class="s3-success-title">앱 생성이 완료되었습니다.</h1>
<h1>앱 생성이 완료되었습니다.</h1> <p class="s3-success-desc">
<p class="result-description"> 담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서
<span class="text-gray">담당자 앱 승인 후</span>
<span class="text-dark">[앱 정보]</span>
<span class="text-gray">화면에서</span>
<br> <br>
<span class="text-dark">Client ID와 Client Secret을 확인</span> <span class="s3-highlight">Client ID와 Client Secret을 확인</span>할 수 있습니다.
<span class="text-gray">할 수 있습니다.</span>
</p> </p>
</div> </div>
</div>
<!-- Action Buttons --> <!-- Bottom Actions -->
<div class="result-actions"> <div class="s3-actions">
<a href="/myapikey" class="btn btn-primary"> <a href="/myapikey" class="s3-btn-complete">
완료 완료
</a> </a>
</div> </div>
</div> </div>
<!-- Error Result (if needed) --> <!-- Error Result Section (Fallback) -->
<div class="register-result error" th:unless="${registrationComplete}" style="display: none;"> <div class="s3-result-container s3-result-container--error" th:unless="${registrationComplete}">
<div class="result-icon-wrapper"> <div class="s3-graphic-wrapper">
<div class="result-icon error-icon"> <div class="s3-error-icon"></div>
<span></span>
</div> </div>
</div> <div class="s3-message-wrapper">
<div class="result-header"> <h1 class="s3-error-title">앱 생성 실패</h1>
<h1>API Key 생성 실패</h1> <p class="s3-error-desc">
<p class="result-description"> 앱 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
API Key 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p> </p>
</div> </div>
<div class="result-actions"> <div class="s3-actions">
<a href="/myapikey/register/step1" class="btn btn-secondary"> <a href="/myapikey/register/step1" class="s3-btn-retry">
다시 시도 다시 시도
</a> </a>
<a href="/myapikey" class="btn btn-primary"> <a href="/myapikey" class="s3-btn-list">
목록으로 목록으로
</a> </a>
</div> </div>
</div> </div>
</section> </div><!-- /step3-wrap -->
</div>
</div>
</div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Clear session storage after successful registration // Clear session storage after successful registration
if (/*[[${registrationComplete}]]*/ false) { if (/*[[${registrationComplete}]]*/ false) {
sessionStorage.removeItem('registration_appName'); sessionStorage.removeItem('registration_appName');
@@ -1,182 +1,227 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/djbank_title_layout}">
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/djbank_title_layout}">
<head> <head>
<title>API 키 신청 상세</title> <title>API 키 신청 상세</title>
</head> </head>
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1> <h1>앱관리</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Title Bar --> <!-- 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"> <div class="common-title-bar">
<h2 class="common-title">신청 상세</h2> <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">신청하신 API Key의 상세 정보를 확인할 수 있습니다.</span> <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> </div>
<!-- App Basic Info Card --> <!-- App Basic Info Card -->
<div class="app-info-card"> <div class="dt-app-card">
<!-- App Icon --> <!-- App Icon -->
<div class="app-info-icon"> <div class="dt-app-icon-box">
<img th:if="${appRequest.appIconFileId != null}" <img th:if="${appRequest.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${appRequest.appIconFileId})}" th:src="@{/file/download(fileSn=1,fileId=${appRequest.appIconFileId})}" alt="앱 아이콘">
alt="앱 아이콘" <svg th:unless="${appRequest.appIconFileId != null}" width="46" height="46"
class="app-icon-image"> viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5">
<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> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle> <circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline> <polyline points="21 15 16 10 5 21"></polyline>
</svg> </svg>
</div> </div>
</div>
<!-- App Info --> <!-- App Info -->
<div class="app-info-content"> <div class="dt-app-info">
<!-- Status Badge --> <!-- Status Badge line (approval 존재 시 표시, 상태별 description/색상) -->
<span class="app-status-badge" <div class="dt-app-status-row" th:if="${appRequest.approval != null}">
th:if="${appRequest.approval != null}" <span class="dt-status-badge"
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' : th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' :
(appRequest.approval.approvalStatus.toString() == 'REJECTED' ? 'status-rejected' : 'status-pending')}" (appRequest.approval.approvalStatus.toString() == 'REJECTED' ? 'status-rejected' : 'status-pending')}"
th:text="${appRequest.approval.approvalStatus.description}"> th:text="${appRequest.approval.approvalStatus.description}">
대기중 대기중
</span> </span>
<!-- App Name --> </div>
<h3 class="app-info-name" th:text="${appRequest.clientName}">앱 이름</h3> <h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3>
<!-- App Description --> <!-- App Description -->
<p class="app-info-description" <p class="dt-app-desc"
th:text="${appRequest.appDescription != null ? appRequest.appDescription : '앱 설명이 없습니다.'}"> th:text="${appRequest.appDescription != null ? appRequest.appDescription : '앱 설명이 없습니다.'}">
여기에 입력한 앱 설명이 표시됩니다. 여기에 입력한 앱 설명이 표시됩니다.
</p> </p>
</div> </div>
</div> </div>
<!-- Request Info Section --> <!-- Request Info Section Card -->
<div class="apikey-info-section"> <div class="dt-info-container">
<!-- Row 1: Client ID & Client Secret (only if approved) -->
<!-- Client ID (if exists) --> <div class="dt-row" th:if="${appRequest.clientId != null}">
<div class="info-row info-row-with-action" th:if="${appRequest.clientId != null}"> <div class="dt-col">
<label class="info-label">Client ID</label> <label class="dt-label">Client ID</label>
<div class="info-value"> <div class="dt-input-group">
<div class="info-value-box with-copy" th:text="${appRequest.clientId}">K00000001</div> <div class="dt-input-box" th:text="${appRequest.clientId}">K00000001</div>
<button type="button" class="btn-copy-action" th:data-secret="${appRequest.clientId}" onclick="copyToClipboardFromButton(this)"> <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> </button>
</div> </div>
</div> </div>
<!-- Callback URL (주석 처리) --> <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="info-row"> <div class="dt-row" th:if="${appRequest.clientId != null}">
<label class="info-label">Callback URL</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">Callback URL</label>
<div class="info-value-box" th:text="${appRequest.callbackUrl != null ? appRequest.callbackUrl : '설정되지 않음'}"> <div class="dt-input-group">
<div class="dt-input-box"
th:text="${appRequest.callbackUrl != null ? appRequest.callbackUrl : '설정되지 않음'}">
http://www.abcd.co.kr http://www.abcd.co.kr
</div> </div>
</div> </div>
</div> </div>
</div>
*/--> */-->
<!-- IP Whitelist --> <!-- 화이트리스트 -->
<div class="info-row"> <div class="dt-row">
<label class="info-label">화이트리스트</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">화이트리스트</label>
<div class="info-value-box" th:text="${appRequest.ipWhitelist != null and !appRequest.ipWhitelist.isEmpty() ? appRequest.ipWhitelist : '설정되지 않음'}"> <div class="dt-input-group">
10.0.0.1, 10.0.0.2, 192.168.123.1 <div class="dt-input-box"
</div> th:text="${appRequest.ipWhitelist != null and !appRequest.ipWhitelist.isEmpty() ? appRequest.ipWhitelist : '설정되지 않음'}">
</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>
</div> </div>
</div> </div>
<!-- Created Date --> <!-- API 목록 -->
<div class="info-row"> <div class="dt-row">
<label class="info-label">신청일시</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">API 목록</label>
<div class="info-value-box" th:text="${#temporals.format(appRequest.createdDate, 'yyyy.MM.dd HH:mm')}"> <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 2025.11.22 12:00
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Expected Completion Date (if set by admin) --> <!-- 예상 완료일 (관리자가 설정한 expectEndDate 가 있을 때만) -->
<div class="info-row" <div class="dt-row"
th:if="${appRequest.approval != null and appRequest.approval.expectEndDate != null and #strings.length(appRequest.approval.expectEndDate) >= 8}"> th:if="${appRequest.approval != null and appRequest.approval.expectEndDate != null and #strings.length(appRequest.approval.expectEndDate) >= 8}">
<label class="info-label">예상 완료일</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">예상 완료일</label>
<div class="info-value-box" <div class="dt-input-group">
<div class="dt-input-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})|"> 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 (토) 2025.05.30 (토)
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Approval Date (if approved) --> <!-- 승인일시 (approvalDate 가 있을 때만) -->
<div class="info-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}"> <div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<label class="info-label">승인일시</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">승인일시</label>
<div class="info-value-box" th:text="${#temporals.format(appRequest.approval.approvalDate, 'yyyy.MM.dd HH:mm')}"> <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 2025.11.22 12:00
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Reason (if exists) --> <!-- 사유 (if exists) -->
<div class="info-row" th:if="${appRequest.approval != null and appRequest.reason != null and !appRequest.reason.isEmpty()}"> <div class="dt-row"
<label class="info-label">사유</label> th:if="${appRequest.approval != null and appRequest.reason != null and !appRequest.reason.isEmpty()}">
<div class="info-value"> <div class="dt-col">
<div class="info-value-box" th:text="${appRequest.reason}"> <label class="dt-label">사유</label>
Approval reason <div class="dt-input-group">
<div class="dt-input-box" th:text="${appRequest.reason}">
사유 내용
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Bottom Navigation Actions -->
<div class="form-actions-center"> <div class="dt-actions">
<button type="button" <!-- Cancel Request Button (danger red) -->
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" <button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
class="btn btn-submit btn-danger"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}" (appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}"
th:data-request-id="${appRequest.id}" th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
onclick="cancelRequestById(this)">
신청 취소 신청 취소
</button> </button>
<a th:href="@{/myapikey}" class="btn btn-submit btn-secondary"> <!-- List Button (gray) -->
<a th:href="@{/myapikey}" class="dt-btn-gray">
목록 목록
</a> </a>
</div> </div>
</section> </div><!-- /detail-wrap -->
</div>
</div>
</div>
</th:block>
</th:block> <th:block layout:fragment="contentScript">
<th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ /*<![CDATA[*/
@@ -185,9 +230,9 @@
var text = $(button).data('secret'); var text = $(button).data('secret');
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() { navigator.clipboard.writeText(text).then(function () {
alert('클립보드에 복사되었습니다.'); alert('클립보드에 복사되었습니다.');
}).catch(function(err) { }).catch(function (err) {
console.error('Failed to copy:', err); console.error('Failed to copy:', err);
fallbackCopy(text); fallbackCopy(text);
}); });
@@ -225,12 +270,12 @@
$.ajax({ $.ajax({
url: '/myapikey/api_key_request/cancel', url: '/myapikey/api_key_request/cancel',
type: 'POST', type: 'POST',
data: {id: requestId}, data: { id: requestId },
dataType: 'json', dataType: 'json',
headers: { headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
} }
}).done(function(response) { }).done(function (response) {
if (response.success) { if (response.success) {
alert(response.message || '신청이 취소되었습니다.'); alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey'; window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
@@ -238,7 +283,7 @@
alert(response.message || '신청 취소 중 오류가 발생했습니다.'); alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide(); $('.loading-overlay').hide();
} }
}).fail(function(xhr, status, error) { }).fail(function (xhr, status, error) {
alert('신청 취소 중 오류가 발생했습니다.'); alert('신청 취소 중 오류가 발생했습니다.');
console.error('Cancel error:', error); console.error('Cancel error:', error);
$('.loading-overlay').hide(); $('.loading-overlay').hide();
@@ -247,7 +292,8 @@
/*]]>*/ /*]]>*/
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -14,8 +14,15 @@
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <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">
<div class="detail-wrap">
<!-- Title Bar --> <!-- Title Bar -->
<div class="common-title-bar"> <div class="common-title-bar">
<h2 class="common-title">인증키 정보</h2> <h2 class="common-title">인증키 정보</h2>
@@ -23,89 +30,87 @@
</div> </div>
<!-- App Basic Info Card --> <!-- App Basic Info Card -->
<div class="app-info-card"> <div class="dt-app-card">
<!-- App Icon --> <div class="dt-app-icon-box">
<div class="app-info-icon"> <img th:if="${apiKey.appIconFileId != null}" th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
<img th:if="${apiKey.appIconFileId != null}" <svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5">
th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}"
alt="앱 아이콘"
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> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle> <circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline> <polyline points="21 15 16 10 5 21"></polyline>
</svg> </svg>
</div> </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> </div>
<!-- App Info --> <h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
<div class="app-info-content"> <p class="dt-app-desc" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
<!-- 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>
</div> </div>
<!-- Credential Info Section --> <!-- Credential Info Section Card -->
<div class="apikey-info-section"> <div class="dt-info-container">
<!-- 인증 방식 --> <!-- 인증 방식 -->
<div class="info-row"> <div class="dt-row">
<label class="info-label">인증 방식</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">인증 방식</label>
<div class="info-value-box" th:text="${authType}">OAuth2</div> <div class="dt-input-group">
<div class="dt-input-box" th:text="${authType}">OAuth2</div>
</div>
</div> </div>
</div> </div>
<!-- Client ID --> <!-- Client ID -->
<div class="info-row info-row-with-action"> <div class="dt-row">
<label class="info-label">Client ID</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">Client ID</label>
<div class="info-value-box with-copy" th:text="${apiKey.clientid}">K00000001</div> <div class="dt-input-group">
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientid}" onclick="copyToClipboardFromButton(this)"> <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> </button>
</div> </div>
</div> </div>
</div>
<!-- Client Secret --> <!-- Client Secret -->
<div class="info-row"> <div class="dt-row">
<label class="info-label"> <div class="dt-col">
<label class="dt-label">
Client Secret Client Secret
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내" <button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button> style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
</label> </label>
<div class="info-value">
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 --> <!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
<th:block th:if="${secretAvailable}"> <th:block th:if="${secretAvailable}">
<!-- View Button (shown initially) --> <!-- View Button (shown initially) -->
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()"> <div class="dt-input-group" id="hiddenSecretBox">
<span>조회</span> <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" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path> <path d="m21 21-4.35-4.35"></path>
</svg> </svg>
</button> <span>조회</span>
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
<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" id="revealedSecretValue"></div>
<button type="button" class="btn-copy-action" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
+ 복사
</button> </button>
</div> </div>
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
<div id="revealedSecretBox" style="display: none; width: 100%;">
<div class="dt-input-group">
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<p class="secret-warning" style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;"> <p class="secret-warning" style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br> ⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
@@ -120,85 +125,69 @@
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()" <button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button> style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Callback URL (주석 처리) --> <!-- Callback URL (기능본에서 비활성 처리됨 — 렌더링되지 않음) -->
<!--/* <!--/*
<div class="info-row"> <div class="dt-row">
<label class="info-label">Callback URL</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">Callback URL</label>
<div class="info-value-box" th:text="${apiKey.redirecturi != null ? apiKey.redirecturi : '설정되지 않음'}"> <div class="dt-input-group">
http://www.abcd.co.kr <div class="dt-input-box" th:text="${apiKey.redirecturi != null ? apiKey.redirecturi : '설정되지 않음'}">http://www.abcd.co.kr</div>
</div> </div>
</div> </div>
</div> </div>
*/--> */-->
<!-- IP Whitelist --> <!-- 화이트 리스트 -->
<div class="info-row"> <div class="dt-row">
<label class="info-label">화이트리스트</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">화이트 리스트</label>
<div class="info-value-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}"> <div class="dt-input-group">
10.0.0.1, 10.0.0.2, 192.168.123.1 <div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">설정되지 않음</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Registered APIs --> <!-- API 목록 -->
<div class="info-row info-row-vertical"> <div class="dt-row">
<label class="info-label">API 목록</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">API 목록</label>
<div class="api-list-box"> <div class="dt-api-list-box">
<div class="api-list-item" th:each="api : ${apiKey.apiList}"> <div class="dt-api-item" th:each="api : ${apiKey.apiList}">
<span class="api-item-name" th:text="${api.apiDesc}">추가된 API 1</span> <span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
<span class="api-item-status">승인</span> <span class="dt-api-status-badge">승인</span>
</div> </div>
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
<!-- Empty State --> 등록된 API가 없습니다.
<div class="empty-state" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}" style="padding: 24px;">
<p>등록된 API가 없습니다.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modified Date --> <!-- 최종 수정일자 -->
<div class="info-row"> <div class="dt-row">
<label class="info-label">최종 수정일자</label> <div class="dt-col">
<div class="info-value"> <label class="dt-label">최종 수정일자</label>
<div class="info-value-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}"> <div class="dt-input-group">
2025.11.22 <div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22</div>
</div>
</div> </div>
</div> </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>
<!-- Action Buttons --> </div><!-- /detail-wrap -->
<div class="form-actions-center"> </div><!-- /app-management-content -->
<a th:href="@{/myapikey}" class="btn btn-submit btn-secondary"> </div><!-- /service-main -->
목록 </div><!-- /signup-guide-v2 -->
</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> </th:block>
@@ -12,50 +12,62 @@
<h1>비밀번호 변경</h1> <h1>비밀번호 변경</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment" class="content"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main app-management-layout">
<div class="org-section-header"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<h3>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</h3> <div class="app-management-content">
</div> <div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/mypage/change_new_password}" method="post"> <form id="passwordChangeForm" th:action="@{/mypage/change_new_password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="org-form-container"> <div class="register-form-container">
<div class="org-form-group"> <div class="info-notice-box">
<label class="org-form-label"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<span class="required">새 비밀번호</span> <circle cx="12" cy="12" r="10"></circle>
</label> <path d="M12 16v-4"></path>
<div class="org-form-input-wrapper"> <path d="M12 8h.01"></path>
<input type="password" name="newPassword" class="org-form-input" </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> placeholder="새 비밀번호 입력" required>
</div> </div>
</div> </div>
<div class="org-form-group"> <div class="form-row">
<label class="org-form-label"> <div class="form-label-wrapper">
<span class="required">새 비밀번호 확인</span> <span class="form-label-text">새 비밀번호 확인</span>
</label> </div>
<div class="org-form-input-wrapper"> <div class="form-field-wrapper">
<input type="password" name="confirmPassword" class="org-form-input" <input type="password" name="confirmPassword" class="form-input"
placeholder="새 비밀번호 확인 입력" required> placeholder="새 비밀번호 확인 입력" required>
</div> </div>
</div> </div>
</div>
<div class="org-info-notice"> <ul class="password-rules-list">
<ul> <li>영문 대문자, 소문자, 숫자, 특수문자 8~20자</li>
<li>영문 대문자, 소문자, 숫자, 특수문자 포함 8~20자</li> <li>아이디, 휴대전화, 3자리 이상연속, 반복문자 사용불가</li>
<li>아이디, 휴대전화, 3자리 이상 연속, 반복문자 사용 불가</li>
</ul> </ul>
</div> </div>
<div class="org-action-buttons"> <div class="form-actions" style="justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="history.back()">취소</button> <div class="right-buttons">
<button type="submit" class="btn btn-primary">변경</button> <button type="button" class="btn-cancel" onclick="history.back()">취소</button>
<button type="submit" class="btn-apply btn-primary">변경</button>
</div>
</div> </div>
</form> </form>
</div> </div>
</div>
</div>
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function() { $(document).ready(function() {
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${error}]]);
@@ -9,33 +9,47 @@
<h1>비밀번호 변경</h1> <h1>비밀번호 변경</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment" class="content"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main app-management-layout">
<div class="org-section-header"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<h3>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</h3> <div class="app-management-content">
</div> <div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/verify_current_password}" method="post"> <form th:action="@{/verify_current_password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="org-form-container"> <div class="register-form-container">
<div class="org-form-group"> <div class="info-notice-box">
<label class="org-form-label"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<span class="required">현재 비밀번호</span> <circle cx="12" cy="12" r="10"></circle>
</label> <path d="M12 16v-4"></path>
<div class="org-form-input-wrapper"> <path d="M12 8h.01"></path>
<input type="password" name="currentPassword" class="org-form-input" </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> placeholder="비밀번호 입력" required>
</div> </div>
</div> </div>
</div> </div>
<div class="org-action-buttons"> <div class="form-actions" style="justify-content: flex-end;">
<button type="button" class="btn btn-secondary" th:onclick="|location.href='@{/}'|">취소</button> <div class="right-buttons">
<button type="submit" class="btn btn-primary">확인</button> <button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div> </div>
</form> </form>
</div> </div>
</div>
</div>
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function() { $(document).ready(function() {
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${error}]]);
@@ -1,242 +1,302 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" <html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>내 정보 관리</h1> <h1>내 정보 관리</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main app-management-layout">
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <div class="app-management-content">
<div class="corp-manager-wrapper">
<h2 class="page-outer-title">법인 기본 정보</h2>
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="common-title-bar">
<h2 class="common-title">법인 기본 정보</h2>
</div>
<div class="register-form-container"> <div class="register-form-container">
<!-- 법인 기본 정보 --> <!-- Section 1: 법인 기본 정보 -->
<div class="register-form"> <div class="inner-section-title">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<h3>법인 기본 정보</h3>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">제휴기관 ID</span> <span class="form-label-text">제휴기관 ID</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" name="organizationId" class="form-input input-readonly info-value-box" <input type="text" name="organizationId" class="form-input input-readonly"
th:value="${user.portalOrg.orgCode}" th:value="${user.portalOrg.orgCode}" placeholder="XXXXXX-01" disabled="disabled">
placeholder="XXXXXX-01"
disabled="disabled">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-row two-columns">
<span class="form-label-text">사업자등록정보</span> <div>
<div class="form-label-wrapper">
<span class="form-label-text">사업자등록번호</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<div class="compound-input"> <div class="compound-input">
<input type="text" th:field="*{portalOrg.compRegNo}" id="compRegNo" <input type="text" th:field="*{portalOrg.compRegNo}" id="compRegNo" class="form-input"
class="form-input"
style="display:none;"> style="display:none;">
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#strings.substring(user.portalOrg.compRegNo,0,3)}" th:value="${#strings.substring(user.portalOrg.compRegNo,0,3)}" name="compRegNo1"
name="compRegNo1" class="form-input" maxlength="3"> class="form-input" maxlength="3">
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#strings.substring(user.portalOrg.compRegNo,3,5)}" th:value="${#strings.substring(user.portalOrg.compRegNo,3,5)}" name="compRegNo2"
name="compRegNo2" class="form-input" maxlength="2"> class="form-input" maxlength="2">
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#strings.substring(user.portalOrg.compRegNo,5,10)}" th:value="${#strings.substring(user.portalOrg.compRegNo,5,10)}" name="compRegNo3"
name="compRegNo3" class="form-input" maxlength="5"> class="form-input" maxlength="5">
</div> </div>
</div> </div>
</div> </div>
<div class="form-row"> <div>
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">법인등록번호</span> <span class="form-label-text">법인등록번호</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<div class="compound-input"> <div class="compound-input">
<input type="text" th:field="*{portalOrg.corpRegNo}" id="corpRegNo" <input type="text" th:field="*{portalOrg.corpRegNo}" id="corpRegNo" class="form-input"
class="form-input"
style="display:none;"> style="display:none;">
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#strings.substring(user.portalOrg.corpRegNo,0,6)}" th:value="${#strings.substring(user.portalOrg.corpRegNo,0,6)}" name="corpRegNo1"
name="corpRegNo1" class="form-input" maxlength="6"> class="form-input" maxlength="6">
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#strings.substring(user.portalOrg.corpRegNo,6,13)}" th:value="${#strings.substring(user.portalOrg.corpRegNo,6,13)}" name="corpRegNo2"
name="corpRegNo2" class="form-input" maxlength="7"> class="form-input" maxlength="7">
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">법인명</span> <span class="form-label-text">법인명</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:field="*{portalOrg.orgName}" class="form-input"> <input type="text" th:field="*{portalOrg.orgName}" class="form-input">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">대표자 성명</span> <span class="form-label-text">대표자 성명</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:field="*{portalOrg.ceoName}" class="form-input"> <input type="text" th:field="*{portalOrg.ceoName}" class="form-input">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">사업장 소재지</span> <span class="form-label-text">사업장 소재지</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:field="*{portalOrg.orgAddr}" class="form-input"> <input type="text" th:field="*{portalOrg.orgAddr}" class="form-input">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">업태</span> <span class="form-label-text">업태</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:field="*{portalOrg.orgSectors}" class="form-input"> <input type="text" th:field="*{portalOrg.orgSectors}" class="form-input">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">업종</span> <span class="form-label-text">업종</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:field="*{portalOrg.orgIndustryType}" class="form-input"> <input type="text" th:field="*{portalOrg.orgIndustryType}" class="form-input">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-row two-columns">
<div
th:with="orgPhoneParts=${user.portalOrg.orgPhoneNumber != null ? user.portalOrg.orgPhoneNumber.split('-') : {}}">
<div class="form-label-wrapper">
<span class="form-label-text">회사 전화번호</span> <span class="form-label-text">회사 전화번호</span>
</div> </div>
<div class="form-field-wrapper" th:with="orgPhoneParts=${user.portalOrg.orgPhoneNumber != null ? user.portalOrg.orgPhoneNumber.split('-') : {}}"> <div class="form-field-wrapper">
<div class="compound-input"> <div class="compound-input">
<input type="text" th:field="*{portalOrg.orgPhoneNumber}" id="orgPhoneNumber" <input type="text" th:field="*{portalOrg.orgPhoneNumber}" id="orgPhoneNumber" class="form-input"
class="form-input" style="display:none;"> style="display:none;">
<select class="form-select phone-number" id="orgPhonePrefix"> <select class="form-select" id="orgPhonePrefix" style="padding: 0 10px;">
<option value="">선택</option> <option value="">선택</option>
<option value="02" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '02'}">02</option> <option value="02" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '02'}">
<option value="031" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '031'}">031</option> 02</option>
<option value="032" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '032'}">032</option> <option value="031"
<option value="033" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '033'}">033</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '031'}">031</option>
<option value="041" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '041'}">041</option> <option value="032"
<option value="042" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '042'}">042</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '032'}">032</option>
<option value="043" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '043'}">043</option> <option value="033"
<option value="044" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '044'}">044</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '033'}">033</option>
<option value="051" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '051'}">051</option> <option value="041"
<option value="052" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '052'}">052</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '041'}">041</option>
<option value="053" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '053'}">053</option> <option value="042"
<option value="054" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '054'}">054</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '042'}">042</option>
<option value="055" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '055'}">055</option> <option value="043"
<option value="061" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '061'}">061</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '043'}">043</option>
<option value="062" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '062'}">062</option> <option value="044"
<option value="063" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '063'}">063</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '044'}">044</option>
<option value="064" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '064'}">064</option> <option value="051"
<option value="070" th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '070'}">070</option> th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '051'}">051</option>
<option value="052"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '052'}">052</option>
<option value="053"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '053'}">053</option>
<option value="054"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '054'}">054</option>
<option value="055"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '055'}">055</option>
<option value="061"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '061'}">061</option>
<option value="062"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '062'}">062</option>
<option value="063"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '063'}">063</option>
<option value="064"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '064'}">064</option>
<option value="070"
th:selected="${#lists.size(orgPhoneParts) > 0 && orgPhoneParts[0] == '070'}">070</option>
</select> </select>
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#lists.size(orgPhoneParts) > 1 ? orgPhoneParts[1] : ''}" th:value="${#lists.size(orgPhoneParts) > 1 ? orgPhoneParts[1] : ''}" name="orgPhone1"
name="orgPhone1" class="form-input" maxlength="4">
class="form-input phone-number" maxlength="4">
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric" <input type="text" pattern="\d*" inputmode="numeric"
th:value="${#lists.size(orgPhoneParts) > 2 ? orgPhoneParts[2] : ''}" th:value="${#lists.size(orgPhoneParts) > 2 ? orgPhoneParts[2] : ''}" name="orgPhone2"
name="orgPhone2" class="form-input" maxlength="4">
class="form-input phone-number" maxlength="4">
</div> </div>
</div> </div>
</div> </div>
<div class="form-row"> <div
<div class="form-label-wrapper label-offset"> th:with="scPhoneParts=${user.portalOrg.scPhoneNumber != null ? user.portalOrg.scPhoneNumber.split('-') : {}}">
<span class="form-label-text">서비스명</span> <div class="form-label-wrapper">
<span class="form-label-text">고객센터 전화번호</span>
</div>
<div class="form-field-wrapper">
<div class="compound-input">
<input type="text" th:field="*{portalOrg.scPhoneNumber}" id="scPhoneNumber" class="form-input"
style="display:none;">
<select class="form-select" id="scPhonePrefix" style="padding: 0 10px;">
<option value="">선택</option>
<option value="02" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '02'}">02
</option>
<option value="031" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '031'}">
031</option>
<option value="032" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '032'}">
032</option>
<option value="033" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '033'}">
033</option>
<option value="041" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '041'}">
041</option>
<option value="042" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '042'}">
042</option>
<option value="043" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '043'}">
043</option>
<option value="044" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '044'}">
044</option>
<option value="051" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '051'}">
051</option>
<option value="052" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '052'}">
052</option>
<option value="053" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '053'}">
053</option>
<option value="054" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '054'}">
054</option>
<option value="055" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '055'}">
055</option>
<option value="061" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '061'}">
061</option>
<option value="062" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '062'}">
062</option>
<option value="063" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '063'}">
063</option>
<option value="064" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '064'}">
064</option>
<option value="070" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '070'}">
070</option>
</select>
<span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric"
th:value="${#lists.size(scPhoneParts) > 1 ? scPhoneParts[1] : ''}" name="scPhone1"
class="form-input" maxlength="4">
<span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric"
th:value="${#lists.size(scPhoneParts) > 2 ? scPhoneParts[2] : ''}" name="scPhone2"
class="form-input" maxlength="4">
</div>
</div>
</div>
</div>
<div class="form-row" style="margin-bottom: 48px;">
<div class="form-label-wrapper">
<span class="form-label-text">서비스 명</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:field="*{portalOrg.serviceName}" class="form-input"> <input type="text" th:field="*{portalOrg.serviceName}" class="form-input">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-label-wrapper label-offset"> <!-- Section 2: 법인 관리자 정보 -->
<span class="form-label-text">고객센터 전화번호</span> <div class="inner-section-title">
</div> <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2">
<div class="form-field-wrapper" th:with="scPhoneParts=${user.portalOrg.scPhoneNumber != null ? user.portalOrg.scPhoneNumber.split('-') : {}}"> <rect x="3" y="3" width="7" height="7"></rect>
<div class="compound-input"> <rect x="14" y="3" width="7" height="7"></rect>
<input type="text" th:field="*{portalOrg.scPhoneNumber}" id="scPhoneNumber" <rect x="14" y="14" width="7" height="7"></rect>
class="form-input" style="display:none;"> <rect x="3" y="14" width="7" height="7"></rect>
<select class="form-select phone-number" id="scPhonePrefix"> </svg>
<option value="">선택</option> <h3>법인 관리자 정보</h3>
<option value="02" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '02'}">02</option>
<option value="031" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '031'}">031</option>
<option value="032" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '032'}">032</option>
<option value="033" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '033'}">033</option>
<option value="041" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '041'}">041</option>
<option value="042" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '042'}">042</option>
<option value="043" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '043'}">043</option>
<option value="044" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '044'}">044</option>
<option value="051" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '051'}">051</option>
<option value="052" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '052'}">052</option>
<option value="053" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '053'}">053</option>
<option value="054" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '054'}">054</option>
<option value="055" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '055'}">055</option>
<option value="061" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '061'}">061</option>
<option value="062" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '062'}">062</option>
<option value="063" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '063'}">063</option>
<option value="064" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '064'}">064</option>
<option value="070" th:selected="${#lists.size(scPhoneParts) > 0 && scPhoneParts[0] == '070'}">070</option>
</select>
<span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric"
th:value="${#lists.size(scPhoneParts) > 1 ? scPhoneParts[1] : ''}"
name="scPhone1"
class="form-input phone-number" maxlength="4">
<span class="separator">-</span>
<input type="text" pattern="\d*" inputmode="numeric"
th:value="${#lists.size(scPhoneParts) > 2 ? scPhoneParts[2] : ''}"
name="scPhone2"
class="form-input phone-number" maxlength="4">
</div>
</div>
</div>
</div>
</div> </div>
<!-- 법인 관리자 정보 -->
<div class="common-title-bar" style="margin-top: 48px;">
<h2 class="common-title">법인 관리자 정보</h2>
</div>
<div class="register-form-container">
<div class="register-form"> <div class="register-form">
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block> <th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
</div> </div>
</div> </div>
</form> </form>
<div class="form-actions form-actions--with-withdrawal">
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
<div class="form-actions-buttons">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary">수정 적용</button>
</div>
</div>
</div>
</section> <div class="form-actions">
<th:block layout:fragment="contentScript"> <a href="#" class="withdrawal-link btn-withdrawal">회원 탈퇴</a>
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn-apply btn-primary">적용</button>
</div>
</div>
</div>
</div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${ error }]]);
}); });
</script> </script>
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
// 기존 success 메시지 처리 // 기존 success 메시지 처리
var successMsg = [[${success}]]; var successMsg = [[${ success }]];
console.log("Success message:", successMsg); console.log("Success message:", successMsg);
if (successMsg) { if (successMsg) {
console.log("Showing alert"); console.log("Showing alert");
@@ -248,7 +308,7 @@
<script th:inline="javascript"> <script th:inline="javascript">
let initialformValuse = {}; let initialformValuse = {};
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
initialformValuse = { initialformValuse = {
compRegNo: document.querySelector('#compRegNo').value, compRegNo: document.querySelector('#compRegNo').value,
@@ -577,7 +637,7 @@
// 수정 버튼 이벤트 // 수정 버튼 이벤트
const submitButton = document.querySelector('.btn-primary:not(.btn_add2)'); const submitButton = document.querySelector('.btn-primary:not(.btn_add2)');
if (submitButton) { if (submitButton) {
submitButton.addEventListener('click', function(e) { submitButton.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
try { try {
@@ -624,7 +684,7 @@
} }
// 필수 입력 필드 검증 (가입 시와 동일하게 사업자등록번호, 법인등록번호, 법인명만 필수) // 필수 입력 필드 검증 (가입 시와 동일하게 사업자등록번호, 법인등록번호, 법인명만 필수)
const requiredFields ={ const requiredFields = {
'portalOrg.compRegNo': '사업자등록번호를 입력해주세요.', 'portalOrg.compRegNo': '사업자등록번호를 입력해주세요.',
'portalOrg.corpRegNo': '법인등록번호를 입력해주세요.', 'portalOrg.corpRegNo': '법인등록번호를 입력해주세요.',
'portalOrg.orgName': '법인명을 입력해주세요.' 'portalOrg.orgName': '법인명을 입력해주세요.'
@@ -632,7 +692,7 @@
for (let [fieldName, errorMessage] of Object.entries(requiredFields)) { for (let [fieldName, errorMessage] of Object.entries(requiredFields)) {
const fieldValue = document.querySelector(`input[name="${fieldName}"]`).value.trim(); const fieldValue = document.querySelector(`input[name="${fieldName}"]`).value.trim();
if(!fieldValue) { if (!fieldValue) {
customPopups.showAlert(errorMessage); customPopups.showAlert(errorMessage);
return; return;
} }
@@ -642,18 +702,18 @@
const scPhoneElement = document.getElementById('scPhoneNumber'); const scPhoneElement = document.getElementById('scPhoneNumber');
// 회사 전화번호 검증 // 회사 전화번호 검증
if(orgPhoneElement && orgPhoneElement.value) { if (orgPhoneElement && orgPhoneElement.value) {
const orgParts = utils.parsePhoneNumber(document.getElementById('orgPhoneNumber').value); const orgParts = utils.parsePhoneNumber(document.getElementById('orgPhoneNumber').value);
if(orgParts && (!orgParts.middle || !orgParts.last)) { if (orgParts && (!orgParts.middle || !orgParts.last)) {
customPopups.showAlert('회사 전화번호를 모두 입력해주세요.'); customPopups.showAlert('회사 전화번호를 모두 입력해주세요.');
return; return;
} }
} }
// 고객센터 전화번호 검증 // 고객센터 전화번호 검증
if(scPhoneElement && scPhoneElement.value) { if (scPhoneElement && scPhoneElement.value) {
const scParts = utils.parsePhoneNumber(document.getElementById('scPhoneNumber').value); const scParts = utils.parsePhoneNumber(document.getElementById('scPhoneNumber').value);
if(scParts && (!scParts.middle || !scParts.last)) { if (scParts && (!scParts.middle || !scParts.last)) {
customPopups.showAlert('고객센터 전화번호를 모두 입력해주세요.'); customPopups.showAlert('고객센터 전화번호를 모두 입력해주세요.');
return; return;
} }
@@ -698,9 +758,10 @@
} }
}); });
</script> </script>
</th:block> </th:block>
<section layout:fragment="pagePopups"> <section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block> <th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
</section> </section>
</body> </body>
</html> </html>
@@ -4,150 +4,215 @@
<body> <body>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="signup-guide"> <div class="signup-guide-v2">
<!-- Header Section --> <!-- Hero Section -->
<div class="signup-guide__header"> <section class="service-hero">
<h1 class="signup-guide__title">회원 가입 안내</h1> <div class="service-hero__inner">
<div class="signup-guide__underline"></div> <div class="service-hero__icon-wrapper">
<div class="signup-guide__desc"> <img th:src="@{/img/keyimage/signUp_img.png}" alt="회원가입 소개 3D 아이콘" class="service-keyImg" />
<p>DJBank API 개발자 포털에 방문해 주셔서 감사합니다.</p>
<p>DJBank API 사용을 위해서는 다음과 같은 이용절차로 진행하여야 합니다.</p>
</div> </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> </div>
<!-- Step Process Card --> <!-- Vertical Timeline Steps -->
<div class="signup-guide__card"> <div class="signup-timeline">
<div class="signup-guide__steps">
<!-- Step 1 --> <!-- Step 1 -->
<div class="signup-guide__step"> <div class="signup-step">
<div class="signup-guide__step-icon"> <div class="signup-step__icon-box one">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
<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"/> xmlns="http://www.w3.org/2000/svg">
<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
<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"/> 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"
<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"/> 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> </svg>
</div> </div>
<div class="signup-guide__step-dot"></div> <div class="signup-step__card">
<div class="signup-guide__step-label"> <div class="signup-step__card-num">STEP 01</div>
<span class="signup-guide__step-num">Step1</span> <div class="signup-step__card-title">회원가입</div>
<span class="signup-guide__step-text">회원가입</span>
</div> </div>
<div class="signup-guide__step-line"></div>
</div> </div>
<!-- Step 2 --> <!-- Step 2 -->
<div class="signup-guide__step"> <div class="signup-step">
<div class="signup-guide__step-icon"> <div class="signup-step__icon-box two">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
<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"/> xmlns="http://www.w3.org/2000/svg">
<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
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> 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> </svg>
</div> </div>
<div class="signup-guide__step-dot"></div> <div class="signup-step__card">
<div class="signup-guide__step-label"> <div class="signup-step__card-num">STEP 02</div>
<span class="signup-guide__step-num">Step2</span> <div class="signup-step__card-title">승인</div>
<span class="signup-guide__step-text">승인</span>
</div> </div>
<div class="signup-guide__step-line"></div>
</div> </div>
<!-- Step 3 --> <!-- Step 3 -->
<div class="signup-guide__step"> <div class="signup-step">
<div class="signup-guide__step-icon"> <div class="signup-step__icon-box three">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> xmlns="http://www.w3.org/2000/svg">
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/> <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/> stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="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> </svg>
</div> </div>
<div class="signup-guide__step-dot"></div> <div class="signup-step__card">
<div class="signup-guide__step-label"> <div class="signup-step__card-num">STEP 03</div>
<span class="signup-guide__step-num">Step3</span> <div class="signup-step__card-title">API/APP 사용 신청</div>
<span class="signup-guide__step-text">API/APP 사용 신청</span>
</div> </div>
<div class="signup-guide__step-line"></div>
</div> </div>
<!-- Step 4 --> <!-- Step 4 -->
<div class="signup-guide__step"> <div class="signup-step">
<div class="signup-guide__step-icon"> <div class="signup-step__icon-box four">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> xmlns="http://www.w3.org/2000/svg">
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> <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> </svg>
</div> </div>
<div class="signup-guide__step-dot"></div> <div class="signup-step__card">
<div class="signup-guide__step-label"> <div class="signup-step__card-num">STEP 04</div>
<span class="signup-guide__step-num">Step4</span> <div class="signup-step__card-title">개발/테스트</div>
<span class="signup-guide__step-text">개발/테스트</span>
</div> </div>
<div class="signup-guide__step-line"></div>
</div> </div>
<!-- Step 5 --> <!-- Step 5 -->
<div class="signup-guide__step"> <div class="signup-step">
<div class="signup-guide__step-icon"> <div class="signup-step__icon-box five">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"-->
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> <!-- xmlns="http://www.w3.org/2000/svg">-->
<path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/> <!-- <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"-->
<path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> <!-- 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> </svg>
</div> </div>
<div class="signup-guide__step-dot"></div> <div class="signup-step__card">
<div class="signup-guide__step-label"> <div class="signup-step__card-num">STEP 05</div>
<span class="signup-guide__step-num">Step5</span> <div class="signup-step__card-title">운영신청 및 심사</div>
<span class="signup-guide__step-text">운영신청 및 심사</span>
</div> </div>
<div class="signup-guide__step-line"></div>
</div> </div>
<!-- Step 6 --> <!-- Step 6 -->
<div class="signup-guide__step signup-guide__step--last"> <div class="signup-step">
<div class="signup-guide__step-icon"> <div class="signup-step__icon-box six">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/> xmlns="http://www.w3.org/2000/svg">
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/> <rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/> stroke-linecap="round" stroke-linejoin="round" />
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"/> <path d="M7 13H25" 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 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 21H14" 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> </svg>
</div> </div>
<div class="signup-guide__step-dot"></div> <div class="signup-step__card">
<div class="signup-guide__step-label"> <div class="signup-step__card-num">STEP 06</div>
<span class="signup-guide__step-num">Step6</span> <div class="signup-step__card-title">서비스 개시(예약)</div>
<span class="signup-guide__step-text">서비스 개시(예약)</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Notes Section --> <div class="signup-action">
<div class="signup-guide__notes"> <a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a>
<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> </div>
</section>
<!-- Action Button -->
<div class="signup-guide__action">
<a th:href="@{/signup}" class="signup-guide__btn">회원가입 하러가기</a>
</div> </div>
</div> </div>
</th:block> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
@@ -4,55 +4,66 @@
<body> <body>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="oauth2-2legged"> <section class="oauth2-2legged">
<!-- Section 1: Hero --> <!-- Section 1: Hero -->
<header class="oauth2-2legged__hero"> <header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="Server-to-Server Token Flow">
<defs>
<marker id="o2leg-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
</marker>
</defs>
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF" />
<rect x="40" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="80" y="120" text-anchor="middle" font-size="11" font-weight="700"
fill="#0049b4">Client</text>
<rect x="160" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="200" y="112" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
<text x="200" y="126" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Open API</text>
<line x1="120" y1="115" x2="160" y2="115" stroke="#0049b4" stroke-width="2"
marker-end="url(#o2leg-hero-arrow)" />
<g transform="translate(60,30)">
<rect width="160" height="32" rx="6" fill="#1A1A2E" />
<text x="14" y="21" font-family="'Fira Code', monospace" font-size="12"
fill="#00D4FF">Bearer eyJhbG..</text>
</g>
<text x="140" y="170" text-anchor="middle" font-size="11" font-weight="600"
fill="#64748B">Server-to-Server Token Flow</text>
</svg>
</div>
<div class="oauth2-2legged__hero-body"> <div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow"> <span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span> <span class="oauth2-2legged__hero-eyebrow-dot"></span>
개발 가이드 · 2-Legged · Client Credentials 개발 가이드 · 2-Legged · Client Credentials
</span> </span>
<h1 class="oauth2-2legged__hero-title">OAuth2 개발가이드</h1> <h1 class="oauth2-2legged__hero-title">OAuth2 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank Open API를 호출하기 위한 ClientID/Secret 기반 토큰 발급과</p> <p class="oauth2-2legged__hero-lead">DJBank Open API를 호출하기 위한 ClientID/Secret 기반 토큰
<p class="oauth2-2legged__hero-lead">Bearer 인증 호출 방법을 단계별로 설명합니다.</p> 발급과<br>Bearer 인증 호출 방법을 단계별로 설명합니다.</p>
<div class="oauth2-2legged__hero-chips">
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">Server-to-Server</span>
<span class="oauth2-2legged__chip">cURL 예제</span>
<span class="oauth2-2legged__chip">application/x-www-form</span>
</div> </div>
</div> </div>
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Server-to-Server Token Flow">
<defs>
<marker id="o2leg-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
</marker>
</defs>
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF"/>
<rect x="40" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
<text x="80" y="120" text-anchor="middle" font-size="11" font-weight="700" fill="#0049b4">Client</text>
<rect x="160" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
<text x="200" y="112" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">DJBank</text>
<text x="200" y="126" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Open API</text>
<line x1="120" y1="115" x2="160" y2="115" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-hero-arrow)"/>
<g transform="translate(60,30)">
<rect width="160" height="32" rx="6" fill="#1A1A2E"/>
<text x="14" y="21" font-family="'Fira Code', monospace" font-size="12" fill="#00D4FF">Bearer eyJhbG..</text>
</g>
<text x="140" y="170" text-anchor="middle" font-size="11" font-weight="600" fill="#64748B">Server-to-Server Token Flow</text>
</svg>
</div>
</header> </header>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('oauth2')}"></th:block>
<div class="service-content">
<!-- Section 2: 사전 준비 --> <!-- Section 2: 사전 준비 -->
<section class="oauth2-2legged__prereq" aria-labelledby="o2leg-prereq-title"> <section class="oauth2-2legged__prereq" aria-labelledby="o2leg-prereq-title">
<span class="oauth2-2legged__eyebrow">PREREQUISITE</span> <span class="oauth2-2legged__eyebrow">PREREQUISITE</span>
@@ -94,40 +105,58 @@
<h2 class="oauth2-2legged__h2" id="o2leg-seq-title">전체 인증·호출 시퀀스</h2> <h2 class="oauth2-2legged__h2" id="o2leg-seq-title">전체 인증·호출 시퀀스</h2>
<div class="oauth2-2legged__sequence-diagram"> <div class="oauth2-2legged__sequence-diagram">
<svg viewBox="0 0 1120 282" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OAuth 2.0 2-Legged Sequence"> <svg viewBox="0 0 1120 282" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="OAuth 2.0 2-Legged Sequence">
<defs> <defs>
<marker id="o2leg-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"> <marker id="o2leg-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5"
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/> markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
</marker> </marker>
<marker id="o2leg-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"> <marker id="o2leg-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B"/> markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B" />
</marker> </marker>
</defs> </defs>
<g> <g>
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE" stroke="#0049b4"/> <rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">Client Application</text> stroke="#0049b4" />
<line x1="240" y1="72" x2="240" y2="254" stroke="#94A3B8" stroke-dasharray="4 4"/> <text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">Client Application</text>
<line x1="240" y1="72" x2="240" y2="254" stroke="#94A3B8" stroke-dasharray="4 4" />
</g> </g>
<g> <g>
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF" stroke="#0049b4"/> <rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">DJBank Open API</text> stroke="#0049b4" />
<line x1="880" y1="72" x2="880" y2="254" stroke="#94A3B8" stroke-dasharray="4 4"/> <text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Open API</text>
<line x1="880" y1="72" x2="880" y2="254" stroke="#94A3B8" stroke-dasharray="4 4" />
</g> </g>
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">① POST /dj/oauth/token</text> <text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope</text> fill="#0049b4">① POST /dj/oauth/token</text>
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-arrow-primary)"/> <text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500"
fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope</text>
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2"
marker-end="url(#o2leg-arrow-primary)" />
<text x="560" y="160" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② access_token 발급</text> <text x="560" y="160" text-anchor="middle" font-size="12" font-weight="700"
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2" marker-end="url(#o2leg-arrow-gray)"/> fill="#64748B">② access_token 발급</text>
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti }</text> <line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
marker-end="url(#o2leg-arrow-gray)" />
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500"
fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti
}</text>
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ GET /api/v1/... · X-AUTH-TOKEN: Bearer &lt;access_token&gt;</text> <text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-arrow-primary)"/> fill="#0049b4">③ GET /api/v1/... · Authorization: Bearer &lt;access_token&gt;</text>
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
marker-end="url(#o2leg-arrow-primary)" />
<text x="560" y="248" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 검증 후 API 응답</text> <text x="560" y="248" text-anchor="middle" font-size="12" font-weight="700"
<line x1="880" y1="254" x2="240" y2="254" stroke="#64748B" stroke-width="2" marker-end="url(#o2leg-arrow-gray)"/> fill="#64748B">④ 검증 후 API 응답</text>
<line x1="880" y1="254" x2="240" y2="254" stroke="#64748B" stroke-width="2"
marker-end="url(#o2leg-arrow-gray)" />
</svg> </svg>
</div> </div>
</section> </section>
@@ -139,8 +168,9 @@
<p class="oauth2-2legged__desc">ClientID/Secret 으로 토큰 엔드포인트에 POST 합니다.</p> <p class="oauth2-2legged__desc">ClientID/Secret 으로 토큰 엔드포인트에 POST 합니다.</p>
<div class="oauth2-2legged__endpoint-box"> <div class="oauth2-2legged__endpoint-box">
<span class="oauth2-2legged__method">GET · POST</span> <span class="oauth2-2legged__method">POST</span>
<code class="oauth2-2legged__endpoint-path">https://openapi.djbank.co.kr/dj/oauth/token</code> <code
class="oauth2-2legged__endpoint-path">https://openapi.djbank.co.kr/dj/oauth/token</code>
<span class="oauth2-2legged__endpoint-content-type">x-www-form-urlencoded</span> <span class="oauth2-2legged__endpoint-content-type">x-www-form-urlencoded</span>
</div> </div>
@@ -158,38 +188,47 @@
<tbody> <tbody>
<tr> <tr>
<td><code>grant_type</code></td> <td><code>grant_type</code></td>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span></td> <td><span
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span>
</td>
<td>고정값 "client_credentials"</td> <td>고정값 "client_credentials"</td>
</tr> </tr>
<tr> <tr>
<td><code>client_id</code></td> <td><code>client_id</code></td>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span></td> <td><span
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span>
</td>
<td>발급받은 클라이언트 식별자</td> <td>발급받은 클라이언트 식별자</td>
</tr> </tr>
<tr> <tr>
<td><code>client_secret</code></td> <td><code>client_secret</code></td>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span></td> <td><span
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span>
</td>
<td>발급받은 비밀 키 (서버 보관)</td> <td>발급받은 비밀 키 (서버 보관)</td>
</tr> </tr>
<tr> <tr>
<td><code>scope</code></td> <td><code>scope</code></td>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span></td> <td><span
<td>고정값 "api"</td> class="oauth2-2legged__req-badge oauth2-2legged__req-badge--optional">선택</span>
</td>
<td>호출 권한 범위 (공백 구분)</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p class="oauth2-2legged__warning">⚠ Content-Type: application/x-www-form-urlencoded 헤더 필수</p> <p class="oauth2-2legged__warning">⚠ Content-Type: application/x-www-form-urlencoded 헤더
필수</p>
</div> </div>
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">cURL</span> <span class="oauth2-2legged__code-tag">cURL</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 액세스 토큰 발급</span> <pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 액세스 토큰 발급</span>
curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi.djbank.co.kr/dj/oauth/token'</span> \ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi.djbank.co.kr/dj/oauth/token'</span> \
-H <span class="o2leg-c">'Content-Type: application/x-www-form-urlencoded'</span> \ -H <span class="o2leg-c">'Content-Type: application/x-www-form-urlencoded'</span> \
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \ -d <span class="o2leg-c">'grant_type=client_credentials'</span> \
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \ -d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \ -d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
-d <span class="o2leg-c">'scope=api'</span> -d <span class="o2leg-c">'scope=read.accounts'</span>
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre> <span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
</div> </div>
@@ -205,11 +244,11 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON</span> <span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON</span>
<pre class="oauth2-2legged__code-block">{ <pre class="oauth2-2legged__code-block">{
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>, <span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>, <span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86399</span>, <span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"api"</span>, <span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"read.accounts"</span>,
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span> <span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
} }
@@ -245,7 +284,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<tr> <tr>
<td><code class="oauth2-2legged__field-name">scope</code></td> <td><code class="oauth2-2legged__field-name">scope</code></td>
<td>string</td> <td>string</td>
<td>부여된 권한 범위 (api)</td> <td>실제 부여된 권한 범위</td>
</tr> </tr>
<tr> <tr>
<td><code class="oauth2-2legged__field-name">jti</code></td> <td><code class="oauth2-2legged__field-name">jti</code></td>
@@ -262,13 +301,13 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title"> <section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span> <span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2> <h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
<p class="oauth2-2legged__desc">X-AUTH-TOKEN 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p> <p class="oauth2-2legged__desc">Authorization 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel"> <div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3> <h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
<div class="oauth2-2legged__header-box"> <div class="oauth2-2legged__header-box">
<code class="oauth2-2legged__header-key">X-AUTH-TOKEN:</code> <code class="oauth2-2legged__header-key">Authorization:</code>
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code> <code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
</div> </div>
@@ -277,16 +316,16 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li> <li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li> <li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
<li>매 호출마다 토큰을 새로 발급하지 마세요</li> <li>매 호출마다 토큰을 새로 발급하지 마세요</li>
<li>401 "Access token expired" 응답 시 재발급 후 재호출</li> <li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</li>
</ul> </ul>
</div> </div>
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">cURL</span> <span class="oauth2-2legged__code-tag">cURL</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span> <pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
curl -X <span class="o2leg-y">GET</span> \ curl -X <span class="o2leg-y">GET</span> \
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \ <span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
-H <span class="o2leg-c">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \ -H <span class="o2leg-c">'Authorization: Bearer eyJhbGciOiJSUzI..'</span> \
-H <span class="o2leg-c">'Accept: application/json'</span> -H <span class="o2leg-c">'Accept: application/json'</span>
<span class="o2leg-g"># 응답</span> <span class="o2leg-g"># 응답</span>
@@ -313,52 +352,36 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td> <td><span
<td><code>E.GW.AUTH_FAIL</code></td> class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">400</span>
<td>client not found (client_id 오류)</td> </td>
<td>client_id 확인</td> <td><code>invalid_request</code></td>
<td>필수 파라미터 누락/형식 오류</td>
<td>grant_type, client_id, client_secret 확인</td>
</tr> </tr>
<tr> <tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td> <td><span
<td><code>E.GW.AUTH_FAIL</code></td> class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span>
<td>Bad client credentials (client_secret 불일치)</td> </td>
<td>client_secret 확인</td> <td><code>invalid_client</code></td>
<td>자격증명 불일치 / 미승인</td>
<td>ClientID·Secret 재확인, 앱 상태 점검</td>
</tr> </tr>
<tr> <tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td> <td><span
<td><code>E.GW.AUTH_FAIL</code></td> class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span>
<td>Include unacceptable scope (허용되지 않은 scope)</td> </td>
<td>scope 를 api 로 설정</td> <td><code>invalid_token</code></td>
</tr> <td>만료/위·변조된 토큰</td>
<tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
<td><code>E.GW.AUTH_FAIL</code></td>
<td>Access token expired (토큰 만료)</td>
<td>토큰 재발급 후 재시도</td> <td>토큰 재발급 후 재시도</td>
</tr> </tr>
<tr> <tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td> <td><span
<td><code>E.GW.AUTH_FAIL</code></td> class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">403</span>
<td>Invalid Mandatory Field {grant_type} (누락)</td> </td>
<td>grant_type 포함 여부 확인</td> <td><code>insufficient_scope</code></td>
</tr> <td>scope 권한 부족</td>
<tr> <td>필요 scope 추가 신청 후 재발급</td>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
<td><code>E.GW.AUTH_FAIL</code></td>
<td>Invalid Mandatory Field {client_id} (누락)</td>
<td>client_id 포함 여부 확인</td>
</tr>
<tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
<td><code>E.GW.AUTH_FAIL</code></td>
<td>Invalid Mandatory Field {client_secret} (누락)</td>
<td>client_secret 포함 여부 확인</td>
</tr>
<tr>
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
<td><code>E.GW.AUTH_FAIL</code></td>
<td>Invalid Mandatory Field {scope} (누락)</td>
<td>scope=api 포함 여부 확인</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -373,11 +396,14 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel oauth2-2legged__code-panel--error"> <div class="oauth2-2legged__code-panel oauth2-2legged__code-panel--error">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--error">401 Unauthorized · JSON</span> <span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--error">401 Unauthorized
<pre class="oauth2-2legged__code-block">{ · JSON</span>
<span class="o2leg-c">"error"</span>: <span class="o2leg-y">"E.GW.AUTH_FAIL"</span>, <pre class="oauth2-2legged__code-block">{
<span class="o2leg-c">"error"</span>: <span class="o2leg-y">"invalid_client"</span>,
<span class="o2leg-c">"error_description"</span>: <span class="o2leg-c">"error_description"</span>:
<span class="o2leg-y">"Unauthorized. [Bad client credentials(Client Secret is not match)]"</span> <span class="o2leg-y">"Client authentication failed"</span>,
<span class="o2leg-c">"status"</span>: <span class="o2leg-p">401</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-y">"2026-06-10T11:23:45Z"</span>
} }
<span class="o2leg-g"># 처리: ClientID·Secret 확인 후 재시도</span></pre> <span class="o2leg-g"># 처리: ClientID·Secret 확인 후 재시도</span></pre>
@@ -397,16 +423,26 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
<tr> <tr>
<td><code class="oauth2-2legged__field-name">error</code></td> <td><code class="oauth2-2legged__field-name">error</code></td>
<td>string</td> <td>string</td>
<td>에러 코드 (예: E.GW.AUTH_FAIL)</td> <td>에러 코드 (예: invalid_client)</td>
</tr> </tr>
<tr> <tr>
<td><code class="oauth2-2legged__field-name">error_description</code></td> <td><code class="oauth2-2legged__field-name">error_description</code></td>
<td>string</td> <td>string</td>
<td>오류 상세 설명 (사람이 읽을 수 있는 형태)</td> <td>사람이 읽을 수 있는 설명</td>
</tr>
<tr>
<td><code class="oauth2-2legged__field-name">status</code></td>
<td>int</td>
<td>HTTP 상태 코드</td>
</tr>
<tr>
<td><code class="oauth2-2legged__field-name">timestamp</code></td>
<td>string</td>
<td>에러 발생 시각 (ISO 8601)</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p class="oauth2-2legged__error-note">⚠ error 코드는 게이트웨이 표준 코드 (E.GW.AUTH_FAIL)</p> <p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 DJBank 확장 코드</p>
</div> </div>
</div> </div>
</section> </section>
@@ -423,8 +459,10 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span> <span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
</a> </a>
</div>
</div>
</section> </section>
</th:block> </th:block>
</body> </body>
@@ -0,0 +1,480 @@
<!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}">
<body>
<th:block layout:fragment="contentFragment">
<section class="oauth2-2legged">
<!-- Section 1: Hero -->
<header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
개발 가이드 · Webhook · HMAC-SHA256
</span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한</p>
<p class="oauth2-2legged__hero-lead">HMAC-SHA256 서명 검증 방법을 단계별로 설명합니다.</p>
<div class="oauth2-2legged__hero-chips">
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
<span class="oauth2-2legged__chip">X-Webhook-Signature</span>
<span class="oauth2-2legged__chip">Raw Body</span>
</div>
</div>
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Signed Webhook Delivery">
<defs>
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
</marker>
</defs>
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF"/>
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">DJBank</text>
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Webhook</text>
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Your</text>
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Endpoint</text>
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-hero-arrow)"/>
<g transform="translate(58,28)">
<rect width="164" height="34" rx="6" fill="#1A1A2E"/>
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9" fill="#00D4FF">X-Webhook-Signature:</text>
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9" fill="#FFD93D">sha256=9f86d0..</text>
</g>
<g transform="translate(133,104)">
<circle r="13" fill="#0049b4"/>
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
</g>
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600" fill="#64748B">Signed Webhook Delivery</text>
</svg>
</div>
</div>
</header>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<div class="service-content">
<!-- Section 2: 개요 -->
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
<div class="oauth2-2legged__prereq-grid">
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">1</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
<p>[마이페이지 &gt; Webhook 관리]에서 발급된</p>
<p>Secret Key를 서버에 안전하게 보관.</p>
</div>
</article>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">2</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
<p>바이트 원문 그대로 서명 계산에 사용.</p>
</div>
</article>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
<p>헤더 서명과 상수 시간으로 비교.</p>
</div>
</article>
</div>
</section>
<!-- Section 3: 전체 서명·검증 시퀀스 -->
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
<div class="oauth2-2legged__sequence-diagram">
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Webhook Signature Verification Sequence">
<defs>
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
</marker>
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B"/>
</marker>
</defs>
<g>
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE" stroke="#0049b4"/>
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">DJBank Webhook Sender</text>
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/>
</g>
<g>
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF" stroke="#0049b4"/>
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">Your Endpoint</text>
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/>
</g>
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=&lt;hex&gt;</text>
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-arrow-primary)"/>
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 동일 secret으로 재계산</text>
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2" marker-end="url(#whsig-arrow-gray)"/>
</svg>
</div>
</section>
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
<span class="oauth2-2legged__eyebrow">STEP 1</span>
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<div class="oauth2-2legged__endpoint-box">
<span class="oauth2-2legged__method">POST</span>
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
</div>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>HEADER</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>X-Webhook-Signature</code></td>
<td><code>sha256=&lt;hex&gt;</code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
</tr>
<tr>
<td><code>X-Webhook-Event</code></td>
<td>이벤트 코드 (예: CONTROL_START)</td>
</tr>
<tr>
<td><code>X-Webhook-Timestamp</code></td>
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
</tr>
<tr>
<td><code>Content-Type</code></td>
<td>application/json</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.</p>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
<pre class="oauth2-2legged__code-block">{
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
}
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
</div>
</div>
</section>
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
<span class="oauth2-2legged__eyebrow">STEP 2</span>
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Verification Steps</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
digest = HMAC_SHA256(secret, rawBody) <span class="o2leg-g"># bytes</span>
expected = <span class="o2leg-y">"sha256="</span> + toHexLower(digest)
<span class="o2leg-cm"># 3) 상수 시간 비교</span>
valid = constantTimeEquals(received, expected)</pre>
</div>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
<ul class="oauth2-2legged__tips">
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
</ul>
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
<ul class="oauth2-2legged__tips">
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
</ul>
</div>
</div>
</section>
<!-- Section 6: STEP 3 — 언어별 예제 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Node.js (Express)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
<span class="o2leg-p">const</span> expected = <span class="o2leg-c">'sha256='</span> +
crypto.<span class="o2leg-y">createHmac</span>(<span class="o2leg-c">'sha256'</span>, secret)
.<span class="o2leg-y">update</span>(rawBody)
.<span class="o2leg-y">digest</span>(<span class="o2leg-c">'hex'</span>);
<span class="o2leg-p">const</span> a = Buffer.<span class="o2leg-y">from</span>(header);
<span class="o2leg-p">const</span> b = Buffer.<span class="o2leg-y">from</span>(expected);
<span class="o2leg-p">return</span> a.length === b.length &amp;&amp;
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
}</pre>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
digest = hmac.<span class="o2leg-y">new</span>(
secret.<span class="o2leg-y">encode</span>(<span class="o2leg-c">'utf-8'</span>),
raw_body,
hashlib.sha256
).<span class="o2leg-y">hexdigest</span>()
expected = <span class="o2leg-c">'sha256='</span> + digest
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
</div>
</div>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Java</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
<span class="o2leg-p">import</span> java.security.MessageDigest;
<span class="o2leg-cm">// rawBody: 파싱 전 원문 문자열</span>
<span class="o2leg-p">boolean</span> <span class="o2leg-y">verify</span>(String rawBody, String header, String secret) <span class="o2leg-p">throws</span> Exception {
Mac mac = Mac.<span class="o2leg-y">getInstance</span>(<span class="o2leg-c">"HmacSHA256"</span>);
mac.<span class="o2leg-y">init</span>(<span class="o2leg-p">new</span> SecretKeySpec(secret.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8), <span class="o2leg-c">"HmacSHA256"</span>));
<span class="o2leg-p">byte</span>[] hash = mac.<span class="o2leg-y">doFinal</span>(rawBody.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
StringBuilder hex = <span class="o2leg-p">new</span> StringBuilder();
<span class="o2leg-p">for</span> (<span class="o2leg-p">byte</span> b : hash) hex.<span class="o2leg-y">append</span>(String.<span class="o2leg-y">format</span>(<span class="o2leg-c">"%02x"</span>, b));
String expected = <span class="o2leg-c">"sha256="</span> + hex;
<span class="o2leg-p">return</span> MessageDigest.<span class="o2leg-y">isEqual</span>(
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
}</pre>
</div>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>CODE</th>
<th>의미</th>
</tr>
</thead>
<tbody>
<tr><td><code>CONTROL_START</code></td><td>점검 시작</td></tr>
<tr><td><code>CONTROL_END</code></td><td>점검 종료</td></tr>
<tr><td><code>DELAY_START</code></td><td>지연 시작</td></tr>
<tr><td><code>DELAY_END</code></td><td>지연 종료</td></tr>
<tr><td><code>ERROR_START</code></td><td>장애 시작</td></tr>
<tr><td><code>ERROR_END</code></td><td>장애 종료</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>반환</th>
<th>상황</th>
<th>DJBank 처리</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span></td>
<td>검증 성공 + 정상 접수</td>
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
</tr>
<tr>
<td><code>400</code></td>
<td>필수 헤더 누락</td>
<td>실패 기록 + 재시도</td>
</tr>
<tr>
<td><code>401</code></td>
<td>서명 불일치 / timestamp 만료</td>
<td>실패 기록 + 재시도</td>
</tr>
<tr>
<td><code>5xx</code> · 타임아웃</td>
<td>수신 서버 일시 장애</td>
<td>실패 기록 + 재시도</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고 재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
<ul class="oauth2-2legged__tips">
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
</ul>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON (권장)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
HTTP/1.1 <span class="o2leg-g">200 OK</span>
Content-Type: application/json
{
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"OK"</span>,
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>
}
<span class="o2leg-cm"># 서명 검증 실패</span>
HTTP/1.1 <span class="o2leg-g">401 Unauthorized</span>
{
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"서명 검증에 실패하였습니다."</span>
}
<span class="o2leg-cm"># 필수 헤더 누락</span>
HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
{
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
}</pre>
</div>
</div>
</section>
<!-- Section 7: 검증 실패 대표 원인 -->
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
<div class="oauth2-2legged__panel">
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
<thead>
<tr>
<th>원인</th>
<th>증상</th>
<th>해결 가이드</th>
</tr>
</thead>
<tbody>
<tr>
<td>본문 재직렬화</td>
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
<td>파싱 전 raw body(bytes)로 계산</td>
</tr>
<tr>
<td>hex 대소문자</td>
<td>대문자 hex로 비교해 불일치</td>
<td>소문자 hex 사용</td>
</tr>
<tr>
<td>접두어 처리</td>
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
<td>양쪽 모두 접두 포함 후 비교</td>
</tr>
<tr>
<td>인코딩</td>
<td>Secret/본문을 UTF-8 외로 인코딩</td>
<td>키·메시지 모두 UTF-8 bytes</td>
</tr>
<tr>
<td>Secret 불일치</td>
<td>재발급 후 이전 Secret 사용</td>
<td>최신 Secret으로 교체</td>
</tr>
<tr>
<td>재전송</td>
<td>동일 이벤트 중복 수신</td>
<td>timestamp 검사 + eventId 멱등 처리</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Section 8: CTA -->
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
<div class="oauth2-2legged__cta-body">
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
</div>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
</a>
</div>
</div>
</section>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
@@ -12,6 +12,95 @@
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/> <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/>
<style>
/* 통계 테이블 (실제 table 태그) 공통 */
.api-statistics-container .stats-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
background: #fff;
}
.api-statistics-container .stats-table th,
.api-statistics-container .stats-table td {
padding: 14px 12px;
border-bottom: 1px solid #e2e8f0;
vertical-align: middle;
}
.api-statistics-container .stats-table thead th {
text-align: center;
background: #f8fafc;
color: #475569;
font-weight: 600;
white-space: nowrap;
}
.api-statistics-container .stats-table tfoot td {
font-weight: 600;
background: #f8fafc;
}
/* API별 표: auto layout — 숫자열은 내용에 맞춰 최소(nowrap), 통계는 고정, API명이 남은 폭 전체 */
.api-statistics-container .stats-table--api { table-layout: auto; }
.api-statistics-container .stats-table--api .col-name {
text-align: left;
width: 100%;
white-space: normal;
word-break: break-word;
padding-left: 4px;
}
/* API명 헤더 셀은 가운데 정렬 (데이터 셀은 좌측 유지) */
.api-statistics-container .stats-table--api thead .col-name {
text-align: center;
}
.api-statistics-container .stats-table--api .col-num {
text-align: center;
white-space: nowrap;
width: 1%;
}
.api-statistics-container .stats-table--api .col-chart {
text-align: center;
width: 180px;
min-width: 180px;
}
.api-statistics-container .stats-table--api .col-chart .stats-progress {
min-width: 150px;
}
.api-statistics-container .stats-table--api .api-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-right: 6px;
}
.api-statistics-container .stats-table--api .api-icon svg { display: block; }
.api-statistics-container .stats-table--api .api-name-text { vertical-align: middle; }
/* 일별/기간별 표: fixed layout — 5열 동일 너비, 전부 가운데 */
.api-statistics-container .stats-table--period { table-layout: fixed; }
.api-statistics-container .stats-table--period th,
.api-statistics-container .stats-table--period td {
text-align: center;
width: 20%;
white-space: nowrap;
}
/* progress bar */
.api-statistics-container .stats-progress {
width: 100%;
height: 8px;
background: #f1f5f9;
border-radius: 4px;
display: flex;
overflow: hidden;
}
/* empty state */
.api-statistics-container .stats-empty td {
text-align: center;
padding: 40px 0;
color: #64748b;
font-weight: 400;
font-size: 16px;
}
</style>
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
<div class="app-management-content">
<section class="api-statistics-container"> <section class="api-statistics-container">
<!-- 집계 안내 문구 (집계 주기 분 + 최신 집계 시각, 서버 계산값) --> <!-- 집계 안내 문구 (집계 주기 분 + 최신 집계 시각, 서버 계산값) -->
@@ -25,6 +114,7 @@
<!-- Search Filter --> <!-- Search Filter -->
<div class="statistics-search-section"> <div class="statistics-search-section">
<!-- 일별/월별 모드 토글 -->
<div class="search-mode-tabs"> <div class="search-mode-tabs">
<button type="button" class="mode-tab active" data-mode="DAILY">일별</button> <button type="button" class="mode-tab active" data-mode="DAILY">일별</button>
<button type="button" class="mode-tab" data-mode="MONTHLY">월별</button> <button type="button" class="mode-tab" data-mode="MONTHLY">월별</button>
@@ -108,7 +198,7 @@
</div> </div>
</div> </div>
<!-- Center: Donut Chart (SVG, 3 segments) --> <!-- Center: Donut Chart (SVG, 3 segments: 성공/타임아웃/실패) -->
<div class="summary-center"> <div class="summary-center">
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200"> <svg id="donutChart" width="200" height="200" viewBox="0 0 200 200">
<circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24"/> <circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24"/>
@@ -153,102 +243,101 @@
</div> </div>
</div> </div>
<!-- API Statistics Table --> <!-- API별 통계 Table Section -->
<div class="statistics-table-card"> <div class="statistics-table-section" style="margin-top: 40px;">
<div class="table-header"> <div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
<h3 class="table-title"> <h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/> <path d="M3 3v18h18"/>
<path d="M18 9l-5 5-4-4-4 4"/>
</svg> </svg>
API별 통계 API별 통계
</h3> </h3>
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) --> <!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
</div> </div>
<div class="table-container"> <table class="stats-table stats-table--api">
<table class="statistics-table">
<thead> <thead>
<tr> <tr>
<th>API명</th> <th class="col-name">API명</th>
<th>API호출수</th> <th class="col-num">API호출수</th>
<th>성공</th> <th class="col-num">성공</th>
<th>타임아웃</th> <th class="col-num">실패</th>
<th>실패</th> <th class="col-num">타임아웃</th>
<th>통계</th> <th class="col-chart">통계</th>
</tr> </tr>
</thead> </thead>
<tbody id="statisticsTableBody"> <tbody id="statisticsTableBody">
<tr th:each="detail : ${details}"> <tr th:each="detail : ${details}">
<td class="api-name"> <td class="col-name">
<span class="api-icon"> <span class="api-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/> <rect x="3" y="3" width="18" height="18" rx="2"/>
</svg> </svg>
</span> </span><span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
<span th:text="${detail.apiName}">API 이름</span>
</td> </td>
<td th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td>
<td class="progress-cell"> <td class="col-chart">
<div class="progress-bar"> <div class="stats-progress">
<div class="progress-success" th:style="'width:' + ${detail.successRate} + '%'"></div> <div class="progress-success" th:style="'width:' + ${detail.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%'"></div> <div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%'"></div> <div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%; background: #F5A3B5;'"></div>
</div> </div>
</td> </td>
</tr> </tr>
<tr th:if="${details == null or details.isEmpty()}" class="empty-row"> <tr th:if="${details == null or details.isEmpty()}" class="stats-empty">
<td colspan="6">데이터가 없습니다.</td> <td colspan="6">데이터가 없습니다.</td>
</tr> </tr>
</tbody> </tbody>
<tfoot id="statisticsTableFoot" th:if="${details != null and !details.isEmpty()}"> <tfoot id="statisticsTableFoot">
<tr class="total-row"> <tr class="total-row" th:if="${details != null and !details.isEmpty()}">
<td class="api-name"> <td class="col-name">
<span class="api-icon total"> <span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/> <path d="M3 3v18h18"/>
<path d="M18 9l-5 5-4-4-4 4"/>
</svg> </svg>
</span> </span><span class="api-name-text">전체</span>
<span>전체</span>
</td> </td>
<td th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td>
<td th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td> <td class="col-num" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td>
<td class="progress-cell"> <td class="col-chart">
<div class="progress-bar"> <div class="stats-progress">
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%'"></div> <div class="progress-success" th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%'"></div> <div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%'"></div> <div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div>
</div> </div>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div>
<!-- 기간별(일별/월별) 누적 통계 --> <!-- 기간별(일별/월별) 누적 통계 Table Section -->
<div class="statistics-table-card"> <div class="statistics-table-section" style="margin-top: 40px;">
<div class="table-header"> <div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
<h3 class="table-title"> <h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/> <rect x="3" y="4" width="18" height="18" rx="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
</svg> </svg>
<span id="periodTableTitle">일별 통계</span> <span id="periodTableTitle">일별 통계</span>
</h3> </h3>
</div> </div>
<div class="table-container">
<table class="statistics-table"> <table class="stats-table stats-table--period">
<thead> <thead>
<tr> <tr>
<th id="periodLabel">일자</th> <th id="periodLabel">일자</th>
<th>API호출수</th> <th>API호출수</th>
<th>성공</th> <th>성공</th>
<th>타임아웃</th>
<th>실패</th> <th>실패</th>
<th>타임아웃</th>
</tr> </tr>
</thead> </thead>
<tbody id="periodTableBody"> <tbody id="periodTableBody">
@@ -256,23 +345,25 @@
<td th:text="${p.period}">2026-07-15 (수)</td> <td th:text="${p.period}">2026-07-15 (수)</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td> <td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td> <td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td> <td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
</tr> </tr>
<tr th:if="${periods == null or periods.isEmpty()}" class="empty-row"> <tr th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
<td colspan="5">데이터가 없습니다.</td> <td colspan="5">데이터가 없습니다.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</section> </section>
</div>
</div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function() {
// Initial data from server
let currentData = { let currentData = {
summary: { summary: {
totalCount: [[${summary.totalCount}]], totalCount: [[${summary.totalCount}]],
@@ -318,7 +409,7 @@
$('#errorRate, #errorRateText').text(dashRate(t, sm.errorRate)); $('#errorRate, #errorRateText').text(dashRate(t, sm.errorRate));
} }
// ── API별 표 ── // ── API별 표 (table DOM) ──
function updateTable() { function updateTable() {
const tbody = $('#statisticsTableBody'); const tbody = $('#statisticsTableBody');
tbody.empty(); tbody.empty();
@@ -327,65 +418,59 @@
currentData.details.forEach(function(d) { currentData.details.forEach(function(d) {
tbody.append(` tbody.append(`
<tr> <tr>
<td class="api-name"> <td class="col-name">
<span class="api-icon"> <span class="api-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/> <rect x="3" y="3" width="18" height="18" rx="2"/>
</svg> </svg>
</span> </span><span class="api-name-text">${d.apiName || '-'}</span>
<span>${d.apiName || '-'}</span>
</td> </td>
<td>${numberWithCommas(d.totalCount)}</td> <td class="col-num">${numberWithCommas(d.totalCount)}</td>
<td>${numberWithCommas(d.successCount)}</td> <td class="col-num">${numberWithCommas(d.successCount)}</td>
<td>${numberWithCommas(d.timeoutCount)}</td> <td class="col-num">${numberWithCommas(d.errorCount)}</td>
<td>${numberWithCommas(d.errorCount)}</td> <td class="col-num">${numberWithCommas(d.timeoutCount)}</td>
<td class="progress-cell"> <td class="col-chart">
<div class="progress-bar"> <div class="stats-progress">
<div class="progress-success" style="width:${d.successRate}%"></div> <div class="progress-success" style="width:${d.successRate}%; background: #4A90D9;"></div>
<div class="progress-timeout" style="width:${d.timeoutRate}%"></div> <div class="progress-timeout" style="width:${d.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${d.errorRate}%"></div> <div class="progress-failure" style="width:${d.errorRate}%; background: #F5A3B5;"></div>
</div> </div>
</td> </td>
</tr>`); </tr>`);
}); });
renderTotalFooter(); renderTotalFooter();
} else { } else {
tbody.html('<tr class="empty-row"><td colspan="6">데이터가 없습니다.</td></tr>'); tbody.html('<tr class="stats-empty"><td colspan="6">데이터가 없습니다.</td></tr>');
$('#statisticsTableFoot').remove(); $('#statisticsTableFoot').empty();
} }
} }
function renderTotalFooter() { function renderTotalFooter() {
var sm = currentData.summary; var sm = currentData.summary;
var footerHtml = ` $('#statisticsTableFoot').html(`
<tfoot id="statisticsTableFoot">
<tr class="total-row"> <tr class="total-row">
<td class="api-name"> <td class="col-name">
<span class="api-icon total"> <span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/> <path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg> </svg>
</span> </span><span class="api-name-text">전체</span>
<span>전체</span>
</td> </td>
<td>${numberWithCommas(sm.totalCount)}</td> <td class="col-num">${numberWithCommas(sm.totalCount)}</td>
<td>${numberWithCommas(sm.successCount)}</td> <td class="col-num">${numberWithCommas(sm.successCount)}</td>
<td>${numberWithCommas(sm.timeoutCount)}</td> <td class="col-num">${numberWithCommas(sm.errorCount)}</td>
<td>${numberWithCommas(sm.errorCount)}</td> <td class="col-num">${numberWithCommas(sm.timeoutCount)}</td>
<td class="progress-cell"> <td class="col-chart">
<div class="progress-bar"> <div class="stats-progress">
<div class="progress-success" style="width:${sm.successRate}%"></div> <div class="progress-success" style="width:${sm.successRate}%; background: #4A90D9;"></div>
<div class="progress-timeout" style="width:${sm.timeoutRate}%"></div> <div class="progress-timeout" style="width:${sm.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${sm.errorRate}%"></div> <div class="progress-failure" style="width:${sm.errorRate}%; background: #F5A3B5;"></div>
</div> </div>
</td> </td>
</tr> </tr>`);
</tfoot>`;
$('#statisticsTableFoot').remove();
$('#statisticsTableBody').after(footerHtml);
} }
// ── 기간별(일/월) 표 ── // ── 기간별(일/월) 표 (table DOM) ──
function updatePeriodTable() { function updatePeriodTable() {
const tbody = $('#periodTableBody'); const tbody = $('#periodTableBody');
tbody.empty(); tbody.empty();
@@ -396,15 +481,16 @@
<td>${p.period || '-'}</td> <td>${p.period || '-'}</td>
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td> <td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td> <td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td> <td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
</tr>`); </tr>`);
}); });
} else { } else {
tbody.html('<tr class="empty-row"><td colspan="5">데이터가 없습니다.</td></tr>'); tbody.html('<tr class="stats-empty"><td colspan="5">데이터가 없습니다.</td></tr>');
} }
} }
// Number formatting
function numberWithCommas(x) { function numberWithCommas(x) {
if (x === null || x === undefined) return '0'; if (x === null || x === undefined) return '0';
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
@@ -421,6 +507,7 @@
return meta ? meta.getAttribute('content') : ''; return meta ? meta.getAttribute('content') : '';
} }
// Validate date range (max 40 days)
function validateDateRange(startDate, endDate) { function validateDateRange(startDate, endDate) {
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' }; if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
var start = new Date(startDate), end = new Date(endDate); var start = new Date(startDate), end = new Date(endDate);
@@ -440,6 +527,40 @@
}; };
} }
// ── 조회 파라미터 sessionStorage 유지 (일별/월별 모드·날짜·월·앱 선택) ──
var STATS_PARAM_KEY = 'apiStatsParams';
function saveParams() {
try {
sessionStorage.setItem(STATS_PARAM_KEY, JSON.stringify({
mode: currentMode,
clientId: selectedClientId || '',
startDate: $('#startDate').val(),
endDate: $('#endDate').val(),
month: $('#month').val() || ''
}));
} catch (e) {}
}
function restoreParams() {
try {
var raw = sessionStorage.getItem(STATS_PARAM_KEY);
if (!raw) return false;
var p = JSON.parse(raw);
if (p.mode) {
currentMode = p.mode;
$('.mode-tab').removeClass('active');
$('.mode-tab[data-mode="' + p.mode + '"]').addClass('active');
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
}
if (p.startDate) $('#startDate').val(p.startDate);
if (p.endDate) $('#endDate').val(p.endDate);
if (p.month) $('#month').val(p.month);
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
return true;
} catch (e) { return false; }
}
// Search Statistics
function searchStatistics() { function searchStatistics() {
if (currentMode === 'MONTHLY') { if (currentMode === 'MONTHLY') {
if (!$('#month').val()) { alert('조회할 월을 선택해주세요.'); return; } if (!$('#month').val()) { alert('조회할 월을 선택해주세요.'); return; }
@@ -447,6 +568,7 @@
var v = validateDateRange($('#startDate').val(), $('#endDate').val()); var v = validateDateRange($('#startDate').val(), $('#endDate').val());
if (!v.valid) { alert(v.message); return; } if (!v.valid) { alert(v.message); return; }
} }
saveParams();
$.ajax({ $.ajax({
url: '/statistics/api/search', url: '/statistics/api/search',
@@ -485,11 +607,17 @@
searchStatistics(); searchStatistics();
}); });
// Event Handlers
$('#btnSearch').on('click', searchStatistics); $('#btnSearch').on('click', searchStatistics);
$('#appSelect').on('change', function() { selectedClientId = $(this).val(); searchStatistics(); }); $('#appSelect').on('change', function() { selectedClientId = $(this).val(); searchStatistics(); });
$('#startDate, #endDate').on('change', searchStatistics); $('#startDate, #endDate').on('change', searchStatistics);
$('#month').on('change', searchStatistics); $('#month').on('change', searchStatistics);
$('#startDate, #endDate').on('keypress', function(e) { if (e.which === 13) searchStatistics(); }); $('#startDate, #endDate').on('keypress', function(e) { if (e.which === 13) searchStatistics(); });
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
if (restoreParams()) {
searchStatistics();
}
}); });
</script> </script>
</th:block> </th:block>
@@ -3,19 +3,22 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title">
<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"> <th:block layout:fragment="contentFragment">
<section class="user-management-container"> <div class="service-main" style="padding-top: 40px;">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('users')}"></th:block>
<!-- Main Content -->
<section class="service-content">
<section class="user-management-container" style="padding-top: 0;">
<!-- App List Title Section --> <!-- App List Title Section -->
<div class="table-controls"> <div class="table-controls">
<h2 class="total-count"> <h2 class="total-count">
<strong th:text="${(users != null ? users.size() : 0) + (pendingUsers != null ? pendingUsers.size() : 0)}">0</strong> <strong th:text="${(users != null ? users.size() : 0) + (pendingUsers != null ? pendingUsers.size() : 0)}">0</strong>
</h2> </h2>
<div class="search-box"> <div class="search-box">
<button type="button" class="btn btn-primary" id="addUserBtn"> <button type="button" class="btn btn-primary" id="addUserBtn">
@@ -26,37 +29,37 @@
</div> </div>
<!-- User Table --> <!-- User Table -->
<div class="list-table" th:if="${(users != null and !users.isEmpty()) or (pendingUsers != null and !pendingUsers.isEmpty())}"> <div class="board-table board-table--user-list" th:if="${(users != null and !users.isEmpty()) or (pendingUsers != null and !pendingUsers.isEmpty())}">
<!-- Table Header --> <!-- Table Header -->
<div class="list-table-header"> <div class="board-table-header">
<div class="header-cell" style="width: 90px;">NO.</div> <div class="header-cell">NO.</div>
<div class="header-cell" style="width: 90px;">성명</div> <div class="header-cell">성명</div>
<div class="header-cell" style="flex: 1; min-width: 180px;">이메일 / 휴대폰</div> <div class="header-cell">이메일 / 휴대폰</div>
<div class="header-cell" style="width: 120px;">권한</div> <div class="header-cell">권한</div>
<div class="header-cell" style="width: 120px;">계정상태</div> <div class="header-cell">계정상태</div>
<div class="header-cell" style="width: 320px;">설정</div> <div class="header-cell">설정</div>
</div> </div>
<!-- Table Body --> <!-- Table Body -->
<div class="list-table-body"> <div class="board-table-body">
<!-- Active/Inactive Users --> <!-- Active/Inactive Users -->
<div class="list-table-row" th:each="user, status : ${users}"> <div class="board-table-row" th:each="user, status : ${users}">
<div class="row-cell" style="width: 90px;" data-label="NO" th:text="${status.index + 1}">1</div> <div class="row-cell" data-label="NO" th:text="${status.index + 1}">1</div>
<div class="row-cell" style="width: 90px;" data-label="성명"> <div class="row-cell" data-label="성명">
<a th:href="@{/users/detail(id=${user.id})}" th:text="${user.maskedUserName}"> <a th:href="@{/users/detail(id=${user.id})}" th:text="${user.maskedUserName}">
사용자 이름 사용자 이름
</a> </a>
</div> </div>
<div class="row-cell" style="flex: 1; min-width: 180px;" data-label="이메일" th:text="${user.maskedEmailAddr}"> <div class="row-cell" data-label="이메일" th:text="${user.maskedEmailAddr}">
user@example.com user@example.com
</div> </div>
<div class="row-cell" style="width: 120px;" data-label="권한"> <div class="row-cell" data-label="권한">
<span th:if="${user.roleCode != null}" th:text="#{${user.roleCode}}">권한</span> <span th:if="${user.roleCode != null}" th:text="#{${user.roleCode}}">권한</span>
</div> </div>
<div class="row-cell" style="width: 120px;" data-label="계정상태" th:text="${user.userStatus.getDescription()}"> <div class="row-cell" data-label="계정상태" th:text="${user.userStatus.getDescription()}">
활성 활성
</div> </div>
<div class="row-cell row-actions" style="width: 320px;"> <div class="row-cell row-actions">
<!-- Current User --> <!-- Current User -->
<span class="user-current-badge" th:if="${user.id == currentUserId}"> <span class="user-current-badge" th:if="${user.id == currentUserId}">
<i class="fas fa-user-check"></i> 현재 사용자 <i class="fas fa-user-check"></i> 현재 사용자
@@ -146,15 +149,15 @@
</div> </div>
<!-- Pending Invitation Users --> <!-- Pending Invitation Users -->
<div class="list-table-row" th:each="user : ${pendingUsers}"> <div class="board-table-row" th:each="user : ${pendingUsers}">
<div class="row-cell" style="width: 90px;" data-label="NO">-</div> <div class="row-cell" data-label="NO">-</div>
<div class="row-cell" style="width: 90px;" data-label="성명">-</div> <div class="row-cell" data-label="성명">-</div>
<div class="row-cell" style="flex: 1; min-width: 180px;" data-label="휴대폰" th:text="${user.maskedMobileNumber}"> <div class="row-cell" data-label="휴대폰" th:text="${user.maskedMobileNumber}">
010-****-**** 010-****-****
</div> </div>
<div class="row-cell" style="width: 120px;" data-label="권한">이용자</div> <div class="row-cell" data-label="권한">이용자</div>
<div class="row-cell" style="width: 120px;" data-label="계정상태">초대중</div> <div class="row-cell" data-label="계정상태">초대중</div>
<div class="row-cell row-actions" style="width: 320px;"> <div class="row-cell row-actions">
<!-- Desktop --> <!-- Desktop -->
<div class="actions-desktop"> <div class="actions-desktop">
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation" <button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
@@ -199,6 +202,8 @@
th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div> th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
</section> </section>
</section>
</div>
</th:block> </th:block>
<section layout:fragment="pagePopups" class="content pop"> <section layout:fragment="pagePopups" class="content pop">
@@ -0,0 +1,72 @@
<!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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Empty Card -->
<div class="s1-form-card">
<div class="webhook-empty">
<div class="empty-icon">🔔</div>
<h3>등록된 Webhook이 없습니다</h3>
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
<p class="field-help" sec:authorize="!hasRole('ROLE_API_KEY_REQUEST')">
Webhook 신청은 법인 관리자만 가능합니다. 기관 관리자에게 문의하세요.
</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button>
</div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('requestWebhook');
if (btn) {
btn.addEventListener('click', function () {
window.location.href = '/webhook/register/step1?clear=true';
});
}
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,190 @@
<!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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Registered Webhook Card -->
<div class="s1-form-card" th:object="${webhook}">
<div class="webhook-card-head">
<h3>등록된 Webhook</h3>
<span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}"
th:text="|등록일 ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일</span>
</div>
<div class="webhook-card-row">
<span class="row-label">수신 URL</span>
<span class="row-value" th:text="*{targetUrl}">https://...</span>
</div>
<div class="webhook-card-row">
<span class="row-label">알림 이벤트</span>
<span class="row-value">
<span class="eventtype-badge" th:each="et : *{eventTypes}" th:text="${et.name}">이벤트</span>
</span>
</div>
<div class="webhook-card-row">
<span class="row-label">대상 API</span>
<span class="row-value">
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
</span>
</div>
<div class="webhook-card-row">
<span class="row-label">Secret Key</span>
<span class="row-value secret-row">
<code id="secretMasked" th:text="*{secretMasked}">••••••••</code>
<th:block sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-mini" id="btnRevealSecret">조회</button>
<button type="button" class="btn-mini" id="btnRegenSecret">재발급</button>
</th:block>
</span>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
<a class="s1-btn-next" th:href="@{/webhook/modify/step1}">수정</a>
</div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
<!-- 비밀번호 재인증 모달 -->
<div class="webhook-modal" id="pwModal" style="display:none;">
<div class="webhook-modal-box">
<h4 id="pwModalTitle">비밀번호 확인</h4>
<p id="pwModalDesc">계속하려면 비밀번호를 입력하세요.</p>
<input type="password" id="pwModalInput" placeholder="비밀번호" autocomplete="current-password">
<p class="field-error" id="pwModalError" style="display:none;"></p>
<div class="webhook-modal-actions">
<button type="button" class="btn-webhook-cancel" id="pwModalCancel">취소</button>
<button type="button" class="btn-webhook-next" id="pwModalConfirm">확인</button>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var csrfToken = document.querySelector('meta[name="_csrf"]');
var csrfHeaderMeta = document.querySelector('meta[name="_csrf_header"]');
var CSRF_TOKEN = csrfToken ? csrfToken.getAttribute('content') : '';
var CSRF_HEADER = csrfHeaderMeta ? csrfHeaderMeta.getAttribute('content') : 'X-XSRF-TOKEN';
var modal = document.getElementById('pwModal');
var input = document.getElementById('pwModalInput');
var errorEl = document.getElementById('pwModalError');
var titleEl = document.getElementById('pwModalTitle');
var descEl = document.getElementById('pwModalDesc');
var pendingAction = null;
function openModal(title, desc, action) {
titleEl.textContent = title;
descEl.textContent = desc;
input.value = '';
errorEl.style.display = 'none';
pendingAction = action;
modal.style.display = 'flex';
input.focus();
}
function closeModal() { modal.style.display = 'none'; pendingAction = null; }
document.getElementById('pwModalCancel').addEventListener('click', closeModal);
document.getElementById('pwModalConfirm').addEventListener('click', function () {
if (pendingAction) { pendingAction(input.value); }
});
input.addEventListener('keyup', function (e) {
if (e.key === 'Enter' && pendingAction) { pendingAction(input.value); }
});
function post(url, password) {
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
headers[CSRF_HEADER] = CSRF_TOKEN;
return fetch(url, {
method: 'POST',
headers: headers,
body: 'password=' + encodeURIComponent(password)
}).then(function (r) { return r.json(); });
}
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
// Secret 조회
var btnReveal = document.getElementById('btnRevealSecret');
if (btnReveal) btnReveal.addEventListener('click', function () {
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
post('/webhook/verify-secret', pw).then(function (res) {
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// Secret 재발급
var btnRegen = document.getElementById('btnRegenSecret');
if (btnRegen) btnRegen.addEventListener('click', function () {
openModal('Secret 재발급',
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
function (pw) {
post('/webhook/regenerate-secret', pw).then(function (res) {
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// 삭제
var btnDelete = document.getElementById('btnDeleteWebhook');
if (btnDelete) btnDelete.addEventListener('click', function () {
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
post('/webhook/delete', pw).then(function (res) {
if (res.success) { window.location.href = '/webhook'; }
else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 기본 정보 수정 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 수정 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- Form Card -->
<div class="s1-form-card">
<form id="webhookStep1Form" method="post" th:action="@{/webhook/modify/step1}"
th:object="${webhookModification}">
<!-- Webhook 수신 URL -->
<div class="s1-field">
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
<div class="webhook-eventtype-list">
<label class="eventtype-item" th:each="et : ${eventTypes}">
<input type="checkbox" name="eventTypes" th:value="${et.code}"
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
<span class="eventtype-code" th:text="${et.code}">CODE</span>
</label>
</div>
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
</div>
</form>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +1,94 @@
<!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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 기본 정보 수정 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</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 선택 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 수정 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookModification.selectedApis}, '/webhook/modify/step2', '/webhook/modify/step2/save')}"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
수정 완료
</button>
</div>
</div><!-- /step2-wrap -->
</div>
</div>
</div>
</th:block>
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
</body>
</html>
@@ -0,0 +1,82 @@
<!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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</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: 수정 완료 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Complete Card -->
<div class="s1-form-card">
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook 설정이 수정되었습니다</h3>
<p class="field-help">Secret Key는 변경되지 않았습니다.</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
</div>
</div><!-- /step3-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 기본 정보 입력 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 신청 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- Form Card -->
<div class="s1-form-card">
<form id="webhookStep1Form" method="post" th:action="@{/webhook/register/step1}"
th:object="${webhookRegistration}">
<!-- Webhook 수신 URL -->
<div class="s1-field">
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
<div class="webhook-eventtype-list">
<label class="eventtype-item" th:each="et : ${eventTypes}">
<input type="checkbox" name="eventTypes" th:value="${et.code}"
th:checked="${#lists.contains(webhookRegistration.eventTypes, et.code)}">
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
<span class="eventtype-code" th:text="${et.code}">CODE</span>
</label>
</div>
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
</div>
</form>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +1,94 @@
<!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}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 기본 정보 입력 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</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 선택 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 신청 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
신청 완료
</button>
</div>
</div><!-- /step2-wrap -->
</div>
</div>
</div>
</th:block>
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
</body>
</html>

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