VisualAcademy.Models.Departments

  • 51 minutes to read

VisualAcademy.Models.Departments 이름의 패키지는 C# 클래스 라이브러리를 사용하여 SQL Server 데이터베이스에 CRUD를 진행하는 교과서다운 코드를 모아놓았습니다.

VisualAcademy GitHub에 VisualAcademy.Models.Departments 이름으로 리포지토리가 만들어져 이곳에서 만든 코드가 Hawaso 프로젝트 등에 NuGet 패키지로 포함되어 사용될 수 있습니다.

이 패키지와 유사한 성격을 가지는 패지키는 파일 업로드 및 다운로드 기능을 염두에 둔 완성형 게시판 소스인 Memos 패키지입니다. 이 내용은 Hawaso 프로젝트의 Memos 모듈을 참고하세요.

Blazor Server CRUD 교과서 코드

Hawaso 프로젝트에서 만든 Departments 모듈은 가장 간단한 CRUD 기능을 Blazor Server를 사용하여 구현한 내용입니다.

이를 사용하면, 입력, 출력, 수정, 삭제의 기능을 단일 페이지에서 구현할 수 있는 장점이 있습니다.

따라서, MVC 프로젝트에 component 태그 헬퍼를 사용하여 인클루드하여 Blazor Server 컴포넌트롤 로드하여 MVC 페이지의 어느 곳에서나 유용하게 특정 기능만 SPA로 구현할 수 있는 장점을 가집니다.

테이블 구조는 간단하지만, 단일 테이블로 이루어진 모든 기능에 손쉽게 적용할 수 있습니다.

테이블 구조

다음은 이번 아티클에서 사용할 테이블 구조입니다. 구분을 짓기 위해서 VisualAcademy 접두사를 붙였지만, Departments 이름으로 만들고 사용해도 됩니다.

코드: VisualAcademyDepartments.sql

C:\VisualAcademy.Models.Departments\src\
    VisualAcademy.Models.Departments\
        VisualAcademy.SqlServer\
            00_VisualAcademyDepartments.sql
--[0][0] 부서: Departments 
CREATE TABLE [dbo].[VisualAcademyDepartments]
(
    [Id]        BIGINT             IDENTITY (1, 1) NOT NULL Primary Key,    -- 부서 고유 아이디, 자동 증가
    [Active]    BIT                DEFAULT ((1)) NOT NULL,                  -- 활성 상태 표시, 기본값 1 (활성)
    [CreatedAt] DATETIMEOFFSET (7) NOT NULL,                                -- 레코드 생성 시간
    [CreatedBy] NVARCHAR (255)     NULL,                                    -- 레코드 생성자 이름
    [Name]      NVARCHAR (MAX)     NULL                                     -- 부서명
);

모델 클래스

다음 코드는 부서 정보를 나타내는 모델 클래스입니다. Department, DepartmentModel, DepartmentDto 등 원하는 형태의 모델명을 사용하세요.

코드: 01_DepartmentModel.cs

C:\VisualAcademy.Models.Departments\src\
    VisualAcademy.Models.Departments\
        VisualAcademy.Models.Departments\
            01_DepartmentModel.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace VisualAcademy.Models.Departments
{
    /// <summary>
    /// 테이블과 일대일로 매핑되는 모델 클래스: Department, DepartmentModel, ...
    /// </summary>
    [Table("VisualAcademyDepartments")]
    public class DepartmentModel
    {
        /// <summary>
        /// 부서 고유 아이디, 자동 증가
        /// </summary>
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public long Id { get; set; }

        /// <summary>
        /// 활성 상태 표시, 기본값 true (활성)
        /// </summary>
        public bool? Active { get; set; }

        /// <summary>
        /// 레코드 생성 시간
        /// </summary>
        public DateTimeOffset CreatedAt { get; set; }

        /// <summary>
        /// 레코드 생성자 이름
        /// </summary>
        public string? CreatedBy { get; set; }

        /// <summary>
        /// 부서명
        /// </summary>
        public string? Name { get; set; }
    }
}

공통 코드

ArticleSet<T, V> 구조체는 페이징 처리를 위해 설계되었습니다. 이 구조체는 특정 페이지에 해당하는 아티클 리스트(Items)와 전체 컬렉션의 아티클 개수(TotalCount)를 포함합니다. 이를 통해 애플리케이션은 페이징된 데이터를 사용자에게 효과적으로 제공할 수 있습니다. T는 모델 클래스 형식을, V는 아티클 수를 나타내는 데이터 형식(예: int, long)을 지정합니다.

Dul\07_Articles\ArticleSet.cs

using System.Collections.Generic;

namespace Dul.Articles
{
    /// <summary>
    /// 페이징된 아티클과 아티클 개수
    /// </summary>
    /// <typeparam name="T">모델 클래스</typeparam>
    /// <typeparam name="V">개수 형식(int, long)</typeparam>
    public struct ArticleSet<T, V>
    {
        /// <summary>
        /// 아티클 리스트: 현재 페이지에 해당하는 아티클 리스트 
        /// </summary>
        public IEnumerable<T> Items { get; set; }

        /// <summary>
        /// 아티클 수: 현재 앱의 지정된 컬렉션의 레코드 수
        /// </summary>
        public V TotalCount { get; set; }

        /// <summary>
        /// 구조체 인스턴스 초기화
        /// </summary>
        /// <param name="items">페이지 아티클 리스트</param>
        /// <param name="totalCount">총 아티클 수</param>
        public ArticleSet(IEnumerable<T> items, V totalCount)
        {
            Items = items;
            TotalCount = totalCount;
        }
    }
}

ICrudRepositoryBase<T, TIdentifier> 인터페이스는 CRUD(Create, Read, Update, Delete) 작업을 추상화합니다. 제네릭 타입 T는 모델 클래스 형식을, TIdentifier는 식별자(예: 엔터티의 ID) 형식을 지정합니다. 이 인터페이스는 데이터를 추가, 조회, 수정, 삭제하는 기본적인 메서드를 정의하며, 필터링을 위한 GetArticlesAsyncGetAllAsync 메서드도 포함합니다. 이러한 메서드들은 페이징 및 검색 기능을 지원하기 위해 설계되었습니다.

ICrudRepositoryBase<T, V, TIdentifier> 인터페이스는 유사한 기능을 제공하지만, V 타입 파라미터를 추가하여 다양한 식별자 형식을 지원합니다. 이를 통해 다양한 데이터 형식의 ID를 가진 엔터티에 대한 CRUD 작업을 효율적으로 처리할 수 있습니다.

Dul\07_Articles\ICrudRepositoryBase.cs

using System.Collections.Generic;
using System.Threading.Tasks;

namespace Dul.Articles
{
    /// <summary>
    /// CRUD 제네릭 인터페이스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    /// <typeparam name="TIdentifier">부모 식별자 형식</typeparam>
    public interface ICrudRepositoryBase<T, TIdentifier> where T : class
    {
        /// <summary>
        /// 입력
        /// </summary>
        Task<T> AddAsync(T model);

        /// <summary>
        /// 출력
        /// </summary>
        Task<List<T>> GetAllAsync();

        /// <summary>
        /// 상세
        /// </summary>
        Task<T> GetByIdAsync(TIdentifier id);

        /// <summary>
        /// 수정
        /// </summary>
        Task<bool> UpdateAsync(T model);

        /// <summary>
        /// 삭제
        /// </summary>
        Task<bool> DeleteAsync(TIdentifier id);

        /// <summary>
        /// 필터링
        /// </summary>
        Task<ArticleSet<T, int>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetArticlesAsync 메서드와 동일 구조 
        /// </summary>
        Task<ArticleSet<T, int>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);
    }

    /// <summary>
    /// CRUD 제네릭 인터페이스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    /// <typeparam name="V">Id 형식: int or long</typeparam>
    /// <typeparam name="TIdentifier">식별자 형식</typeparam>
    public interface ICrudRepositoryBase<T, V, TIdentifier> where T : class
    {
        /// <summary>
        /// 입력
        /// </summary>
        Task<T> AddAsync(T model);

        /// <summary>
        /// 출력
        /// </summary>
        Task<List<T>> GetAllAsync();

        /// <summary>
        /// 상세
        /// </summary>
        Task<T> GetByIdAsync(TIdentifier id);

        /// <summary>
        /// 수정
        /// </summary>
        Task<bool> UpdateAsync(T model);

        /// <summary>
        /// 삭제
        /// </summary>
        Task<bool> DeleteAsync(TIdentifier id);

        /// <summary>
        /// 필터링
        /// </summary>
        Task<ArticleSet<T, V>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetArticlesAsync 메서드와 동일 구조 
        /// </summary>
        Task<ArticleSet<T, V>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);
    }
}
  • AddAsync: 모델 인스턴스를 추가합니다.
  • GetAllAsync: 모든 모델 인스턴스를 리스트로 반환합니다.
  • GetByIdAsync: 주어진 식별자에 해당하는 모델 인스턴스를 반환합니다.
  • UpdateAsync: 모델 인스턴스를 수정합니다.
  • DeleteAsync: 주어진 식별자에 해당하는 모델 인스턴스를 삭제합니다.
  • GetArticlesAsyncGetAllAsync: 주어진 조건에 맞는 모델 인스턴스를 페이징 처리하여 반환합니다. 이 메서드들은 검색 필드, 검색 쿼리, 정렬 순서 및 부모 식별자를 파라미터로 받아 필터링된 결과를 제공합니다.

리포지토리 인터페이스

다음은 02_IDepartmentRepository.cs 파일의 내용입니다. 코드를 작성하기에 앞서 Dul 이름의 NuGet 패키지를 프로젝트에 참조 추가합니다.

C:\VisualAcademy.Models.Departments\src\
    VisualAcademy.Models.Departments\
        VisualAcademy.Models.Departments\
            02_IDepartmentRepository.cs

PagingResult.cs

다음 코드는 Dul 이름의 NuGet 패키지 안에 있는 PagingResult.cs 파일의 내용입니다.

using System.Collections.Generic;

namespace Dul.Domain.Common
{
    /// <summary>
    /// Paging 처리된 레코드셋과 전체 레코드 카운트를 반환하는 PagingResult 클래스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    public struct PagingResult<T>
    {
        /// <summary>
        /// 페이징 처리된 레코드셋
        /// </summary>
        public IEnumerable<T> Records { get; set; }

        /// <summary>
        /// 전체 레코드의 개수
        /// </summary>
        public int TotalRecords { get; set; }

        /// <summary>
        /// 페이징 처리된 레코드셋과 전체 레코드의 개수를 인자로 받아 초기화하는 생성자
        /// </summary>
        /// <param name="items">페이징 처리된 레코드셋</param>
        /// <param name="totalRecords">전체 레코드의 개수</param>
        public PagingResult(IEnumerable<T> items, int totalRecords)
        {
            Records = items;
            TotalRecords = totalRecords;
        }
    }
}

ICrudRepositoryBase.cs

다음 코드는 Dul 이름의 NuGet 패키지 안에 있는 ICrudRepositoryBase.cs 파일의 내용입니다.

using System.Collections.Generic;
using System.Threading.Tasks;

namespace Dul.Articles
{
    /// <summary>
    /// CRUD 제네릭 인터페이스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    /// <typeparam name="TIdentifier">부모 식별자 형식</typeparam>
    public interface ICrudRepositoryBase<T, TIdentifier> where T : class
    {
        /// <summary>
        /// 입력
        /// </summary>
        Task<T> AddAsync(T model);

        /// <summary>
        /// 출력
        /// </summary>
        Task<List<T>> GetAllAsync();

        /// <summary>
        /// 상세
        /// </summary>
        Task<T> GetByIdAsync(TIdentifier id);

        /// <summary>
        /// 수정
        /// </summary>
        Task<bool> UpdateAsync(T model);

        /// <summary>
        /// 삭제
        /// </summary>
        Task<bool> DeleteAsync(TIdentifier id);

        /// <summary>
        /// 필터링
        /// </summary>
        Task<ArticleSet<T, int>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetArticlesAsync 메서드와 동일 구조 
        /// </summary>
        Task<ArticleSet<T, int>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);
    }

    /// <summary>
    /// CRUD 제네릭 인터페이스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    /// <typeparam name="V">Id 형식: int or long</typeparam>
    /// <typeparam name="TIdentifier">식별자 형식</typeparam>
    public interface ICrudRepositoryBase<T, V, TIdentifier> where T : class
    {
        /// <summary>
        /// 입력
        /// </summary>
        Task<T> AddAsync(T model);

        /// <summary>
        /// 출력
        /// </summary>
        Task<List<T>> GetAllAsync();

        /// <summary>
        /// 상세
        /// </summary>
        Task<T> GetByIdAsync(TIdentifier id);

        /// <summary>
        /// 수정
        /// </summary>
        Task<bool> UpdateAsync(T model);

        /// <summary>
        /// 삭제
        /// </summary>
        Task<bool> DeleteAsync(TIdentifier id);

        /// <summary>
        /// 필터링
        /// </summary>
        Task<ArticleSet<T, V>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetArticlesAsync 메서드와 동일 구조 
        /// </summary>
        Task<ArticleSet<T, V>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);
    }
}

IDepartmentCrudRepository.cs

IDepartmentCrudRepository.cs 파일은 부서 모델(DepartmentModel)에 대한 CRUD(Create, Read, Update, Delete) 작업을 위한 인터페이스를 정의합니다.

이 인터페이스는 부서 모델에 대한 데이터를 추가, 수정, 삭제, 조회하는 메서드들을 명시하며, 페이징 처리를 통한 전체 조회나 특정 검색 쿼리를 이용한 조회 등의 기능을 제공합니다. 부모 ID에 의한 전체 조회, 검색, 삭제 등의 작업들을 지원하기 위한 메서드도 포함하고 있습니다.

추가로, 월별 생성 수를 조회하거나, 부모 ID에 따른 상태를 조회하는 메서드 등 부서에 특화된 기능을 제공합니다. 필요에 따라 추가적인 API는 이 인터페이스를 확장하여 구현할 수 있습니다.

using Dul.Articles;
using Dul.Domain.Common;

namespace VisualAcademy.Models.Departments
{
    public interface IDepartmentCrudRepository<T> : ICrudRepositoryBase<DepartmentModel, long>
    {
        /// <summary>
        /// 모델 수정
        /// </summary>
        Task<bool> EditAsync(T model);

        /// <summary>
        /// 기본 답변 추가
        /// </summary>
        Task<T> AddAsync(
            T model,
            int parentRef,
            int parentStep,
            int parentRefOrder);

        /// <summary>
        /// 고급 답변 추가
        /// </summary>
        Task<T> AddAsync(
            T model,
            int parentId);

        /// <summary>
        /// 페이징을 통한 전체 조회
        /// </summary>
        Task<PagingResult<T>> GetAllAsync(
            int pageIndex,
            int pageSize);

        /// <summary>
        /// 부모 Id에 의한 페이징 전체 조회
        /// </summary>
        Task<PagingResult<T>> GetAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            int parentId);

        /// <summary>
        /// 부모 Key에 의한 페이징 전체 조회
        /// </summary>
        Task<PagingResult<T>> GetAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string parentKey);

        /// <summary>
        /// 페이징을 통한 검색 조회
        /// </summary>
        Task<PagingResult<T>> SearchAllAsync(
            int pageIndex,
            int pageSize,
            string searchQuery);

        /// <summary>
        /// 부모 Id에 의한 페이징 검색 조회
        /// </summary>
        Task<PagingResult<T>> SearchAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            int parentId);

        /// <summary>
        /// 부모 Key에 의한 페이징 검색 조회
        /// </summary>
        Task<PagingResult<T>> SearchAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            string parentKey);
    }

    public interface IDepartmentRepository : IDepartmentCrudRepository<DepartmentModel>
    {
        /// <summary>
        /// 필터링 옵션에 따른 조회
        /// </summary>
        Task<ArticleSet<DepartmentModel, long>> GetByAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options);

        /// <summary>
        /// 부모 Id에 따른 상태 조회
        /// </summary>
        Task<Tuple<int, int>> GetStatus(int parentId);

        /// <summary>
        /// 부모 Id에 의한 전체 삭제
        /// </summary>
        Task<bool> DeleteAllByParentId(int parentId);

        /// <summary>
        /// 월별 생성 수 조회
        /// </summary>
        Task<SortedList<int, double>> GetMonthlyCreateCountAsync();

        // 강의 이외에 추가적인 API가 필요하다면 이곳에 기록(예를 들어, 시작일부터 종료일까지의 데이터 검색)
        // ...
    }
}

DbContext 클래스

다음 코드 DbContext 클래스입니다.

코드: 03_DepartmentAppDbContext.cs

using Microsoft.EntityFrameworkCore;
using System.Configuration;

namespace VisualAcademy.Models.Departments
{
    /// <summary>
    /// DepartmentApp에서 사용하는 데이터베이스 컨텍스트 클래스입니다.
    /// 이 클래스는 Entity Framework Core와 데이터베이스 간의 브리지 역할을 합니다.
    /// </summary>
    public class DepartmentAppDbContext : DbContext
    {
        /// <summary>
        /// 기본 생성자. 쿼리 트래킹 동작을 NoTracking으로 설정합니다.
        /// </summary>
        public DepartmentAppDbContext() : base()
        {
            ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        /// <summary>
        /// DbContextOptions을 인자로 받는 생성자입니다.
        /// 이 생성자는 Dependency Injection에 의해 호출되며, 
        /// 주로 Startup.cs에서 DBContext를 services에 등록할 때 사용됩니다.
        /// </summary>
        public DepartmentAppDbContext(DbContextOptions<DepartmentAppDbContext> options)
            : base(options)
        {
            ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        /// <summary>
        /// 데이터베이스 연결 설정을 담당하는 메서드입니다.
        /// 이 메서드는 DbContext가 처음 생성될 때 호출됩니다.
        /// </summary>
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                // .NET Framework 시절에 Web.config 또는 App.config 파일에서 데이터베이스 연결 문자열 값 가져오는 기본 코드
                string connectionString = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString;
                optionsBuilder.UseSqlServer(connectionString);
            }
        }

        /// <summary>
        /// 데이터베이스 모델을 설정하는 메서드입니다.
        /// 이 메서드는 DbContext가 처음 생성될 때 호출됩니다.
        /// </summary>
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Departments 테이블의 Created, PostDate 열은 자동으로 GetDate() 제약 조건을 부여하기 
            modelBuilder.Entity<DepartmentModel>().Property(m => m.CreatedAt).HasDefaultValueSql("GetDate()");
        }

        /// <summary>
        /// DepartmentApp 솔루션 관련 모든 테이블에 대한 참조 
        /// </summary>
        public DbSet<DepartmentModel> Departments { get; set; } = null!;
    }
}

리포지토리 클래스

다음은 DepartmentRepository 클래스의 전체 내용입니다. 강의에서 단계별로 만들면서 사용한 메서드들이라서 단일 테이블에 대한 여러 가지 경우의 수를 넣다보니 코드가 많이 깁니다. 필요한 메서드들만 선택해서 가져다 사용해도 됩니다.

코드: 04_DepartmentRepository.cs

using Dul.Articles;
using Dul.Domain.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace VisualAcademy.Models.Departments
{
    /// <summary>
    /// DepartmentRepository는 IDepartmentRepository와 IDisposable 인터페이스를 구현하는 클래스입니다.
    /// </summary>
    public class DepartmentRepository : IDepartmentRepository, IDisposable
    {
        private readonly DepartmentAppDbContext _context;
        private readonly ILogger _logger;

        /// <summary>
        /// DepartmentRepository 생성자는 DB Context와 Logger를 주입받습니다.
        /// </summary>
        public DepartmentRepository(DepartmentAppDbContext context, ILoggerFactory loggerFactory)
        {
            this._context = context;
            this._logger = loggerFactory.CreateLogger(nameof(DepartmentRepository));
        }

        #region [4][1] 입력: AddAsync
        //[4][1] 입력: AddAsync
        /// <summary>
        /// AddAsync 메서드는 신규 DepartmentModel 인스턴스를 데이터베이스에 추가합니다.
        /// </summary>
        public async Task<DepartmentModel> AddAsync(DepartmentModel model)
        {
            model.CreatedAt = DateTime.UtcNow;

            try
            {
                _context.Departments.Add(model);
                await _context.SaveChangesAsync();
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(AddAsync)}): {e.Message}");
            }

            return model;
        }
        #endregion

        #region [4][2] 출력: GetAllAsync
        //[4][2] 출력: GetAllAsync
        /// <summary>
        /// GetAllAsync 메서드는 모든 DepartmentModel 인스턴스를 데이터베이스에서 가져옵니다.
        /// </summary>
        public async Task<List<DepartmentModel>> GetAllAsync()
        {
            return await _context.Departments.OrderByDescending(m => m.Id).ToListAsync();
        }
        #endregion

        #region [4][3] 상세: GetByIdAsync
        //[4][3] 상세: GetByIdAsync
        /// <summary>
        /// GetByIdAsync 메서드는 주어진 Id에 해당하는 DepartmentModel 인스턴스를 데이터베이스에서 가져옵니다.
        /// </summary>
        public async Task<DepartmentModel> GetByIdAsync(long id)
        {
            var model = await _context.Departments.SingleOrDefaultAsync(m => m.Id == id);

            return model!;
        }
        #endregion

        #region [4][4] 수정: UpdateAsync
        //[4][4] 수정: UpdateAsync
        /// <summary>
        /// EditAsync 메서드는 주어진 DepartmentModel 인스턴스의 변경사항을 데이터베이스에 반영합니다.
        /// </summary>
        public async Task<bool> EditAsync(DepartmentModel model)
        {
            try
            {
                //_context.Departments.Attach(model);
                _context.Entry(model).State = EntityState.Modified;
                return (await _context.SaveChangesAsync() > 0 ? true : false);
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(EditAsync)}): {e.Message}");
            }

            return false;
        }

        /// <summary>
        /// UpdateAsync 메서드는 주어진 DepartmentModel 인스턴스의 변경사항을 데이터베이스에 반영합니다.
        /// </summary>
        public async Task<bool> UpdateAsync(DepartmentModel model)
        {
            try
            {
                var m = _context.Departments.Find(model.Id);

                m!.Name = model.Name;
                m.Active = model.Active;

                _context.Update(m);
                return (await _context.SaveChangesAsync() > 0 ? true : false);
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(UpdateAsync)}): {e.Message}");
            }

            return false;
        }
        #endregion

        #region [4][5] 삭제: DeleteAsync
        //[4][5] 삭제
        /// <summary>
        /// 지정된 ID를 가진 Department를 삭제합니다.
        /// </summary>
        /// <param name="id">삭제할 Department의 ID</param>
        /// <returns>작업 성공 여부</returns>
        public async Task<bool> DeleteAsync(long id)
        {
            try
            {
                var model = await _context.Departments.FindAsync(id);
                _context.Remove(model!);
                return await _context.SaveChangesAsync() > 0;
            }
            catch (Exception ಠ_ಠ) // Disapproval Look
            {
                _logger?.LogError($"ERROR({nameof(DeleteAsync)}): {ಠ_ಠ.Message}");
            }

            return false;
        }
        #endregion

        #region [4][6] 페이징: GetAllAsync
        //[4][6] 페이징: GetAllAsync()
        /// <summary>
        /// 모든 Department를 페이지 단위로 반환합니다.
        /// </summary>
        /// <param name="pageIndex">반환할 페이지 인덱스</param>
        /// <param name="pageSize">페이지 당 반환할 개수</param>
        /// <returns>페이지 결과</returns>
        public async Task<PagingResult<DepartmentModel>> GetAllAsync(int pageIndex, int pageSize)
        {
            var totalRecords = await _context.Departments.CountAsync();
            var models = await _context.Departments
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<DepartmentModel>(models, totalRecords);
        }
        #endregion

        #region [4][7] 부모: GetAllByParentIdAsync
        //[4][7] 부모
        /// <summary>
        /// 지정된 부모 ID를 가진 Department를 페이지 단위로 반환합니다.
        /// </summary>
        /// <param name="pageIndex">반환할 페이지 인덱스</param>
        /// <param name="pageSize">페이지 당 반환할 개수</param>
        /// <param name="parentId">부모 ID</param>
        /// <returns>페이지 결과</returns>
        public async Task<PagingResult<DepartmentModel>> GetAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            int parentId)
        {
            var totalRecords = await _context.Departments
                //.Where(m => m.ParentId == parentId)
                .CountAsync();
            var models = await _context.Departments
                //.Where(m => m.ParentId == parentId)
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<DepartmentModel>(models, totalRecords);
        }
        #endregion

        #region [4][8] 상태: GetStatus
        //[4][8] 상태
        /// <summary>
        /// 지정된 부모 ID를 가진 Department의 상태를 반환합니다.
        /// </summary>
        /// <param name="parentId">부모 ID</param>
        /// <returns>고정된 기록과 전체 기록의 수</returns>
        public async Task<Tuple<int, int>> GetStatus(int parentId)
        {
            var totalRecords = await _context.Departments
                //.Where(m => m.ParentId == parentId)
                .CountAsync();
            var pinnedRecords = await _context.Departments
                //.Where(m => m.ParentId == parentId && m.IsPinned == true)
                .CountAsync();

            return new Tuple<int, int>(pinnedRecords, totalRecords); // (2, 10)
        }
        #endregion

        #region [4][9] 부모 삭제: DeleteAllByParentId
        //[4][9] 부모 삭제
        /// <summary>
        /// 지정된 부모 ID를 가진 모든 Department를 삭제합니다.
        /// </summary>
        /// <param name="parentId">부모 ID</param>
        /// <returns>작업 성공 여부</returns>
        public async Task<bool> DeleteAllByParentId(int parentId)
        {
            try
            {
                var models = await _context.Departments
                    //.Where(m => m.ParentId == parentId)
                    .ToListAsync();

                foreach (var model in models)
                {
                    _context.Departments.Remove(model);
                }

                return (await _context.SaveChangesAsync() > 0 ? true : false);

            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(DeleteAllByParentId)}): {e.Message}");
            }

            return false;
        }
        #endregion

        #region [4][10] 검색: SearchAllAsync
        //[4][10] 검색
        /// <summary>
        /// 주어진 쿼리로 Department를 검색하고, 그 결과를 페이지 단위로 반환합니다.
        /// </summary>
        /// <param name="pageIndex">반환할 페이지 인덱스</param>
        /// <param name="pageSize">페이지 당 반환할 개수</param>
        /// <param name="searchQuery">검색 쿼리</param>
        /// <returns>페이지 결과</returns>
        public async Task<PagingResult<DepartmentModel>> SearchAllAsync(
            int pageIndex,
            int pageSize,
            string searchQuery)
        {
            var totalRecords = await _context.Departments
                .Where(m => m.Name!.Contains(searchQuery)
                //|| m.Title.Contains(searchQuery) 
                //|| m.Title.Contains(searchQuery)
                )
                .CountAsync();
            var models = await _context.Departments
                .Where(m => m.Name!.Contains(searchQuery)
                //|| m.Title.Contains(searchQuery) 
                //|| m.Title.Contains(searchQuery)
                )
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<DepartmentModel>(models, totalRecords);
        }
        #endregion

        #region [4][11] 부모 검색: SearchAllByParentIdAsync
        //[4][11] 부모 검색
        /// <summary>
        /// 주어진 쿼리로 지정된 부모 ID를 가진 Department를 검색하고, 그 결과를 페이지 단위로 반환합니다.
        /// </summary>
        /// <param name="pageIndex">반환할 페이지 인덱스</param>
        /// <param name="pageSize">페이지 당 반환할 개수</param>
        /// <param name="searchQuery">검색 쿼리</param>
        /// <param name="parentId">부모 ID</param>
        /// <returns>페이지 결과</returns>
        public async Task<PagingResult<DepartmentModel>> SearchAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            int parentId)
        {
            var totalRecords = await _context.Departments
                //.Where(m => m.ParentId == parentId)
                .Where(m => EF.Functions.Like(m.Name!, $"%{searchQuery}%")
                //|| m.Title.Contains(searchQuery) 
                )
                .CountAsync();
            var models = await _context.Departments
                //.Where(m => m.ParentId == parentId)
                .Where(m => m.Name!.Contains(searchQuery)
                //|| m.Title.Contains(searchQuery) 
                )
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<DepartmentModel>(models, totalRecords);
        }
        #endregion

        #region [4][12] 통계: GetMonthlyCreateCountAsync
        //[4][12] 통계
        /// <summary>
        /// 지난 12개월 동안 생성된 기록의 월별 통계를 반환합니다.
        /// </summary>
        public async Task<SortedList<int, double>> GetMonthlyCreateCountAsync()
        {
            SortedList<int, double> createCounts = new SortedList<int, double>();

            // 1월부터 12월까지 0.0으로 초기화
            for (int i = 1; i <= 12; i++)
            {
                createCounts[i] = 0.0;
            }

            for (int i = 0; i < 12; i++)
            {
                // 현재 달부터 12개월 전까지 반복
                var current = DateTime.Now.AddMonths(-i);
                var cnt = _context.Departments.AsEnumerable()
                    .Where(
                        m => m?.CreatedAt != null
                        //&&
                        //Convert.ToDateTime(m.Created).Month == current.Month
                        //&&
                        //Convert.ToDateTime(m.Created).Year == current.Year
                    )
                    .ToList().Count();
                createCounts[current.Month] = cnt;
            }

            return await Task.FromResult(createCounts);
        }
        #endregion

        #region [4][13] 부모 페이징: GetAllByParentKeyAsync
        //[4][13] 부모 페이징
        /// <summary>
        /// 부모 키를 기준으로 페이지화된 부서 목록을 반환합니다.
        /// </summary>
        public async Task<PagingResult<DepartmentModel>> GetAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string parentKey)
        {
            var totalRecords = await _context.Departments
                //.Where(m => m.ParentKey == parentKey)
                .CountAsync();
            var models = await _context.Departments
                //.Where(m => m.ParentKey == parentKey)
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<DepartmentModel>(models, totalRecords);
        }
        #endregion

        #region [4][14] 부모 검색: SearchAllByParentKeyAsync
        //[4][14] 부모 검색
        /// <summary>
        /// 부모 키를 기준으로 검색 쿼리가 적용된 페이지화된 부서 목록을 반환합니다.
        /// </summary>
        public async Task<PagingResult<DepartmentModel>> SearchAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            string parentKey)
        {
            var totalRecords = await _context.Departments
                //.Where(m => m.ParentKey == parentKey)
                .Where(m => EF.Functions.Like(m.Name!, $"%{searchQuery}%")
                //|| m.Title.Contains(searchQuery) 
                )
                .CountAsync();
            var models = await _context.Departments
                //.Where(m => m.ParentKey == parentKey)
                .Where(m => m.Name!.Contains(searchQuery)
                //|| m.Title.Contains(searchQuery)
                )
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<DepartmentModel>(models, totalRecords);
        }
        #endregion

        #region [4][15][1] 리스트(페이징, 검색, 정렬): GetAllAsync<TParentIdentifier>()
        //[4][15] 리스트(페이징, 검색, 정렬)
        /// <summary>
        /// 페이징, 검색, 정렬이 적용된 부서 목록을 반환합니다.
        /// </summary>
        public async Task<ArticleSet<DepartmentModel, int>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier)
        {
            var items = _context.Departments.AsQueryable();

            #region ParentBy: 특정 부모 키 값(int, string)에 해당하는 리스트인지 확인
            //// ParentBy 
            //if (parentIdentifier is int parentId && parentId != 0)
            //{
            //    items = items.Where(m => m.ParentId == parentId);
            //}
            //else if (parentIdentifier is string parentKey && !string.IsNullOrEmpty(parentKey))
            //{
            //    items = items.Where(m => m.ParentKey == parentKey);
            //}
            #endregion

            #region Search Mode: SearchField와 SearchQuery에 해당하는 데이터 검색
            // Search Mode
            if (!string.IsNullOrEmpty(searchQuery))
            {
                if (searchField == "Name")
                {
                    // Name
                    items = items
                        .Where(m => m.Name!.Contains(searchQuery)).AsNoTracking();
                }
                //else if (searchField == "Title")
                //{
                //    // Title
                //    items = items;
                //        //.Where(m => m.Title.Contains(searchQuery));
                //}
                else
                {
                    // All: 기타 더 검색이 필요한 컬럼이 있다면 추가 가능
                    items = items
                        .Where(m => m.Name!.Contains(searchQuery)
                        //|| m.Title.Contains(searchQuery)
                        ).AsNoTracking();
                }
            }
            #endregion

            // 총 레코드 수 계산
            var totalCount = await items.AsNoTracking().CountAsync();

            #region Sorting: 어떤 열에 대해 정렬(None, Asc, Desc)할 것인지 원하는 문자열로 지정
            // Sorting
            switch (sortOrder)
            {
                case "Name":
                    items = items
                        .OrderBy(m => m.Name).ThenByDescending(m => m.Id);
                    break;
                case "NameDesc":
                    items = items
                        .OrderByDescending(m => m.Name).ThenByDescending(m => m.Id);
                    break;
                default:
                    items = items
                        .OrderByDescending(m => m.Id).AsNoTracking(); // .ThenBy(m => m.RefOrder);
                    break;
            }
            #endregion

            // Paging
            items = items.Skip(pageIndex * pageSize).Take(pageSize).AsNoTracking();

            return new ArticleSet<DepartmentModel, int>(await items.AsNoTracking().ToListAsync(), totalCount);
        }
        #endregion

        #region  [4][15][2] 리스트(페이징, 검색, 정렬): GetArticlesAsync<TParentIdentifier>()
        public async Task<ArticleSet<DepartmentModel, int>> GetArticlesAsync<TParentIdentifier>(
            int pageIndex,
            int pageSize,
            string searchField,
            string searchQuery,
            string sortOrder,
            TParentIdentifier parentIdentifier)
        {
            return await GetAllAsync(pageIndex, pageSize, searchField, searchQuery, sortOrder, parentIdentifier);
        }
        #endregion

        #region [4][16] 답변: AddAsync(ReplyAsync)
        //[4][16] 답변: AddAsync(ReplyAsync)
        /// <summary>
        /// 답변을 추가합니다. 추가된 답변은 저장되며, 그 결과가 반환됩니다.
        /// </summary>
        public async Task<DepartmentModel> AddAsync(DepartmentModel model, int parentRef, int parentStep, int parentRefOrder)
        {
            model.CreatedAt = DateTime.UtcNow;

            try
            {
                _context.Departments.Add(model);
                await _context.SaveChangesAsync();
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(AddAsync)}): {e.Message}");
            }

            return model;
        }
        #endregion

        #region [4][17] 답변: AddAsync(ReplyAsync)
        //[4][17]  답변: AddAsync(ReplyAsync)
        /// <summary>
        /// 부서에 답변을 추가합니다. 추가된 답변은 저장되며, 그 결과가 반환됩니다.
        /// </summary>
        public async Task<DepartmentModel> AddAsync(DepartmentModel model, int parentId)
        {
            model.CreatedAt = DateTime.UtcNow;

            try
            {
                _context.Departments.Add(model);
                await _context.SaveChangesAsync();
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(AddAsync)}): {e.Message}");
            }

            return model;
        } 
        #endregion

        #region [4][18] 검색: GetByAsync()
        //[4][18] 검색: GetByAsync()
        /// <summary>
        /// 특정 필터 옵션에 따라 데이터를 검색하고 그 결과를 반환합니다.
        /// </summary>
        public async Task<ArticleSet<DepartmentModel, long>> GetByAsync<TParentIdentifier>(
            FilterOptions<TParentIdentifier> options)
        {
            var items = _context.Departments.AsQueryable();

            //#region ParentBy: 특정 부모 키 값(int, string)에 해당하는 리스트인지 확인
            //if (options.ChildMode)
            //{
            //    // ParentBy 
            //    if (options.ParentIdentifier is int parentId && parentId != 0)
            //    {
            //        //items = items.Where(m => m.ParentId == parentId);
            //    }
            //    else if (options.ParentIdentifier is string parentKey && !string.IsNullOrEmpty(parentKey))
            //    {
            //        //items = items.Where(m => m.ParentKey == parentKey);
            //    }
            //}
            //#endregion

            #region Search Mode: SearchField와 SearchQuery에 해당하는 데이터 검색
            if (options.SearchMode)
            {
                // Search Mode
                if (!string.IsNullOrEmpty(options.SearchQuery))
                {
                    var searchQuery = options.SearchQuery; // 검색어

                    if (options.SearchField == "Name")
                    {
                        // Name
                        items = items.Where(m => m.Name!.Contains(searchQuery));
                    }
                    //else if (options.SearchField == "Title")
                    //{
                    //    // Title
                    //    items = items.Where(m => m.Title.Contains(searchQuery));
                    //}
                    //else if (options.SearchField == "Content")
                    //{
                    //    // Title
                    //    items = items.Where(m => m.Content.Contains(searchQuery));
                    //}
                    else
                    {
                        // All: 기타 더 검색이 필요한 컬럼이 있다면 추가 가능
                        items = items.Where(m => m.Name!.Contains(searchQuery)
                        //|| m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery)
                        );
                    }
                }
            }
            #endregion

            // 총 레코드 수 계산
            var totalCount = await items.CountAsync();

            #region Sorting: 어떤 열에 대해 정렬(None, Asc, Desc)할 것인지 원하는 문자열로 지정
            if (options.SortMode)
            {
                // Sorting
                foreach (var sf in options.SortFields)
                {
                    switch ($"{sf.Key}{sf.Value}")
                    {
                        case "NameAsc":
                            //items = items.OrderBy(m => m.Name);
                            items = items.OrderBy(m => m.Name).ThenByDescending(m => m.Id);
                            break;
                        default:
                            //items = items.OrderByDescending(m => m.Id);
                            items = items.OrderByDescending(m => m.Id);
                            break;
                    }
                }
            }
            #endregion

            // Paging
            items = items.Skip(options.PageIndex * options.PageSize).Take(options.PageSize);

            return new ArticleSet<DepartmentModel, long>(await items.AsNoTracking().ToListAsync(), totalCount);
        }
        #endregion

        #region Dispose
        // https://learn.microsoft.com/ko-kr/dotnet/api/system.gc.suppressfinalize?view=net-5.0
        /// <summary>
        /// Dispose 메서드는 사용한 리소스를 청소하며, 이 개체를 가비지 컬렉터(GC)가 따로 자동으로 정리하지 않게 요청합니다.
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary>
        /// Dispose 메서드는 리소스를 정리합니다. 이 메서드는 Dispose 메서드에 의해 호출됩니다.
        /// </summary>
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                if (_context != null)
                {
                    _context.Dispose(); //_context = null;
                }
            }
        }
        #endregion
    }
}

DI 등록 관련 코드 모음 클래스 생성

다음은 Startup.cs 또는 Program.cs 파일에서 DI에 등록할 때 사용하는 코드를 하나의 클래스로 모아 놓은 클래스입니다.

코드: 05_DepartmentServicesRegistrationExtensions.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace VisualAcademy.Models.Departments
{
    // 이 정적 클래스는 IServiceCollection에 대한 확장 메서드를 제공하며, 부서 관련 서비스의 등록을 담당합니다.
    public static class DepartmentServicesRegistrationExtensions
    {
        // 이 메서드는 주어진 IServiceCollection에 DepartmentAppDbContext와 IDepartmentRepository를 추가합니다.
        // dbContext는 제공된 연결 문자열로 구성되며, 그 수명주기는 transient로 설정됩니다.
        // 이는 필요할 때마다 새 인스턴스가 생성되게 함을 의미합니다.
        // 또한 IDepartmentRepository의 구현체인 DepartmentRepository도 transient로 등록되어, 필요할 때마다 새 인스턴스가 생성됩니다.
        public static void AddDependencyInjectionContainerForDepartmentApp(this IServiceCollection services, string connectionString)
        {
            services.AddDbContext<DepartmentAppDbContext>(options => options.UseSqlServer(connectionString)
                //.EnableSensitiveDataLogging()
                , ServiceLifetime.Transient);
            services.AddTransient<IDepartmentRepository, DepartmentRepository>();
        }
    }
}

리포지토리 테스트 클래스

Memos 테스트 프로젝트를 참고하여, Departments 모듈에 대한 테스트 프로젝트를 생성하면 됩니다.

// 코드 생략...

Statup.cs 또는 Program.cs에 종속성 주입(DI)

다음 코드를 사용하여 Departments 관련 클래스들(모델, 인터페이스, 리포지토리 클래스)을 해당 프로젝트에서 사용할 수 있습니다.

Startup.cs 파일에서의 사용 모양

using VisualAcademy.Models.Departments;

// 부서 관리: 기본 CRUD 교과서 코드
services.AddDependencyInjectionContainerForDepartmentApp(Configuration.GetConnectionString("DefaultConnection"));

Program.cs 파일에서의 사용 모양

using VisualAcademy.Models.Departments;

// 부서 관리: 기본 CRUD 교과서 코드
builder.Services.AddDependencyInjectionContainerForDepartmentApp(Configuration.GetConnectionString("DefaultConnection"));

Departments 관련 MVC Controller with CRUD 뷰 페이지

다음은 Departments 주제로 ASP.NET Core MVC 스캐폴딩 기능을 사용하여 CRUD를 구현하는 코드의 내용입니다.

바로 Blazor Server 컴포넌트 제작으로 넘어가려면 다음 절로 이동하세요.

MVC 스캐폴딩으로 CRUD를 구현하는 내용에 대한 강좌는 다음 링크를 참고하세요.

TODO: MVC 스캐폴딩

Departments 관련 Web API Controller with CRUD

하나의 모듈에 대한 CRUD를 Swagger UI를 사용하는 ASP.NET Core Web API를 스캐폴딩 기능을 사용하여 구현할 수 있습니다. 이 곳의 내용은 Blazor Server에서 사용하기 보다는 Blazor Wasm, React, Vue, Angular, jQuery 등에서 호출되어 사용되는 부분입니다. Blazor Server 관점에서 개발하려면 다음 절로 이동하세요.

Web API 스캐폴딩으로 CRUD를 구현하는 내용에 대한 강좌는 다음 링크를 참고하세요.

TODO: Web API 스캐폴딩

Departments 관련 Excel 다운로드 API 생성

아직 제작하지 않은 Blazor Server의 Manage 컴포넌트에서는 리스트로 출력되는 내용을 Excel 파일로 다운로드하는 기능을 제공하고 있습니다. 이 부분에서 사용되는 API는 ASP.NET Core Web API로 구현이 되는데요. 해당 코드는 다음과 같습니다.

DepartmentDownloadController.cs

Hawaso\Apis\Departments\DepartmentDownloadController.cs

using Microsoft.AspNetCore.Authorization;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using System.Drawing;
using VisualAcademy.Models.Departments;

namespace Hawaso.Apis.Departments;

[Authorize(Roles = "Administrators")]
public class DepartmentDownloadController : Controller
{
    private readonly IDepartmentRepository _repository;

    public DepartmentDownloadController(IDepartmentRepository repository) => _repository = repository;

    /// <summary>
    /// 엑셀 파일 강제 다운로드 기능(/ExcelDown)
    /// </summary>
    public async Task<IActionResult> ExcelDown()
    {
        var results = await _repository.GetAllAsync(0, 100);

        var models = results.Records.ToList();

        if (models != null)
        {
            using (var package = new ExcelPackage())
            {
                var worksheet = package.Workbook.Worksheets.Add("Departments");

                var tableBody = worksheet.Cells["B2:B2"].LoadFromCollection(
                    from m in models select new { m.Id, m.Name, CreatedAt = m.CreatedAt.LocalDateTime.ToString(), m.Active, m.CreatedBy }
                    , true);

                var uploadCol = tableBody.Offset(1, 1, models.Count, 1);

                // 그라데이션 효과 부여 
                var rule = uploadCol.ConditionalFormatting.AddThreeColorScale();
                rule.LowValue.Color = Color.SkyBlue;
                rule.MiddleValue.Color = Color.White;
                rule.HighValue.Color = Color.Red;

                var header = worksheet.Cells["B2:F2"];
                worksheet.DefaultColWidth = 25;
                //worksheet.Cells[3, 2, models.Count + 2, 2].Style.Numberformat.Format = "yyyy MMM d DDD";
                tableBody.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
                tableBody.Style.Fill.PatternType = ExcelFillStyle.Solid;
                tableBody.Style.Fill.BackgroundColor.SetColor(Color.WhiteSmoke);
                tableBody.Style.Border.BorderAround(ExcelBorderStyle.Medium);
                header.Style.Font.Bold = true;
                header.Style.Font.Color.SetColor(Color.White);
                header.Style.Fill.BackgroundColor.SetColor(Color.DarkBlue);

                return File(package.GetAsByteArray(), "application/octet-stream", $"{DateTime.Now.ToString("yyyyMMddhhmmss")}_Departments.xlsx");
            }
        }
        return Redirect("/");
    }
}

Blazor Server 컴포넌트

Departments 관련하여 Blazor Server 컴포넌트를 구성할 폴더 구조는 다음과 같습니다. Departments 폴더에 Manage 이름으로 Razor Component를 생성하고, 관련된 서브 컴포넌트들은 Components 폴더에 둡니다.

Departments 보다 더 향상된 기능을 구현하는 Memos 모듈의 폴더 구조는 참고용으로 아래에 표시하였습니다. 내용은 비슷합니다. 제 강의의 Blazor Server 게시판 프로젝트 강의를 수강하셨다면, 각각의 컴포넌트 파일명을 만드는 연습을 여러 번 했기에 익숙한 폴더명과 파일명이 될 것입니다. 참고로 Manage 이름의 컴포넌트는 단일 페이지에서 CRUD를 구현하는 코드를 나타냅니다.

강의용 Hawaso 프로젝트의 Pages 폴더의 일부 내용

.NET 8.0 이상 사용하는 환경이라면, /Pages/ 폴더 대신에 /Components/ 폴더에 관련된 컴포넌트를 모아 놓으면 됩니다.

Pages
│  _Host.cshtml
│  
├─Departments
│  │  Manage.razor
│  │  Manage.razor.cs
│  │  
│  └─Components
│          DeleteDialog.razor
│            DeleteDialog.razor.cs
│          ModalForm.razor
│            ModalForm.razor.cs
│          SearchBox.razor
│            SearchBox.razor.cs
│          SortOrderArrow.razor
│      
├─Memos
│  │  Create.razor
│  │    Create.razor.cs
│  │  Delete.razor
│  │    Delete.razor.cs
│  │  Details.razor
│  │    Details.razor.cs
│  │  Edit.razor
│  │    Edit.razor.cs
│  │  Index.razor
│  │    Index.razor.cs
│  │  Manage.razor
│  │    Manage.razor.cs
│  │  
│  └─Components
│          DeleteDialog.razor
│            DeleteDialog.razor.cs
│          EditorForm.razor
│            EditorForm.razor.cs
│          ModalForm.razor
│            ModalForm.razor.cs
│          SearchBox.razor
│            SearchBox.razor.cs
│          SortOrderArrow.razor
│ ...

DeleteDialog.razor 컴포넌트

DeleteDialog 컴포넌트는 리스트(Manage) 페이지에서 특정 레코드를 삭제할 때 뜨는 팝업 다이얼로그입니다.

@namespace VisualAcademy.Pages.Departments.Components

@if (IsShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">DELETE</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="Hide">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <p>Are you sure you want to delete?</p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @onclick="OnClickCallback">Yes</button>
                    <button type="button" class="btn btn-secondary" data-dismiss="modal" @onclick="@Hide">Cancel</button>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}

DeleteDialog.razor.cs

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;

namespace VisualAcademy.Pages.Departments.Components;

public partial class DeleteDialog
{
    #region Parameters
    /// <summary>
    /// 부모에서 OnClickCallback 속성에 지정한 이벤트 처리기 실행
    /// </summary>
    [Parameter]
    public EventCallback<MouseEventArgs> OnClickCallback { get; set; }
    #endregion

    #region Properties
    /// <summary>
    /// 모달 다이얼로그를 표시할건지 여부 
    /// </summary>
    public bool IsShow { get; set; } = false;
    #endregion

    #region Public Methods
    /// <summary>
    /// 폼 보이기 
    /// </summary>
    public void Show() => IsShow = true;

    /// <summary>
    /// 폼 닫기
    /// </summary>
    public void Hide() => IsShow = false;
    #endregion
}

ModalForm.razor

ModalForm 컴포넌트는 리스트(Manage) 페이지에서 팝업을 통해서 특정 항목을 입력하거나, 이미 입력된 내용을 수정할 때 사용하는 모달 팝업 다이얼로그 컴포넌트입니다.

@namespace VisualAcademy.Pages.Departments.Components

@if (IsShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">@EditorFormTitle</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="Hide">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                        @if (ModelEdit.Id != 0)
                        {
                            <div class="form-group">
                                <label for="id">Id: </label> @ModelEdit.Id
                                <input type="hidden" @bind-value="@ModelEdit.Id" />
                            </div>
                        }
                        <div class="form-group">
                            <label for="txtName">Name</label>
                            <input type="text" @bind="@ModelEdit.Name"class="form-control" placeholder="Enter Name" />
                        </div>
                        <div class="form-group">
                            <button type="button" class="btn btn-primary" @onclick="CreateOrEditClick">Submit</button>
                            <button type="button" class="btn btn-secondary" @onclick="Hide">Cancel</button>
                        </div>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}

ModalForm.razor.cs

using Microsoft.AspNetCore.Components;
using System;
using VisualAcademy.Models.Departments;

namespace VisualAcademy.Pages.Departments.Components;

public partial class ModalForm : ComponentBase
{
    #region Properties
    /// <summary>
    /// (글쓰기/글수정)모달 다이얼로그를 표시할건지 여부 
    /// </summary>
    public bool IsShow { get; set; } = false;
    #endregion

    #region Public Methods
    /// <summary>
    /// 폼 보이기 
    /// </summary>
    public void Show() => IsShow = true; // 현재 인라인 모달 폼 보이기

    /// <summary>
    /// 폼 닫기
    /// </summary>
    public void Hide() => IsShow = false; // 현재 인라인 모달 폼 숨기기
    #endregion

    #region Parameters
    [Parameter]
    public string UserName { get; set; }

    /// <summary>
    /// 폼의 제목 영역
    /// </summary>
    [Parameter]
    public RenderFragment EditorFormTitle { get; set; }

    /// <summary>
    /// 넘어온 모델 개체 
    /// </summary>
    [Parameter]
    public DepartmentModel ModelSender { get; set; }

    public DepartmentModel ModelEdit { get; set; }

    #region Lifecycle Methods
    // 넘어온 Model 값을 수정 전용 ModelEdit에 담기 
    protected override void OnParametersSet()
    {
        ModelEdit = new DepartmentModel();
        ModelEdit.Id = ModelSender.Id;
        ModelEdit.Name = ModelSender.Name;
        // 더 많은 정보는 여기에서...
    }
    #endregion

    /// <summary>
    /// 부모 컴포넌트에게 생성(Create)이 완료되었다고 보고하는 목적으로 부모 컴포넌트에게 알림
    /// 학습 목적으로 Action 대리자 사용
    /// </summary>
    [Parameter]
    public Action CreateCallback { get; set; }

    /// <summary>
    /// 부모 컴포넌트에게 수정(Edit)이 완료되었다고 보고하는 목적으로 부모 컴포넌트에게 알림
    /// 학습 목적으로 EventCallback 구조체 사용
    /// </summary>
    [Parameter]
    public EventCallback<bool> EditCallback { get; set; }

    [Parameter]
    public string ParentKey { get; set; } = "";
    #endregion

    #region Injectors
    /// <summary>
    /// 리포지토리 클래스에 대한 참조 
    /// </summary>
    [Inject]
    public IDepartmentRepository RepositoryReference { get; set; }

    #endregion

    #region Event Handlers
    protected async void CreateOrEditClick()
    {
        // 변경 내용 저장
        ModelSender.Active = true;
        ModelSender.Name = ModelEdit.Name;
        ModelSender.CreatedBy = UserName ?? "Anonymous";

        if (ModelSender.Id == 0)
        {
            // Create
            ModelSender.CreatedAt = DateTime.UtcNow;
            await RepositoryReference.AddAsync(ModelSender);
            CreateCallback?.Invoke();
        }
        else
        {
            // Edit
            await RepositoryReference.UpdateAsync(ModelSender);
            await EditCallback.InvokeAsync(true);
        }
    }
    #endregion
}

SearchBox.razor

SearchBox 컴포넌트는 리스트 페이지에서 항목을 검색할 때 사용하는 검색 폼입니다. 이 검색 폼에는 디바운스 기능이라고 해서, 계속 입력되는 동안에는 검색을 진행하지 않고 입력이 완료된 후 300밀리초 후에 검색이 진행되는 기능이 들어 있습니다. 이 시간은 필요에 의해서 코드 비하인드에서 적절한 시간으로 수정해서 사용하면 됩니다.

@namespace VisualAcademy.Pages.Departments.Components

<div class="input-group mb-3">
    <input class="form-control form-control-sm form-control-borderless" type="search" placeholder="Search topics or keywords"  aria-describedby="btnSearch"
           @attributes="AdditionalAttributes" @bind="SearchQuery" @bind:event="oninput">
    <div class="input-group-append">
        <button class="btn btn-sm btn-success" type="submit" @onclick="Search" id="btnSearch">Search</button>
    </div>
</div>

SearchBox.razor.cs

using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Timers;

namespace VisualAcademy.Pages.Departments.Components;

public partial class SearchBox : ComponentBase, IDisposable
{
    #region Fields
    private string searchQuery;
    private System.Timers.Timer debounceTimer;
    #endregion

    #region Parameters
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object> AdditionalAttributes { get; set; }

    // 자식 컴포넌트에서 발생한 정보를 부모 컴포넌트에게 전달
    [Parameter]
    public EventCallback<string> SearchQueryChanged { get; set; }

    [Parameter]
    public int Debounce { get; set; } = 300;
    #endregion

    #region Properties
    public string SearchQuery
    {
        get => searchQuery;
        set
        {
            searchQuery = value;
            debounceTimer.Stop(); // 텍스트박스에 값을 입력하는 동안 타이머 중지
            debounceTimer.Start(); // 타이머 실행(300밀리초 후에 딱 한 번 실행)
        }
    }
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override void OnInitialized()
    {
        debounceTimer = new System.Timers.Timer();
        debounceTimer.Interval = Debounce;
        debounceTimer.AutoReset = false; // 딱 한번 실행 
        debounceTimer.Elapsed += SearchHandler;
    }
    #endregion

    #region Event Handlers
    protected void Search() => SearchQueryChanged.InvokeAsync(SearchQuery); // 부모의 메서드에 검색어 전달

    protected async void SearchHandler(object source, ElapsedEventArgs e) => await InvokeAsync(() => SearchQueryChanged.InvokeAsync(SearchQuery)); // 부모의 메서드에 검색어 전달
    #endregion

    #region Public Methods
    public void Dispose() => debounceTimer.Dispose();
    #endregion
}

SortOrderArrow.razor

SortOrderArrow 컴포넌트는 리스트 페이지에서 컬럼 정렬(Sorting) 기능을 구현할 때 현재 정렬 상태를 3가지로 표현하는 화살표 모양을 순수 텍스트로 표시해주기 위한 컴포넌트입니다.

언젠가는 QuickGrid 등으로 대체할 때까지는 정렬 기능이 이 컴포넌트를 사용할 예정입니다.

@namespace VisualAcademy.Pages.Departments.Components

<span style="color: silver; vertical-align:text-bottom; margin-left: 7px; font-weight: bold; float: right;">@arrow</span>

@code {
    [Parameter]
    public string SortColumn { get; set; }

    [Parameter]
    public string SortOrder { get; set; }

    private string arrow = " ";

    protected override void OnParametersSet()
    {
        if (SortOrder == "")
        {
            arrow = "↕";
        }
        else if (SortOrder.Contains(SortColumn) && SortOrder.Contains("Desc"))
        {
            arrow = "↓";
        }
        else if (SortOrder.Contains(SortColumn))
        {
            arrow = "↑";
        }

        StateHasChanged();
    }
}

Manage.razor

Manage 컴포넌트는 Blazor Server로 구현된 모듈의 핵심 페이지입니다. 이 컴포넌트에서 CRUD, 즉, 입력, 출력, 상세 보기, 수정, 삭제, 검색, 페이징, 정렬, 엑셀 다운로드 등의 전반적인 웹 애플리케이션의 기능을 모두 맛보기 형태로 살펴볼 수 있습니다. 사실, 현재 텍스트 아티클을 구성하는 목적도 현업에서 매번 비슷한 형태로 특정 기능을 구현할 때 이 문서의 내용 순서로 머릿속의 생각을 정리하면서 구현할 수 있는 가이드를 위해서 만들어 놓은 것입니다.

현재 Department는 하나의 항목(부서 이름)만 입력 받지만, 현업에서는 훨씨 더 많은 내용들을 서로 다른 모양(텍스트박스, 체크박스, 드롭다운리스트 등)으로 입력 받지만 그 내용은 비슷하다보면 됩니다.

개수가 많고 적음의 차이이지 CRUD 관점에서는 기본 뼈대 코드는 같습니다.

그래서 현재 아티클의 목적은 CRUD에 대한 교과서적 코드를 완성하는데 목적이 있습니다.

@page "/Departments"
@page "/Departments/Manage"

@namespace VisualAcademy.Pages.Departments

@attribute [Authorize(Roles = "Administrators")]

<h3 class="mt-1 mb-1">Departments <span class="oi oi-plus text-primary align-baseline" @onclick="ShowEditorForm" style="cursor: pointer;"></span> 
    <button onclick="location.href = '/DepartmentDownload/ExcelDown';" class="btn btn-sm btn-primary" style="float: right;">Excel Export</button></h3>

<div class="row">
    <div class="col-md-12">
        @if (models == null)
        {
            <div>
                <p>
                    <MatProgressBar Indeterminate="true"></MatProgressBar>
                </p>
            </div>
        }
        else
        {
            <div class="table-responsive">
                <table class="table table-bordered table-hover">
                    <colgroup>
                        <col style="width: 50px;" />
                        <col style="width: 300px;" />
                        <col style="width: 200px;" />
                        <col style="width: 200px;" />
                        <col style="width: auto;" />
                        <col style="width: 300px;" />
                    </colgroup>
                    <thead class="thead-light">
                        <tr>
                            <th class="text-center">ID</th>
                            <th class="text-center text-nowrap" @onclick="@(() => SortByName())" style="cursor: pointer;">Name <VisualAcademy.Pages.Departments.Components.SortOrderArrow SortColumn="Name" SortOrder="@sortOrder"></VisualAcademy.Pages.Departments.Components.SortOrderArrow></th>
                            <th class="text-center text-nowrap">Created</th>
                            <th class="text-center text-nowrap">Active</th>
                            <th class="text-center text-nowrap">&nbsp;</th>
                            <th class="text-center text-nowrap">Admin</th>
                        </tr>
                    </thead>
                    @if (models.Count == 0)
                    {
                        <tbody>
                            <tr>
                                <td colspan="6" class="text-center">
                                    <p>No Data.</p>
                                </td>
                            </tr>
                        </tbody>
                    }
                    <tbody>
                        @foreach (var m in models)
                        {
                            <tr>
                                <td class="text-center" style="font-size: xx-small;">
                                    @m.Id
                                </td>
                                <td style="cursor: pointer;" class="text-center">
                                    <span class="btn-link text-nowrap">@m.Name</span>
                                </td>
                                <td class="text-center text-nowrap small">
                                    @(Dul.DateTimeUtility.ShowTimeOrDate(m.CreatedAt))
                                </td>
                                <td class="text-center">
                                    @if (m.Active != null && m.Active.Value)
                                    {
                                        <input type="checkbox" checked />
                                    }
                                    else
                                    {
                                        <input type="checkbox" />
                                    }
                                </td>
                                <td>&nbsp;</td>
                                <td class="text-center">
                                    <input type="button" name="btnEdit" value="Edit" class="btn btn-sm btn-primary" @onclick="(() => EditBy(m))" />
                                    <input type="button" name="btnDelete" value="Del" class="btn btn-sm btn-danger" @onclick="(() => DeleteBy(m))" />
                                    <input type="button" name="btnToggle" value="Toggle" class="btn btn-sm btn-primary" @onclick="(() => ToggleBy(m))" />
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        }
    </div>
    <div class="col-md-12">
        <DulPager.DulPagerComponent Model="pager" PageIndexChanged="PageIndexChanged"></DulPager.DulPagerComponent>
    </div>
    <div class="col-md-12">
        <VisualAcademy.Pages.Departments.Components.SearchBox placeholder="Search Departments..." SearchQueryChanged="Search"></VisualAcademy.Pages.Departments.Components.SearchBox>
    </div>
</div>

@*입력 또는 수정 폼 모달 다이얼로그*@
<VisualAcademy.Pages.Departments.Components.ModalForm @ref="EditorFormReference" ModelSender="model" CreateCallback="CreateOrEdit" EditCallback="CreateOrEdit" UserName="@UserName">
    <EditorFormTitle>@EditorFormTitle</EditorFormTitle>
</VisualAcademy.Pages.Departments.Components.ModalForm>

@*삭제 폼 모달 다이얼로그*@
<VisualAcademy.Pages.Departments.Components.DeleteDialog @ref="DeleteDialogReference" OnClickCallback="DeleteClick">
</VisualAcademy.Pages.Departments.Components.DeleteDialog>

@if (IsInlineDialogShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Toggle</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="ToggleClose">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <p>Do you want to toggle post #@(model.Id)?</p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @onclick="ToggleClick">Toggle</button>
                    <button type="button" class="btn btn-secondary" @onclick="ToggleClose">Close</button>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}
using BlazorUtils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VisualAcademy.Models.Departments;

namespace VisualAcademy.Pages.Departments;

public partial class Manage : ComponentBase
{
    #region Parameters
    [Parameter]
    public int ParentId { get; set; } = 0;

    [Parameter]
    public string ParentKey { get; set; } = "";
    #endregion

    #region Injectors
    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; }

    [Inject]
    public IJSRuntime JSRuntimeInjector { get; set; }

    [Inject]
    public IDepartmentRepository RepositoryReference { get; set; }
    #endregion

    #region Properties
    /// <summary>
    /// 글쓰기 또는 수정하기 폼의 제목에 전달할 문자열(태그 포함 가능)
    /// </summary>
    public string EditorFormTitle { get; set; } = "CREATE";
    #endregion

    /// <summary>
    /// EditorForm에 대한 참조: 모달로 글쓰기 또는 수정하기
    /// </summary>
    //public Components.EditorForm EditorFormReference { get; set; }
    public Components.ModalForm EditorFormReference { get; set; }

    /// <summary>
    /// DeleteDialog에 대한 참조: 모달로 항목 삭제하기 
    /// </summary>
    public Components.DeleteDialog DeleteDialogReference { get; set; }

    /// <summary>
    /// 현재 페이지에서 리스트로 사용되는 모델 리스트 
    /// </summary>
    protected List<DepartmentModel> models = new List<DepartmentModel>();

    /// <summary>
    /// 현재 페이지에서 선택된 단일 데이터를 나타내는 모델 클래스 
    /// </summary>
    protected DepartmentModel model = new DepartmentModel();

    /// <summary>
    /// 페이저 설정
    /// </summary>
    protected DulPager.DulPagerBase pager = new DulPager.DulPagerBase()
    {
        PageNumber = 1,
        PageIndex = 0,
        PageSize = 10,
        PagerButtonCount = 5
    };

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync()
    {
        if (UserId == "" && UserName == "")
        {
            await GetUserIdAndUserName();
        }

        await DisplayData();
    }
    #endregion

    private async Task DisplayData()
    {
        // ParentKey와 ParentId를 사용하는 목적은 특정 부모의 Details 페이지에서 리스트로 표현하기 위함
        if (ParentKey != "")
        {
            var articleSet = await RepositoryReference.GetArticlesAsync<string>(pager.PageIndex, pager.PageSize, "", this.searchQuery, this.sortOrder, ParentKey);
            pager.RecordCount = articleSet.TotalCount;
            models = articleSet.Items.ToList();
        }
        else if (ParentId != 0)
        {
            var articleSet = await RepositoryReference.GetArticlesAsync<int>(pager.PageIndex, pager.PageSize, "", this.searchQuery, this.sortOrder, ParentId);
            pager.RecordCount = articleSet.TotalCount;
            models = articleSet.Items.ToList();
        }
        else
        {
            var articleSet = await RepositoryReference.GetArticlesAsync<int>(pager.PageIndex, pager.PageSize, searchField: "", this.searchQuery, this.sortOrder, parentIdentifier: 0);
            pager.RecordCount = articleSet.TotalCount;
            models = articleSet.Items.ToList();
        }

        StateHasChanged(); // Refresh
    }

    protected async void PageIndexChanged(int pageIndex)
    {
        pager.PageIndex = pageIndex;
        pager.PageNumber = pageIndex + 1;

        await DisplayData();

        StateHasChanged();
    }

    #region Event Handlers
    /// <summary>
    /// 글쓰기 모달 폼 띄우기 
    /// </summary>
    protected void ShowEditorForm()
    {
        EditorFormTitle = "CREATE";
        this.model = new DepartmentModel(); // 모델 초기화
        EditorFormReference.Show();
    }

    /// <summary>
    /// 관리자 전용: 모달 폼으로 선택 항목 수정
    /// </summary>
    protected void EditBy(DepartmentModel model)
    {
        EditorFormTitle = "EDIT";
        this.model = new DepartmentModel(); // 모델 초기화
        this.model = model;
        EditorFormReference.Show();
    }

    /// <summary>
    /// 관리자 전용: 모달 폼으로 선택 항목 삭제
    /// </summary>
    protected void DeleteBy(DepartmentModel model)
    {
        this.model = model;
        DeleteDialogReference.Show();
    }
    #endregion

    /// <summary>
    /// 모델 초기화 및 모달 폼 닫기
    /// </summary>
    protected async void CreateOrEdit()
    {
        EditorFormReference.Hide();
        this.model = null;
        this.model = new DepartmentModel();

        await DisplayData();
    }

    /// <summary>
    /// 삭제 모달 폼에서 현재 선택한 항목 삭제
    /// </summary>
    protected async void DeleteClick()
    {
        await RepositoryReference.DeleteAsync(this.model.Id);
        DeleteDialogReference.Hide();
        this.model = new DepartmentModel(); // 선택했던 모델 초기화
        await DisplayData(); // 다시 로드
    }

    #region Toggle with Inline Dialog
    /// <summary>
    /// 인라인 폼을 띄울건지 여부 
    /// </summary>
    public bool IsInlineDialogShow { get; set; } = false;

    protected void ToggleClose()
    {
        IsInlineDialogShow = false;
        this.model = new DepartmentModel();
    }

    /// <summary>
    /// 토글: Pinned
    /// </summary>
    protected async void ToggleClick()
    {
        model.Active = !model.Active;

        // 변경된 내용 업데이트
        await RepositoryReference.UpdateAsync(this.model);

        IsInlineDialogShow = false; // 표시 속성 초기화
        this.model = new DepartmentModel(); // 선택한 모델 초기화 

        await DisplayData(); // 다시 로드
    }

    /// <summary>
    /// ToggleBy(PinnedBy)
    /// </summary>
    protected void ToggleBy(DepartmentModel model)
    {
        this.model = model;
        IsInlineDialogShow = true;
    }
    #endregion

    #region Search
    private string searchQuery = "";

    protected async void Search(string query)
    {
        pager.PageIndex = 0;

        this.searchQuery = query;

        await DisplayData();
    }
    #endregion

    #region Excel
    protected void DownloadExcelWithWebApi()
    {
        FileUtil.SaveAsExcel(JSRuntimeInjector, "/DepartmentDownload/ExcelDown");

        NavigationManagerInjector.NavigateTo($"/Departments"); // 다운로드 후 현재 페이지 다시 로드
    }

    protected void DownloadExcel()
    {
        //using (var package = new ExcelPackage())
        //{
        //    var worksheet = package.Workbook.Worksheets.Add("Departments");

        //    var tableBody = worksheet.Cells["B2:B2"].LoadFromCollection(
        //        (from m in models select new { m.Created, m.Name, m.Title, m.DownCount, m.FileName })
        //        , true);

        //    var uploadCol = tableBody.Offset(1, 1, models.Count, 1);

        //    // 그라데이션 효과 부여
        //    var rule = uploadCol.ConditionalFormatting.AddThreeColorScale();
        //    rule.LowValue.Color = Color.SkyBlue;
        //    rule.MiddleValue.Color = Color.White;
        //    rule.HighValue.Color = Color.Red;

        //    var header = worksheet.Cells["B2:F2"];
        //    worksheet.DefaultColWidth = 25;
        //    worksheet.Cells[3, 2, models.Count + 2, 2].Style.Numberformat.Format = "yyyy MMM d DDD";
        //    tableBody.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
        //    tableBody.Style.Fill.PatternType = ExcelFillStyle.Solid;
        //    tableBody.Style.Fill.BackgroundColor.SetColor(Color.WhiteSmoke);
        //    tableBody.Style.Border.BorderAround(ExcelBorderStyle.Medium);
        //    header.Style.Font.Bold = true;
        //    header.Style.Font.Color.SetColor(Color.White);
        //    header.Style.Fill.BackgroundColor.SetColor(Color.DarkBlue);

        //    FileUtil.SaveAs(JSRuntimeInjector, $"{DateTime.Now.ToString("yyyyMMddhhmmss")}_Departments.xlsx", package.GetAsByteArray());
        //}
    }
    #endregion

    #region Sorting
    private string sortOrder = "";

    protected async void SortByName()
    {
        if (!sortOrder.Contains("Name"))
        {
            sortOrder = ""; // 다른 열을 정렬하고 있었다면, 다시 초기화
        }

        if (sortOrder == "")
        {
            sortOrder = "Name";
        }
        else if (sortOrder == "Name")
        {
            sortOrder = "NameDesc";
        }
        else
        {
            sortOrder = "";
        }

        await DisplayData();
    }
    #endregion

    #region Get UserId and UserName
    [Parameter]
    public string UserId { get; set; } = "";

    [Parameter]
    public string UserName { get; set; } = "";

    [Inject] public UserManager<VisualAcademy.Data.ApplicationUser> UserManagerRef { get; set; }

    [Inject] public AuthenticationStateProvider AuthenticationStateProviderRef { get; set; }

    private async Task GetUserIdAndUserName()
    {
        var authState = await AuthenticationStateProviderRef.GetAuthenticationStateAsync();
        var user = authState.User;

        if (user.Identity.IsAuthenticated)
        {
            var currentUser = await UserManagerRef.GetUserAsync(user);
            UserId = currentUser.Id;
            UserName = user.Identity.Name;
        }
        else
        {
            UserId = "";
            UserName = "Anonymous";
        }
    }
    #endregion
}

웹 브라우저 실행 및 단계별 테스트

Ctrl+F5를 눌러 프로젝트를 실행하고 웹브라우저에서 /departments/ 경로를 요청하면 Departments 폴더의 Manage 컴포넌트가 실행이 됩니다.

이 컴포넌트가 정상적으로 실행되면, 부서명을 입력, 출력, 수정, 삭제, 검색, 정렬, 등의 기능을 단계별로 테스트해 볼 수 있습니다.

VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com