feat: implement multi-tenant company management system
TaxBaik CI/CD / build-and-deploy (push) Successful in 48s

Architecture:
- Create companies table with company_code as unique identifier
- Add company_id foreign key to admin_users for multi-tenant support
- Implement backward compatibility with DEFAULT company for existing users

Core Components:
- Company entity with full CRUD operations
- ICompanyRepository interface following Repository pattern
- CompanyRepository with Dapper implementation
- CompanyService with business logic and validation
- CompanyController with REST API endpoints

Admin UI:
- CompanyForm reusable component (Create/Edit pattern)
- CompanyList.razor with pagination and company overview
- CompanyCreate.razor for registering new companies
- CompanyEdit.razor for managing existing companies with delete
- All pages follow admin-page-hero pattern for consistency

SOLID Principles:
- Single Responsibility: Each component has one reason to change
- Open/Closed: Extensible without modifying existing code
- Interface Segregation: Clean repository and service contracts
- Dependency Inversion: All layers depend on abstractions

Database Migration (V014):
- Creates companies table with active/inactive status
- Assigns existing admin users to DEFAULT company
- Provides foundation for role-based access control

Future Enhancement:
- Admin users can belong to specific companies
- Data filtering based on company_id (multi-tenant isolation)
- Company-based permission model
This commit is contained in:
2026-06-28 16:52:22 +09:00
parent c65742a0c7
commit 7dd51a1169
12 changed files with 756 additions and 0 deletions
@@ -0,0 +1,88 @@
@using TaxBaik.Application.Services
<MudForm @ref="form">
<MudTextField @bind-Value="model.CompanyCode" Label="회사 코드"
Variant="Variant.Outlined" Class="mb-4" Required="true"
HelperText="영문/숫자, 최대 50자" />
<MudTextField @bind-Value="model.CompanyName" Label="회사명"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.ContactPerson" Label="담당자명"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.Phone" Label="전화번호"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.Email" Label="이메일"
Variant="Variant.Outlined" Class="mb-4" InputType="InputType.Email" />
<MudTextField @bind-Value="model.Memo" Label="메모"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsActive" Label="활성" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">
@ButtonText
</MudButton>
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
</div>
</MudForm>
@code {
[Parameter, EditorRequired]
public string ButtonText { get; set; } = "저장";
[Parameter]
public EventCallback<CompanyFormModel> OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
[Parameter]
public CompanyFormModel? InitialData { get; set; }
private MudForm? form;
private CompanyFormModel model = new();
protected override void OnInitialized()
{
if (InitialData != null)
{
model = new CompanyFormModel
{
CompanyCode = InitialData.CompanyCode,
CompanyName = InitialData.CompanyName,
ContactPerson = InitialData.ContactPerson,
Phone = InitialData.Phone,
Email = InitialData.Email,
Memo = InitialData.Memo,
IsActive = InitialData.IsActive
};
}
}
private async Task HandleSubmit()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync(model);
}
public class CompanyFormModel
{
public string CompanyCode { get; set; } = "";
public string CompanyName { get; set; } = "";
public string? ContactPerson { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Memo { get; set; }
public bool IsActive { get; set; } = true;
}
}
@@ -0,0 +1,51 @@
@page "/admin/companies/create"
@attribute [Authorize]
@using TaxBaik.Web.Components.Admin.Forms
@inject IApiClient ApiClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
<PageTitle>고객사 등록</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">새 고객사 등록</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">새로운 고객사를 추가합니다.</MudText>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<CompanyForm ButtonText="등록" OnSubmit="HandleCreate" OnCancel="GoBack" />
</MudPaper>
@code {
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/companies");
}
private async Task HandleCreate(CompanyForm.CompanyFormModel model)
{
try
{
await ApiClient.PostAsync<object>("company", new
{
companyCode = model.CompanyCode,
companyName = model.CompanyName,
contactPerson = model.ContactPerson,
phone = model.Phone,
email = model.Email,
memo = model.Memo
});
Snackbar.Add("고객사가 등록되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/companies");
}
catch (Exception ex)
{
Snackbar.Add($"등록 실패: {ex.Message}", Severity.Error);
}
}
}
@@ -0,0 +1,128 @@
@page "/admin/companies/{id:int}/edit"
@attribute [Authorize]
@using TaxBaik.Web.Components.Admin.Forms
@inject IApiClient ApiClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>고객사 수정</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 수정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객사 정보를 수정합니다.</MudText>
</div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Close" @onclick="GoBack">취소</MudButton>
</section>
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mt-4" />
}
else if (formModel == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">고객사를 찾을 수 없습니다.</MudAlert>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<CompanyForm ButtonText="수정" InitialData="formModel" OnSubmit="HandleUpdate" OnCancel="GoBack" />
<MudDivider Class="my-4" />
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeleteCompany" Class="mt-2">
고객사 삭제
</MudButton>
</MudPaper>
}
@code {
[Parameter]
public int Id { get; set; }
private CompanyForm.CompanyFormModel? formModel;
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
try
{
var company = await ApiClient.GetAsync<dynamic>($"company/{Id}");
IDictionary<string, object>? dict = company as IDictionary<string, object>;
if (dict != null)
{
formModel = new CompanyForm.CompanyFormModel
{
CompanyCode = (string)dict["companyCode"],
CompanyName = (string)dict["companyName"],
ContactPerson = (string?)dict["contactPerson"],
Phone = (string?)dict["phone"],
Email = (string?)dict["email"],
Memo = (string?)dict["memo"],
IsActive = (bool)(dynamic)dict["isActive"]
};
}
}
catch (Exception ex)
{
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
}
}
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/companies");
}
private async Task HandleUpdate(CompanyForm.CompanyFormModel model)
{
try
{
await ApiClient.PutAsync<object>($"company/{Id}", new
{
companyCode = model.CompanyCode,
companyName = model.CompanyName,
contactPerson = model.ContactPerson,
phone = model.Phone,
email = model.Email,
memo = model.Memo,
isActive = model.IsActive
});
Snackbar.Add("고객사가 수정되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/companies");
}
catch (Exception ex)
{
Snackbar.Add($"수정 실패: {ex.Message}", Severity.Error);
}
}
private async Task DeleteCompany()
{
var result = await DialogService.ShowMessageBox(
"고객사 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return;
try
{
await ApiClient.DeleteAsync($"company/{Id}");
Snackbar.Add("고객사가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/companies");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
}
}
}
@@ -0,0 +1,134 @@
@page "/admin/companies"
@attribute [Authorize]
@inject IApiClient ApiClient
@inject ISnackbar Snackbar
<PageTitle>고객사 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Settings</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">고객사 관리</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">등록된 고객사를 관리하고 새로운 고객사를 추가합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
Href="/taxbaik/admin/companies/create">새 고객사 등록</MudButton>
</section>
<MudPaper Class="admin-surface mb-4 mt-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1">@($"전체 고객사 {totalCompanies}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<MudDataGrid Items="@companies" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.CompanyCode" Title="회사코드" />
<PropertyColumn Property="x => x.CompanyName" Title="회사명" />
<PropertyColumn Property="x => x.ContactPerson" Title="담당자" />
<PropertyColumn Property="x => x.Phone" Title="전화" />
<PropertyColumn Property="x => x.Email" Title="이메일" />
<PropertyColumn Property="x => x.IsActive" Title="활성">
<CellTemplate Context="cell">
<MudCheckBox T="bool" Value="@cell.Item.IsActive" Disabled="true" />
</CellTemplate>
</PropertyColumn>
<PropertyColumn Property="x => x.CreatedAt" Title="등록일" Format="yyyy-MM-dd" />
<TemplateColumn>
<CellTemplate Context="cell">
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary"
Href="@($"/taxbaik/admin/companies/{cell.Item.Id}/edit")">수정</MudButton>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
<MudStack Row="true" Justify="Justify.Center" Class="mt-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage <= 1 || isLoading)" @onclick="PreviousPage">이전</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(currentPage >= totalPages || isLoading)" @onclick="NextPage">다음</MudButton>
</MudStack>
@code {
private List<CompanyDto> companies = [];
private bool isLoading = true;
private int currentPage = 1;
private int totalPages = 1;
private int totalCompanies = 0;
private const int PageSize = 20;
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
try
{
isLoading = true;
var response = await ApiClient.GetAsync<dynamic>($"company?page={currentPage}&pageSize={PageSize}");
IDictionary<string, object>? dict = response as IDictionary<string, object>;
if (dict != null)
{
totalCompanies = (int)(dynamic)dict["total"];
totalPages = (totalCompanies + PageSize - 1) / PageSize;
if (dict["data"] is System.Collections.IEnumerable dataList)
{
companies = new List<CompanyDto>();
foreach (var item in dataList)
{
if (item is IDictionary<string, object> companyDict)
{
companies.Add(new CompanyDto
{
Id = (int)(dynamic)companyDict["id"],
CompanyCode = (string)companyDict["companyCode"],
CompanyName = (string)companyDict["companyName"],
ContactPerson = (string?)companyDict["contactPerson"],
Phone = (string?)companyDict["phone"],
Email = (string?)companyDict["email"],
IsActive = (bool)(dynamic)companyDict["isActive"],
CreatedAt = DateTime.Parse(companyDict["createdAt"].ToString()!)
});
}
}
}
}
}
catch (Exception ex)
{
Snackbar.Add($"고객사 로드 실패: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
}
}
private async Task NextPage()
{
currentPage++;
await LoadData();
}
private async Task PreviousPage()
{
currentPage = Math.Max(1, currentPage - 1);
await LoadData();
}
private class CompanyDto
{
public int Id { get; set; }
public string CompanyCode { get; set; } = "";
public string CompanyName { get; set; } = "";
public string? ContactPerson { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
}
@@ -0,0 +1,117 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CompanyController(CompanyService companyService) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var company = await companyService.GetByIdAsync(id);
if (company == null)
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(company);
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpGet("code/{code}")]
public async Task<IActionResult> GetByCode(string code)
{
try
{
var company = await companyService.GetByCodeAsync(code);
if (company == null)
return NotFound(new ProblemDetails { Title = "회사를 찾을 수 없습니다.", Status = StatusCodes.Status404NotFound });
return Ok(company);
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpGet]
public async Task<IActionResult> GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
try
{
var (companies, total) = await companyService.GetPagedAsync(page, pageSize);
return Ok(new { data = companies, total, page, pageSize });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 목록 조회 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateCompanyRequest request)
{
try
{
var id = await companyService.CreateAsync(
request.CompanyCode, request.CompanyName, request.ContactPerson,
request.Phone, request.Email, request.Memo);
return CreatedAtAction(nameof(GetById), new { id }, new { message = "회사가 등록되었습니다.", id });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 등록 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateCompanyRequest request)
{
try
{
await companyService.UpdateAsync(id, request.CompanyCode, request.CompanyName,
request.ContactPerson, request.Phone, request.Email, request.Memo, request.IsActive);
return Ok(new { message = "회사가 수정되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 수정 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await companyService.DeleteAsync(id);
return Ok(new { message = "회사가 삭제되었습니다." });
}
catch (ValidationException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message, Status = StatusCodes.Status400BadRequest });
}
catch (Exception ex)
{
return StatusCode(500, new ProblemDetails { Title = "회사 삭제 실패", Detail = ex.Message, Status = StatusCodes.Status500InternalServerError });
}
}
public record CreateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo);
public record UpdateCompanyRequest(string CompanyCode, string CompanyName, string? ContactPerson, string? Phone, string? Email, string? Memo, bool IsActive);
}