본문 바로가기
BackEnd/Project

[PT Manager] Ch03. Web 관리자 이용권 등록 페이지

by 개발 Blog 2024. 8. 28.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

이번 포스팅에서는 웹 관리자 페이지에서 이용권을 대량으로 등록할 수 있는 기능을 구현하는 과정을 살펴본다. 이 기능은 여러 사용자 그룹에 대해 특정 패키지를 선택하여 이용권을 한 번에 등록할 수 있도록 도와준다. 아래에서는 이 기능을 구현하는 전체적인 흐름과 코드 구성 요소를 설명한다.

1. 웹 관리자 페이지 컨트롤러 설정

먼저, 웹 관리자 페이지의 컨트롤러를 설정한다. 이 컨트롤러는 대량 이용권 등록 페이지를 렌더링 하고, 사용자가 입력한 정보를 처리하여 새로운 이용권을 등록하는 역할을 한다.

package com.example.pass.contoller.admin;

import com.example.pass.service.packaze.PackageService;
import com.example.pass.service.pass.BulkPassService;
import com.example.pass.service.user.UserGroupMappingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequestMapping(value = "/admin")
public class AdminViewController {
    @Autowired
    private BulkPassService bulkPassService;
    @Autowired
    private PackageService packageService;
    @Autowired
    private UserGroupMappingService userGroupMappingService;

    @GetMapping("/bulk-pass")
    public ModelAndView registerBulkPass(ModelAndView modelAndView) {
        modelAndView.addObject("bulkPasses", bulkPassService.getAllBulkPasses());
        modelAndView.addObject("packages", packageService.getAllPackages());
        modelAndView.addObject("userGroupIds", userGroupMappingService.getAllUserGroupIds());
        modelAndView.addObject("request", new BulkPassRequest());
        modelAndView.setViewName("admin/bulk-pass");

        return modelAndView;
    }

    @PostMapping("/bulk-pass")
    public String addBulkPass(@ModelAttribute("request") BulkPassRequest request, Model model) {
        bulkPassService.addBulkPass(request);
        return "redirect:/admin/bulk-pass";
    }
}
  • AdminViewController는 /admin/bulk-pass 경로로 접근할 때 호출되며, GET 요청과 POST 요청을 처리한다. GET 요청은 등록 페이지를 보여주기 위한 것이고, POST 요청은 사용자가 입력한 대량 이용권 등록 정보를 서버에 전달하여 처리하는 역할을 한다.
  • GET 요청이 처리될 때는 현재 등록된 모든 대량 이용권 목록, 패키지 목록, 그리고 사용자 그룹 ID 목록을 모델에 추가하여 뷰에 전달한다. 사용자는 이 정보를 기반으로 패키지와 사용자 그룹을 선택하여 이용권을 등록할 수 있다.

2. 서비스 계층 구현

서비스 계층에서는 비즈니스 로직을 처리하며, 이를 위해 몇 가지 추가적인 데이터 클래스를 사용한다.

 

BulkPassService

BulkPassService는 대량 이용권과 관련된 주요 비즈니스 로직을 담당한다. 예를 들어, 모든 대량 이용권을 조회하거나 새로운 대량 이용권을 등록하는 기능을 제공한다. 새로운 대량 이용권을 등록할 때는 사용자가 선택한 패키지 정보를 가져와서 이용권의 수량과 기간을 설정한다.

package com.example.pass.service.pass;

import com.example.pass.contoller.admin.BulkPassRequest;
import com.example.pass.repository.packaze.PackageEntity;
import com.example.pass.repository.packaze.PackageRepository;
import com.example.pass.repository.pass.BulkPassEntity;
import com.example.pass.repository.pass.BulkPassRepository;
import com.example.pass.repository.pass.BulkPassStatus;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class BulkPassService {
    private final BulkPassRepository bulkPassRepository;

    private final PackageRepository packageRepository;

    public BulkPassService(BulkPassRepository bulkPassRepository, PackageRepository packageRepository) {
        this.bulkPassRepository = bulkPassRepository;
        this.packageRepository = packageRepository;
    }

    public List<BulkPass> getAllBulkPasses() {
        List<BulkPassEntity> bulkPassEntities = bulkPassRepository.findAllOrderByStartedAtDesc();
        return BulkPassModelMapper.INSTANCE.map(bulkPassEntities);
    }

    public void addBulkPass(BulkPassRequest bulkPassRequest) {
        PackageEntity packageEntity = packageRepository.findById(bulkPassRequest.getPackageSeq()).orElseThrow();

        BulkPassEntity bulkPassEntity = BulkPassModelMapper.INSTANCE.map(bulkPassRequest);
        bulkPassEntity.setStatus(BulkPassStatus.READY);
        bulkPassEntity.setCount(packageEntity.getCount());
        bulkPassEntity.setEndedAt(packageEntity.getPeriod());

        bulkPassRepository.save(bulkPassEntity);
    }

}

 

PackageService

PackageService는 패키지 정보를 관리하는 서비스로, 등록된 모든 패키지 목록을 제공한다. 이를 통해 관리자는 이용권을 등록할 때 선택 가능한 패키지 목록을 사용자에게 제공할 수 있다.

package com.example.pass.service.packaze;

import com.example.pass.repository.packaze.PackageEntity;
import com.example.pass.repository.packaze.PackageRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class PackageService {
    private final PackageRepository packageRepository;

    public PackageService(PackageRepository packageRepository) {
        this.packageRepository = packageRepository;
    }

    public List<Package> getAllPackages() {
        List<PackageEntity> bulkPassEntities = packageRepository.findAllByOrderByPackageName();
        return PackageModelMapper.INSTANCE.map(bulkPassEntities);
    }
}

 

UserGroupMappingService

UserGroupMappingService는 사용자 그룹과 관련된 로직을 처리하며, 등록된 모든 사용자 그룹 ID를 제공한다. 이를 통해 관리자는 특정 사용자 그룹에 대해 대량 이용권을 등록할 수 있다.

package com.example.pass.service.user;

import com.example.pass.repository.user.UserGroupMappingRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserGroupMappingService {
    private final UserGroupMappingRepository userGroupMappingRepository;

    public UserGroupMappingService(UserGroupMappingRepository userGroupMappingRepository) {
        this.userGroupMappingRepository = userGroupMappingRepository;
    }

    public List<String> getAllUserGroupIds() {
        return userGroupMappingRepository.findDistinctUserGroupId();

    }
}

 

3. 엔티티 및 리포지토리 계층

엔티티는 데이터베이스와의 매핑을 담당하며, 리포지토리는 이러한 엔티티에 대한 데이터베이스 접근 로직을 제공한다. 이번 기능에서는 BulkPassEntity, PackageEntity, UserGroupMappingEntity가 주요 엔티티로 사용된다.

 

BulkPassEntity

BulkPassEntity는 대량 이용권 정보를 저장하는 엔티티로, 데이터베이스 테이블과 매핑된다. 이 엔티티는 패키지, 사용자 그룹, 상태, 수량, 시작 및 종료 일시 등의 정보를 포함한다.

package com.example.pass.repository.pass;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.*;
import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@Entity
@Table(name = "bulk_pass")
public class BulkPassEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본 키 생성을 DB에 위임합니다. (AUTO_INCREMENT)
    private Integer bulkPassSeq;
    private Integer packageSeq;
    private String userGroupId;

    @Enumerated(EnumType.STRING)
    private BulkPassStatus status;
    private Integer count;

    private LocalDateTime startedAt;
    private LocalDateTime endedAt;

    public void setEndedAt(Integer period) {
        if (period == null) {
            return;

        }
        this.endedAt = this.startedAt.plusDays(period);

    }

    public void setEndedAt(LocalDateTime endedAt) {
        this.endedAt = endedAt;

    }

}

 

BulkPassRepository

BulkPassRepository는 BulkPassEntity에 대한 데이터베이스 접근을 담당하며, 모든 대량 이용권을 시작 일시 역순으로 조회하는 쿼리를 포함한다. 이를 통해 최근에 등록된 대량 이용권을 우선적으로 조회할 수 있다.

package com.example.pass.repository.pass;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface BulkPassRepository extends JpaRepository<BulkPassEntity, Integer> {

    @Query(value = "select b from BulkPassEntity b " +
            "order by b.startedAt desc")
    List<BulkPassEntity> findAllOrderByStartedAtDesc();
}

 

UserGroupMappingEntity

UserGroupMappingEntity는 사용자 그룹 매핑 정보를 저장하는 엔티티로, 사용자 그룹 ID와 사용자 ID를 포함한다. 이 엔티티는 복합 키를 사용하여 사용자 그룹과 사용자 간의 관계를 관리한다.

package com.example.pass.repository.user;

import com.example.pass.repository.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;

@Getter
@Setter
@ToString
@Entity
@Table(name = "user_group_mapping")
@IdClass(UserGroupMappingId.class)
public class UserGroupMappingEntity extends BaseEntity {
    @Id
    private String userGroupId;
    @Id
    private String userId;

    private String userGroupName;
    private String description;

}

 

UserGroupMappingRepository

UserGroupMappingRepository는 사용자 그룹 매핑 엔티티에 대한 데이터베이스 접근을 담당하며, 모든 고유한 사용자 그룹 ID를 조회하는 쿼리를 포함한다. 이를 통해 관리자에게 등록된 사용자 그룹 목록을 제공할 수 있다.

package com.example.pass.repository.user;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface UserGroupMappingRepository extends JpaRepository<UserGroupMappingEntity, Integer> {

    @Query("select distinct u.userGroupId " +
            "from UserGroupMappingEntity u " +
            "order by u.userGroupId")
    List<String> findDistinctUserGroupId();
}

4. 데이터 모델과 DTO

서비스 계층에서 비즈니스 로직을 처리할 때 데이터를 주고받거나 상태를 관리하기 위해 다양한 데이터 모델과 DTO(Data Transfer Object)를 사용한다. 이들은 주로 서비스 계층과 컨트롤러 간의 데이터 교환을 위해 사용되며, 데이터베이스 엔티티와는 별도로 관리된다.

 

BulkPass

이 클래스는 대량 이용권의 정보를 담는 데이터 모델이다. 서비스 계층에서 대량 이용권과 관련된 로직을 처리할 때 사용된다.

package com.example.pass.service.pass;

import com.example.pass.repository.pass.BulkPassStatus;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
public class BulkPass {
    private Integer bulkPassSeq;
    private String userGroupId;
    private Integer count;
    private BulkPassStatus status;
    private LocalDateTime startedAt;
    private LocalDateTime endedAt;

}
  • 이 클래스는 주로 서비스 계층에서 대량 이용권의 상태와 정보를 관리하는데 사용된다. 이를 통해 다양한 비즈니스 로직을 수행할 수 있다.

BulkPassRequest

이 클래스는 대량 이용권 등록 시 클라이언트에서 전달된 데이터를 담는 DTO이다.

package com.example.pass.contoller.admin;

import com.example.pass.util.LocalDateTimeUtils;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
public class BulkPassRequest {
    private Integer packageSeq;
    private String userGroupId;
    private LocalDateTime startedAt;

    public void setStartedAt(String startedAtString) {
        this.startedAt = LocalDateTimeUtils.parse(startedAtString);

    }

}
  • 이 클래스는 컨트롤러에서 사용자가 입력한 데이터를 받아 서비스 계층으로 전달할 때 사용된다. 이를 통해 사용자 입력을 기반으로 새로운 대량 이용권을 생성하는 로직을 처리할 수 있다.

BulkPassStatus

이 enum은 대량 이용권의 상태를 나타내는 열거형이다.

package com.example.pass.repository.pass;

public enum BulkPassStatus {
    READY, COMPLETED
}
  • 서비스 계층에서 대량 이용권의 상태를 관리할 때 사용된다. 이를 통해 이용권이 현재 어떤 상태에 있는지 명확하게 구분할 수 있다.

Package

이 클래스는 패키지 정보를 담는 데이터 모델이다. 서비스 계층에서 패키지 관련 로직을 처리할 때 사용된다.

package com.example.pass.service.packaze;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class Package {
    private Integer packageSeq;
    private String packageName;
}
  • 이 클래스는 주로 패키지 정보를 담고, 이를 서비스 계층에서 활용하여 이용권 등록 시 필요한 패키지 정보를 제공하는 데 사용된다. 예를 들어, 어떤 패키지에 대해 이용권을 등록할지 결정할 때 이 클래스의 인스턴스를 사용한다.

UserGroupMappingId

이 클래스는 사용자 그룹 매핑 엔티티에서 복합 키를 관리하기 위한 데이터 모델이다.

package com.example.pass.repository.user;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.io.Serializable;

@Getter
@Setter
@ToString
public class UserGroupMappingId implements Serializable {
    private String userGroupId;
    private String userId;
}
  • 이 클래스는 JPA에서 복합 키를 관리할 때 사용된다. UserGroupMappingEntity와 함께 사용되며, 사용자와 그룹 간의 관계를 나타내는 복합 키를 정의한다. 이를 통해 특정 사용자와 그룹 간의 매핑을 고유하게 식별할 수 있다.

5. View 파일 구성

대량 이용권 등록 페이지와 관련된 뷰 파일들에 대해 설명한다. 이 뷰 파일들은 Thymeleaf 템플릿 엔진을 사용하여 서버 측에서 동적으로 HTML을 생성한다.

 

bulk-pass.html

이 파일은 대량 이용권 등록 페이지를 위한 메인 뷰이다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="/admin/header :: header"></th:block>
<body id="page-top">
<!-- Page Wrapper -->
<div id="wrapper">
    <th:block th:replace="/admin/sidebar :: sidebar"></th:block>
    <!-- Content Wrapper -->
    <div id="content-wrapper" class="d-flex flex-column">
        <!-- Main Content -->
        <div id="content">
            <th:block th:replace="/admin/topbar :: topbar"></th:block>
            <!-- Begin Page Content -->
            <div class="container-fluid">
                <!-- Page Heading -->
                <h1 class="h3 mb-1 text-gray-800">Passes</h1>
                <p class="mb-4">다수의 회원들에게 이용권을 일괄 지급을 예약할 수 있습니다. 이용권은 시작 시간 1일 전 회원들에게 지급됩니다.</p>
                <!-- Content Row -->
                <div class="row">
                    <!-- Grow In Utility -->
                    <div class="col-lg-8">
                        <div class="card position-relative">
                            <div class="card-header py-3">
                                <h6 class="m-0 font-weight-bold text-primary">이용권 일괄 지급 목록</h6>
                            </div>
                            <div class="card-body">
                                <div class="table-responsive">
                                    <table class="table table-bordered" id="dataTable" width="100%" cellspacing="0">
                                        <thead>
                                        <tr>
                                            <th>회원 그룹 ID</th>
                                            <th>상태</th>
                                            <th>횟수</th>
                                            <th>시작 일시</th>
                                            <th>종료 일시</th>
                                        </tr>
                                        </thead>
                                        <tbody>
                                        <tr th:each="bulkPass : ${bulkPasses}" th:classappend="${bulkPass.getStatus().name() == 'COMPLETED' ? 'table-active' : ''}">
                                            <td th:text="${bulkPass.getUserGroupId()}"></td>
                                            <td th:if="${bulkPass.getStatus().name() == 'COMPLETED'}">지급 완료</td>
                                            <td th:unless="${bulkPass.getStatus().name() == 'COMPLETED'}">지급 전</td>
                                            <td th:text="${bulkPass.getCount()}"></td>
                                            <td th:text="${#temporals.format(bulkPass.getStartedAt(), 'yyyy-MM-dd HH:mm')}"></td>
                                            <td th:text="${#temporals.format(bulkPass.getEndedAt(), 'yyyy-MM-dd HH:mm')}"></td>
                                        </tr>
                                        </tbody>
                                    </table>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="col-lg-4">
                        <div class="card position-relative full-height">
                            <div class="card-header py-3">
                                <h6 class="m-0 font-weight-bold text-primary">이용권 일괄 지급 등록</h6>
                            </div>
                            <div class="card-body">
                                <form action="#" th:action="@{/admin/bulk-pass}" th:object="${request}" method="post">
                                    <div class="small mb-1">패키지</div>
                                    <select class="form-control mb-3" th:field="*{packageSeq}">
                                        <option value="">등록된 패키지를 선택해주세요.</option>
                                        <option th:each="package : ${packages}"
                                                th:value="${package.getPackageSeq()}"
                                                th:text="${package.getPackageName()}" >
                                        </option>
                                    </select>

                                    <div class="small mb-1">회원 그룹 ID</div>
                                    <select class="form-control mb-3" th:field="*{userGroupId}">
                                        <option value="">등록된 회원 그룹을 선택해주세요.</option>
                                        <option th:each="userGroupId : ${userGroupIds}"
                                                th:value="${userGroupId}"
                                                th:text="${userGroupId}">
                                        </option>
                                    </select>

                                    <div class="small mb-1">시작 일시</div>
                                    <input class="form-control mb-5" type="text" placeholder="2022-09-01 00:00" th:field="*{startedAt}" />
                                    <div class="d-flex align-items-end flex-column">
                                        <input class="btn btn-primary" type="submit" value="+ 등록">
                                    </div>

                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <!-- Fade In Utility -->

    </div>

</div>

</div>
<!-- /.container-fluid -->

</div>
<!-- End of Main Content -->
<th:block th:replace="/admin/footer :: footer"></th:block>
</div>
<!-- End of Content Wrapper -->

</div>
<!-- End of Page Wrapper -->
<th:block th:replace="/admin/script :: script"></th:block>

</body>
</html>
  • Header 및 Sidebar: 페이지 상단의 헤더와 좌측 사이드바는 각각 header.html과 sidebar.html에서 공통적으로 불러온다. 이를 통해 전체 관리 페이지의 일관성을 유지한다.
  • 이용권 일괄 지급 목록: bulkPasses라는 변수로 전달된 대량 이용권 목록을 테이블 형식으로 출력한다. 이용권의 상태에 따라 '지급 전' 또는 '지급 완료'로 표시되며, 완료된 항목은 시각적으로 구분된다.
  • 이용권 일괄 지급 등록 폼: 패키지 선택, 사용자 그룹 선택, 시작 일시 입력 필드가 포함된 폼이다. 이 폼에서 입력된 데이터는 서버로 전송되어 새로운 대량 이용권 등록 요청을 처리한다.

footer.html

이 파일은 페이지 하단의 푸터를 정의한다. 모든 관리자 페이지의 하단에 동일한 푸터를 표시하기 위해 사용된다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="footer">
    <footer class="sticky-footer bg-white">
        <div class="container my-auto">
            <div class="copyright text-center my-auto">
                <span>Copyright &copy; PT Pass Admin 2024</span>
            </div>
        </div>
    </footer>
</th:block>

 

header.html

이 파일은 페이지의 헤더를 정의한다. 메타 태그, 폰트, CSS 파일 등을 로드하며, 페이지 제목도 설정한다. 각 페이지 상단에 공통적으로 적용되어 일관된 레이아웃을 제공한다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="header">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <meta name="description" content="">
        <meta name="author" content="">
        <link rel="icon" type="image/x-icon" href="/admin/img/favicon.ico" />

        <title>PT Pass Admin - Manage passes</title>

        <!-- Custom fonts for this template-->
        <link href="/admin/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
        <link href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i" rel="stylesheet">

        <!-- Custom styles for this template-->
        <link href="/admin/css/sb-admin-2.min.css" rel="stylesheet">
    </head>
</th:block>>

 

index.html

관리자 대시보드의 메인 페이지 뷰이다. 주요 통계와 요약 정보를 표시하며, 대시보드에 필요한 데이터들을 시각적으로 표현한다. 각 섹션은 카드 형식으로 구성되어 있으며, 일별 출석 및 취소 데이터를 차트로 표현할 수 있는 섹션도 포함된다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="/admin/header :: header"></th:block>
<body id="page-top">
    <!-- Page Wrapper -->
    <div id="wrapper">
        <th:block th:replace="/admin/sidebar :: sidebar"></th:block>
        <!-- Content Wrapper -->
        <div id="content-wrapper" class="d-flex flex-column">
            <!-- Main Content -->
            <div id="content">
                <th:block th:replace="/admin/topbar :: topbar"></th:block>
                <!-- Begin Page Content -->
                <div class="container-fluid">
                    <!-- Page Heading -->
                    <div class="d-sm-flex align-items-center justify-content-between mb-4">
                        <h1 class="h3 mb-0 text-gray-800">Dashboard</h1>
                        <a href="#" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm"><i
                                class="fas fa-download fa-sm text-white-50"></i> 요약 다운로드</a>
                    </div>

                    <!-- Content Row -->
                    <div class="row">
                        <div class="col-xl-3 col-md-6 mb-4">
                            <div class="card border-left-primary shadow h-100 py-2">
                                <div class="card-body">
                                    <div class="row no-gutters align-items-center">
                                        <div class="col mr-2">
                                            <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
                                                활성화 회원 수</div>
                                            <div class="h5 mb-0 font-weight-bold text-gray-800">10</div>
                                        </div>
                                        <div class="col-auto">
                                            <i class="fas fa-user fa-2x text-gray-300"></i>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>

                        <div class="col-xl-3 col-md-6 mb-4">
                            <div class="card border-left-success shadow h-100 py-2">
                                <div class="card-body">
                                    <div class="row no-gutters align-items-center">
                                        <div class="col mr-2">
                                            <div class="text-xs font-weight-bold text-success text-uppercase mb-1">
                                                소득 (해당 월 기준)</div>
                                            <div class="h5 mb-0 font-weight-bold text-gray-800">₩2,150,000</div>
                                        </div>
                                        <div class="col-auto">
                                            <i class="fas fa-won-sign fa-2x text-gray-300"></i>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <div class="col-xl-3 col-md-6 mb-4">
                            <div class="card border-left-info shadow h-100 py-2">
                                <div class="card-body">
                                    <div class="row no-gutters align-items-center">
                                        <div class="col mr-2">
                                            <div class="text-xs font-weight-bold text-info text-uppercase mb-1">태스크</div>
                                            <div class="row no-gutters align-items-center">
                                                <div class="col-auto">
                                                    <div class="h5 mb-0 mr-3 font-weight-bold text-gray-800">50%</div>
                                                </div>
                                                <div class="col">
                                                    <div class="progress progress-sm mr-2">
                                                        <div class="progress-bar bg-info" role="progressbar"
                                                            style="width: 50%" aria-valuenow="50" aria-valuemin="0"
                                                            aria-valuemax="100"></div>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                        <div class="col-auto">
                                            <i class="fas fa-clipboard-list fa-2x text-gray-300"></i>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <!-- Pending Requests Card Example -->
                        <div class="col-xl-3 col-md-6 mb-4">
                            <div class="card border-left-warning shadow h-100 py-2">
                                <div class="card-body">
                                    <div class="row no-gutters align-items-center">
                                        <div class="col mr-2">
                                            <div class="text-xs font-weight-bold text-warning text-uppercase mb-1">오늘의 수업</div>
                                            <div class="h5 mb-0 font-weight-bold text-gray-800">10</div>
                                        </div>
                                        <div class="col-auto">
                                            <i class="fas fa-dumbbell fa-2x text-gray-300"></i>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                    <!-- Content Row -->
                    <div class="row">
                        <!-- Area Chart -->
                        <div class="col-xl-12 col-lg-7">
                            <div class="card shadow mb-4">
                                <!-- Card Header - Dropdown -->
                                <div
                                    class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
                                    <h6 class="m-0 font-weight-bold text-primary">일별 출석 및 취소</h6>
                                    <div class="dropdown no-arrow">
                                        <a class="dropdown-toggle" href="#" role="button" id="dropdownMenuLink"
                                            data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                                            <i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
                                        </a>
                                        <div class="dropdown-menu dropdown-menu-right shadow animated--fade-in"
                                            aria-labelledby="dropdownMenuLink">
                                            <div class="dropdown-header">Dropdown Header:</div>
                                            <a class="dropdown-item" href="#">Action</a>
                                            <a class="dropdown-item" href="#">Another action</a>
                                            <div class="dropdown-divider"></div>
                                            <a class="dropdown-item" href="#">Something else here</a>
                                        </div>
                                    </div>
                                </div>
                                <!-- Card Body -->
                                <div class="card-body">
                                    <div class="chart-area">
                                        <canvas id="myAreaChart"></canvas>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>

                </div>
                <!-- /.container-fluid -->

            </div>
            <!-- End of Main Content -->

            <th:block th:replace="/admin/footer :: footer"></th:block>

        </div>
        <!-- End of Content Wrapper -->

    </div>
    <!-- End of Page Wrapper -->

    <th:block th:replace="/admin/script :: script"></th:block>
    <script src="/admin/vendor/chart.js/Chart.min.js"></script>
    <script th:inline="javascript">
        /*<![CDATA[*/
        let chartData = /*[[${chartData}]]*/ "";
        /*]]>*/
    </script>
    <script src="/admin/js/chart-area.js"></script>

</body>

</html>

 

script.html

이 파일은 모든 페이지에 공통적으로 사용되는 JavaScript 파일들을 로드하는 역할을 한다. jQuery, Bootstrap, 커스텀 스크립트 등이 포함되어 있으며, 이를 통해 페이지의 인터랙티브 기능을 구현한다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="script">
    <!-- Bootstrap core JavaScript-->
    <script src="/admin/vendor/jquery/jquery.min.js"></script>
    <script src="/admin/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>

    <!-- Core plugin JavaScript-->
    <script src="/admin/vendor/jquery-easing/jquery.easing.min.js"></script>

    <!-- Custom scripts for all pages-->
    <script src="/admin/js/sb-admin-2.min.js"></script>
</th:block>
</html>

 

sidebar.html

이 파일은 좌측 사이드바를 정의한다. 대시보드, 이용권 관리, 사용자 관리 등의 메뉴 항목을 포함하고 있으며, 각 항목은 관리자 페이지의 주요 섹션으로 연결된다. 사이드바는 페이지 내의 다른 부분과 독립적으로 동작할 수 있도록 구성되어 있다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="sidebar">
    <ul class="navbar-nav bg-gradient-primary sidebar sidebar-dark accordion" id="accordionSidebar">
        <!-- Sidebar - Brand -->
        <a class="sidebar-brand d-flex align-items-center justify-content-center" href="/admin">
            <div class="sidebar-brand-icon">
                <i class="fas fa-running"></i>
            </div>
            <div class="sidebar-brand-text mx-3">Pass Admin</div>
        </a>

        <!-- Divider -->
        <hr class="sidebar-divider my-0">

        <!-- Nav Item - Dashboard -->
        <li class="nav-item">
            <a class="nav-link" href="/admin">
                <i class="fas fa-fw fa-tachometer-alt"></i>
                <span>Dashboard</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider">

        <!-- Heading -->
        <div class="sidebar-heading">
            your gym
        </div>

        <!-- Nav Item - Pages Collapse Menu -->
        <li class="nav-item">
            <a class="nav-link" href="/admin/bulk-pass">
                <i class="fas fa-fw fa-dumbbell"></i>
                <span>Pass</span></a>
        </li>
        <li class="nav-item">
            <a class="nav-link" href="#">
                <i class="fas fa-fw fa-user"></i>
                <span>Users</span></a>
        </li>
        <li class="nav-item">
            <a class="nav-link" href="#">
                <i class="fas fa-fw fa-users"></i>
                <span>User Groups</span></a>
        </li>
        <li class="nav-item">
            <a class="nav-link" href="#">
                <i class="fas fa-fw fa-chart-area"></i>
                <span>Statistics</span></a>
        </li>
        <li class="nav-item">
            <a class="nav-link" href="#">
                <i class="fas fa-fw fa-table"></i>
                <span>Notice</span></a>
        </li>

        <li class="nav-item">
            <a class="nav-link" href="#">
                <i class="fas fa-fw fa-cog"></i>
                <span>Settings</span></a>
        </li>

        <hr class="sidebar-divider d-none d-md-block">
        <!-- Sidebar Toggler (Sidebar) -->
        <div class="text-center d-none d-md-inline">
            <button class="rounded-circle border-0" id="sidebarToggle"></button>
        </div>

    </ul>
</th:block>

 

topbar.html

이 파일은 페이지 상단의 네비게이션 바를 정의한다. 사용자 프로필, 알림, 메시지 등의 항목을 포함하며, 페이지 상단에서 쉽게 접근할 수 있도록 한다. 모바일 뷰에서도 적절히 동작할 수 있도록 구성되어 있다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="topbar">
    <nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">

        <!-- Sidebar Toggle (Topbar) -->
        <button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
            <i class="fa fa-bars"></i>
        </button>

        <!-- Topbar Search -->
        <form class="d-none d-sm-inline-block form-inline mr-auto ml-md-3 my-2 my-md-0 mw-100 navbar-search">
            <div class="input-group">
                <input type="text" class="form-control bg-light border-0 small" placeholder="Search for..."
                       aria-label="Search" aria-describedby="basic-addon2">
                <div class="input-group-append">
                    <button class="btn btn-primary" type="button">
                        <i class="fas fa-search fa-sm"></i>
                    </button>
                </div>
            </div>
        </form>

        <!-- Topbar Navbar -->
        <ul class="navbar-nav ml-auto">

            <!-- Nav Item - Search Dropdown (Visible Only XS) -->
            <li class="nav-item dropdown no-arrow d-sm-none">
                <a class="nav-link dropdown-toggle" href="#" id="searchDropdown" role="button"
                   data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                    <i class="fas fa-search fa-fw"></i>
                </a>
                <!-- Dropdown - Messages -->
                <div class="dropdown-menu dropdown-menu-right p-3 shadow animated--grow-in"
                     aria-labelledby="searchDropdown">
                    <form class="form-inline mr-auto w-100 navbar-search">
                        <div class="input-group">
                            <input type="text" class="form-control bg-light border-0 small"
                                   placeholder="Search for..." aria-label="Search"
                                   aria-describedby="basic-addon2">
                            <div class="input-group-append">
                                <button class="btn btn-primary" type="button">
                                    <i class="fas fa-search fa-sm"></i>
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </li>

            <!-- Nav Item - Alerts -->
            <li class="nav-item dropdown no-arrow mx-1">
                <a class="nav-link dropdown-toggle" href="#" id="alertsDropdown" role="button"
                   data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                    <i class="fas fa-bell fa-fw"></i>
                    <!-- Counter - Alerts -->
                    <span class="badge badge-danger badge-counter">3+</span>
                </a>
                <!-- Dropdown - Alerts -->
                <div class="dropdown-list dropdown-menu dropdown-menu-right shadow animated--grow-in"
                     aria-labelledby="alertsDropdown">
                    <h6 class="dropdown-header">
                        Alerts Center
                    </h6>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="mr-3">
                            <div class="icon-circle bg-primary">
                                <i class="fas fa-file-alt text-white"></i>
                            </div>
                        </div>
                        <div>
                            <div class="small text-gray-500">December 12, 2019</div>
                            <span class="font-weight-bold">A new monthly report is ready to download!</span>
                        </div>
                    </a>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="mr-3">
                            <div class="icon-circle bg-success">
                                <i class="fas fa-donate text-white"></i>
                            </div>
                        </div>
                        <div>
                            <div class="small text-gray-500">December 7, 2019</div>
                            $290.29 has been deposited into your account!
                        </div>
                    </a>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="mr-3">
                            <div class="icon-circle bg-warning">
                                <i class="fas fa-exclamation-triangle text-white"></i>
                            </div>
                        </div>
                        <div>
                            <div class="small text-gray-500">December 2, 2019</div>
                            Spending Alert: We've noticed unusually high spending for your account.
                        </div>
                    </a>
                    <a class="dropdown-item text-center small text-gray-500" href="#">Show All Alerts</a>
                </div>
            </li>

            <!-- Nav Item - Messages -->
            <li class="nav-item dropdown no-arrow mx-1">
                <a class="nav-link dropdown-toggle" href="#" id="messagesDropdown" role="button"
                   data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                    <i class="fas fa-envelope fa-fw"></i>
                    <!-- Counter - Messages -->
                    <span class="badge badge-danger badge-counter">7</span>
                </a>
                <!-- Dropdown - Messages -->
                <div class="dropdown-list dropdown-menu dropdown-menu-right shadow animated--grow-in"
                     aria-labelledby="messagesDropdown">
                    <h6 class="dropdown-header">
                        Message Center
                    </h6>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="dropdown-list-image mr-3">
                            <img class="rounded-circle" src="/admin/img/undraw_profile_1.svg"
                                 alt="...">
                            <div class="status-indicator bg-success"></div>
                        </div>
                        <div class="font-weight-bold">
                            <div class="text-truncate">Hi there! I am wondering if you can help me with a
                                problem I've been having.</div>
                            <div class="small text-gray-500">Emily Fowler · 58m</div>
                        </div>
                    </a>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="dropdown-list-image mr-3">
                            <img class="rounded-circle" src="/admin/img/undraw_profile_2.svg"
                                 alt="...">
                            <div class="status-indicator"></div>
                        </div>
                        <div>
                            <div class="text-truncate">I have the photos that you ordered last month, how
                                would you like them sent to you?</div>
                            <div class="small text-gray-500">Jae Chun · 1d</div>
                        </div>
                    </a>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="dropdown-list-image mr-3">
                            <img class="rounded-circle" src="/admin/img/undraw_profile_3.svg"
                                 alt="...">
                            <div class="status-indicator bg-warning"></div>
                        </div>
                        <div>
                            <div class="text-truncate">Last month's report looks great, I am very happy with
                                the progress so far, keep up the good work!</div>
                            <div class="small text-gray-500">Morgan Alvarez · 2d</div>
                        </div>
                    </a>
                    <a class="dropdown-item d-flex align-items-center" href="#">
                        <div class="dropdown-list-image mr-3">
                            <img class="rounded-circle" src="https://source.unsplash.com/Mv9hjnEUHR4/60x60"
                                 alt="...">
                            <div class="status-indicator bg-success"></div>
                        </div>
                        <div>
                            <div class="text-truncate">Am I a good boy? The reason I ask is because someone
                                told me that people say this to all dogs, even if they aren't good...</div>
                            <div class="small text-gray-500">Chicken the Dog · 2w</div>
                        </div>
                    </a>
                    <a class="dropdown-item text-center small text-gray-500" href="#">Read More Messages</a>
                </div>
            </li>

            <div class="topbar-divider d-none d-sm-block"></div>

            <!-- Nav Item - User Information -->
            <li class="nav-item dropdown no-arrow">
                <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button"
                   data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                    <span class="mr-2 d-none d-lg-inline text-gray-600 small">김은찬</span>
                    <img class="img-profile rounded-circle"
                         src="/admin/img/undraw_profile_3.svg">
                </a>
                <!-- Dropdown - User Information -->
                <div class="dropdown-menu dropdown-menu-right shadow animated--grow-in"
                     aria-labelledby="userDropdown">
                    <a class="dropdown-item" href="#">
                        <i class="fas fa-user fa-sm fa-fw mr-2 text-gray-400"></i>
                        Profile
                    </a>
                    <a class="dropdown-item" href="#">
                        <i class="fas fa-cogs fa-sm fa-fw mr-2 text-gray-400"></i>
                        Settings
                    </a>
                    <a class="dropdown-item" href="#">
                        <i class="fas fa-list fa-sm fa-fw mr-2 text-gray-400"></i>
                        Activity Log
                    </a>
                    <div class="dropdown-divider"></div>
                    <a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
                        <i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
                        Logout
                    </a>
                </div>
            </li>

        </ul>

    </nav>
</th:block>

6. 실행화면

위에서 구현한 웹 관리자 페이지의 대량 이용권 등록 기능의 실행 화면을 소개한다.

  • 현재 등록된 모든 대량 이용권이 테이블 형태로 표시된다. 각 이용권은 회원 그룹 ID, 상태, 횟수, 시작 일시, 종료 일시 정보가 포함된다.
  • 우측에는 새로운 대량 이용권을 등록하기 위한 폼이 있다. 패키지회원 그룹 ID를 선택하고 시작 일시를 입력한 후, "+ 등록" 버튼을 눌러 새로운 이용권을 등록할 수 있다.
  • 등록된 이용권 정보는 목록에 바로 반영되며, 추가된 항목은 상태가 "지급 전"으로 표시된다.

이로써 웹 관리자 페이지에서 대량 이용권 등록 기능을 구현하고, 이를 실제로 동작시키는 과정까지 모두 살펴보았다. 이번 구현을 통해 관리자는 여러 사용자 그룹에 대해 효율적으로 이용권을 관리할 수 있게 되었다. 앞으로 이 기능을 기반으로 더욱 다양한 관리 기능을 추가해 나갈 수 있을 것이다.