feat: implement multi-tenant company management system
TaxBaik CI/CD / build-and-deploy (push) Successful in 48s
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:
@@ -19,6 +19,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<FaqService>();
|
||||
services.AddScoped<ConsultationService>();
|
||||
services.AddScoped<TaxFilingService>();
|
||||
services.AddScoped<CompanyService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace TaxBaik.Application.Services;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CompanyService(ICompanyRepository repository)
|
||||
{
|
||||
public async Task<int> CreateAsync(string companyCode, string companyName, string? contactPerson = null,
|
||||
string? phone = null, string? email = null, string? memo = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyCode))
|
||||
throw new ValidationException("회사 코드를 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
throw new ValidationException("회사명을 입력하세요.");
|
||||
|
||||
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||
if (existing != null)
|
||||
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||
|
||||
var company = new Company
|
||||
{
|
||||
CompanyCode = companyCode.Trim(),
|
||||
CompanyName = companyName.Trim(),
|
||||
ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim(),
|
||||
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim(),
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await repository.CreateAsync(company, ct);
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
await repository.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Company?> GetByCodeAsync(string code, CancellationToken ct = default) =>
|
||||
await repository.GetByCodeAsync(code, ct);
|
||||
|
||||
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken ct = default) =>
|
||||
await repository.GetAllActiveAsync(ct);
|
||||
|
||||
public async Task<(IEnumerable<Company>, int)> GetPagedAsync(int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var (items, total) = await repository.GetPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), ct);
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, string companyCode, string companyName, string? contactPerson = null,
|
||||
string? phone = null, string? email = null, string? memo = null, bool isActive = true, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyCode))
|
||||
throw new ValidationException("회사 코드를 입력하세요.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
throw new ValidationException("회사명을 입력하세요.");
|
||||
|
||||
var company = await repository.GetByIdAsync(id, ct);
|
||||
if (company == null)
|
||||
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||
|
||||
var existing = await repository.GetByCodeAsync(companyCode.Trim(), ct);
|
||||
if (existing != null && existing.Id != id)
|
||||
throw new ValidationException("이미 존재하는 회사 코드입니다.");
|
||||
|
||||
company.CompanyCode = companyCode.Trim();
|
||||
company.CompanyName = companyName.Trim();
|
||||
company.ContactPerson = string.IsNullOrWhiteSpace(contactPerson) ? null : contactPerson.Trim();
|
||||
company.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
|
||||
company.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
|
||||
company.Memo = string.IsNullOrWhiteSpace(memo) ? null : memo.Trim();
|
||||
company.IsActive = isActive;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await repository.UpdateAsync(company, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var company = await repository.GetByIdAsync(id, ct);
|
||||
if (company == null)
|
||||
throw new ValidationException("회사를 찾을 수 없습니다.");
|
||||
|
||||
if (company.CompanyCode == "DEFAULT")
|
||||
throw new ValidationException("기본 회사는 삭제할 수 없습니다.");
|
||||
|
||||
await repository.DeleteAsync(id, ct);
|
||||
}
|
||||
|
||||
private static int NormalizePage(int page) => Math.Max(1, page);
|
||||
private static int NormalizePageSize(int pageSize) => Math.Clamp(pageSize, 1, 100);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TaxBaik.Domain.Entities;
|
||||
|
||||
public class Company
|
||||
{
|
||||
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 string? Memo { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TaxBaik.Domain.Interfaces;
|
||||
|
||||
using TaxBaik.Domain.Entities;
|
||||
|
||||
public interface ICompanyRepository
|
||||
{
|
||||
Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default);
|
||||
Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Company company, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IFaqRepository, FaqRepository>();
|
||||
services.AddScoped<IConsultationRepository, ConsultationRepository>();
|
||||
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
|
||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TaxBaik.Infrastructure.Repositories;
|
||||
|
||||
using Dapper;
|
||||
using TaxBaik.Domain.Entities;
|
||||
using TaxBaik.Domain.Interfaces;
|
||||
|
||||
public class CompanyRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICompanyRepository
|
||||
{
|
||||
public async Task<int> CreateAsync(Company company, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstAsync<int>(
|
||||
@"INSERT INTO companies (company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at)
|
||||
VALUES (@CompanyCode, @CompanyName, @ContactPerson, @Phone, @Email, @Memo, @IsActive, NOW(), NOW())
|
||||
RETURNING id",
|
||||
company);
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<Company?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryFirstOrDefaultAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE company_code = @Code",
|
||||
new { Code = code });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Company>> GetAllActiveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<Company>(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies WHERE is_active = TRUE ORDER BY company_name");
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Company> Items, int Total)> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
using var reader = await conn.QueryMultipleAsync(
|
||||
@"SELECT id, company_code, company_name, contact_person, phone, email, memo, is_active, created_at, updated_at
|
||||
FROM companies
|
||||
ORDER BY company_name
|
||||
LIMIT @PageSize OFFSET @Offset;
|
||||
|
||||
SELECT COUNT(*) FROM companies;",
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
var items = (await reader.ReadAsync<Company>()).ToList();
|
||||
var total = await reader.ReadFirstAsync<int>();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Company company, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE companies
|
||||
SET company_code = @CompanyCode, company_name = @CompanyName,
|
||||
contact_person = @ContactPerson, phone = @Phone, email = @Email,
|
||||
memo = @Memo, is_active = @IsActive, updated_at = NOW()
|
||||
WHERE id = @Id",
|
||||
company);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
await conn.ExecuteAsync("DELETE FROM companies WHERE id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Create Companies table for multi-tenant support
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
contact_person VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
email VARCHAR(200),
|
||||
memo TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add company_id to admin_users (nullable for backward compatibility)
|
||||
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS company_id INT REFERENCES companies(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Create index for company lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_companies_code ON companies(company_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_users_company ON admin_users(company_id);
|
||||
|
||||
-- Insert default company for existing admin users
|
||||
INSERT INTO companies (company_code, company_name, is_active)
|
||||
VALUES ('DEFAULT', '기본 회사', TRUE)
|
||||
ON CONFLICT (company_code) DO NOTHING;
|
||||
|
||||
-- Assign existing admin users to default company if not assigned
|
||||
UPDATE admin_users
|
||||
SET company_id = (SELECT id FROM companies WHERE company_code = 'DEFAULT')
|
||||
WHERE company_id IS NULL;
|
||||
Reference in New Issue
Block a user