diff --git a/TaxBaik.Application/DTOs/ClientDto.cs b/TaxBaik.Application/DTOs/ClientDto.cs new file mode 100644 index 0000000..d99b04f --- /dev/null +++ b/TaxBaik.Application/DTOs/ClientDto.cs @@ -0,0 +1,30 @@ +namespace TaxBaik.Application.DTOs; + +public class ClientDto +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string? CompanyName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? ServiceType { get; set; } + public string? TaxType { get; set; } + public string Status { get; set; } = "active"; + public string? Source { get; set; } + public string? Memo { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class CreateClientDto +{ + public string Name { get; set; } = null!; + public string? CompanyName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? ServiceType { get; set; } + public string? TaxType { get; set; } + public string Status { get; set; } = "active"; + public string? Source { get; set; } + public string? Memo { get; set; } +} diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index d69a663..79e1529 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -15,6 +15,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddScoped(); return services; } } diff --git a/TaxBaik.Application/Services/ClientService.cs b/TaxBaik.Application/Services/ClientService.cs new file mode 100644 index 0000000..eed66e7 --- /dev/null +++ b/TaxBaik.Application/Services/ClientService.cs @@ -0,0 +1,69 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Application.DTOs; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class ClientService(IClientRepository repository) +{ + public static readonly string[] ServiceTypes = + ["기장", "부동산", "증여·상속", "종합소득세", "법인세", "부가가치세", "기타"]; + + public static readonly string[] TaxTypes = + ["개인사업자", "법인사업자", "면세사업자", "근로소득자", "기타"]; + + public static readonly string[] Sources = + ["홈페이지 문의", "소개", "직접 방문", "카카오 채널", "블로그", "기타"]; + + public async Task<(IEnumerable Items, int Total)> GetPagedAsync( + int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) => + await repository.GetPagedAsync(Math.Max(1, page), Math.Clamp(pageSize, 1, 100), status, search, ct); + + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await repository.GetByIdAsync(id, ct); + + public async Task CreateAsync(CreateClientDto dto, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + throw new ValidationException("고객명을 입력하세요."); + + var client = new Client + { + Name = dto.Name.Trim(), + CompanyName = dto.CompanyName?.Trim(), + Phone = dto.Phone?.Trim(), + Email = dto.Email?.Trim(), + ServiceType = dto.ServiceType, + TaxType = dto.TaxType, + Status = dto.Status, + Source = dto.Source, + Memo = dto.Memo?.Trim() + }; + + return await repository.CreateAsync(client, ct); + } + + public async Task UpdateAsync(int id, CreateClientDto dto, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + throw new ValidationException("고객명을 입력하세요."); + + var client = await repository.GetByIdAsync(id, ct) + ?? throw new KeyNotFoundException($"고객 ID {id}를 찾을 수 없습니다."); + + client.Name = dto.Name.Trim(); + client.CompanyName = dto.CompanyName?.Trim(); + client.Phone = dto.Phone?.Trim(); + client.Email = dto.Email?.Trim(); + client.ServiceType = dto.ServiceType; + client.TaxType = dto.TaxType; + client.Status = dto.Status; + client.Source = dto.Source; + client.Memo = dto.Memo?.Trim(); + + await repository.UpdateAsync(client, ct); + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) => + await repository.DeleteAsync(id, ct); +} diff --git a/TaxBaik.Domain/Entities/Client.cs b/TaxBaik.Domain/Entities/Client.cs new file mode 100644 index 0000000..85c86d6 --- /dev/null +++ b/TaxBaik.Domain/Entities/Client.cs @@ -0,0 +1,17 @@ +namespace TaxBaik.Domain.Entities; + +public class Client +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string? CompanyName { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? ServiceType { get; set; } + public string? TaxType { get; set; } + public string Status { get; set; } = "active"; + public string? Source { get; set; } + public string? Memo { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/TaxBaik.Domain/Interfaces/IClientRepository.cs b/TaxBaik.Domain/Interfaces/IClientRepository.cs new file mode 100644 index 0000000..4d756de --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IClientRepository.cs @@ -0,0 +1,14 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IClientRepository +{ + Task<(IEnumerable Items, int Total)> GetPagedAsync( + int page, int pageSize, string? status = null, string? search = null, + CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task CreateAsync(Client client, CancellationToken ct = default); + Task UpdateAsync(Client client, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index 72b266c..c02d85b 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -16,6 +16,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/TaxBaik.Infrastructure/Repositories/ClientRepository.cs b/TaxBaik.Infrastructure/Repositories/ClientRepository.cs new file mode 100644 index 0000000..05e4330 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/ClientRepository.cs @@ -0,0 +1,70 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IClientRepository +{ + private const string SelectColumns = + "id, name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at"; + + public async Task<(IEnumerable Items, int Total)> GetPagedAsync( + int page, int pageSize, string? status = null, string? search = null, CancellationToken ct = default) + { + using var conn = Conn(); + var offset = (page - 1) * pageSize; + + using var reader = await conn.QueryMultipleAsync( + $@"SELECT {SelectColumns} FROM clients + WHERE (@Status::text IS NULL OR status = @Status) + AND (@Search::text IS NULL OR name ILIKE @SearchLike OR phone ILIKE @SearchLike OR company_name ILIKE @SearchLike) + ORDER BY created_at DESC + LIMIT @PageSize OFFSET @Offset; + + SELECT COUNT(*) FROM clients + WHERE (@Status::text IS NULL OR status = @Status) + AND (@Search::text IS NULL OR name ILIKE @SearchLike OR phone ILIKE @SearchLike OR company_name ILIKE @SearchLike);", + new { Status = status, Search = search, SearchLike = string.IsNullOrEmpty(search) ? null : $"%{search}%", PageSize = pageSize, Offset = offset }); + + var items = (await reader.ReadAsync()).ToList(); + var total = await reader.ReadFirstAsync(); + return (items, total); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + $"SELECT {SelectColumns} FROM clients WHERE id = @Id", + new { Id = id }); + } + + public async Task CreateAsync(Client client, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO clients (name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at) + VALUES (@Name, @CompanyName, @Phone, @Email, @ServiceType, @TaxType, @Status, @Source, @Memo, NOW(), NOW()) + RETURNING id", + client); + } + + public async Task UpdateAsync(Client client, CancellationToken ct = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE clients + SET name = @Name, company_name = @CompanyName, phone = @Phone, email = @Email, + service_type = @ServiceType, tax_type = @TaxType, status = @Status, + source = @Source, memo = @Memo, updated_at = NOW() + WHERE id = @Id", + client); + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) + { + using var conn = Conn(); + await conn.ExecuteAsync("DELETE FROM clients WHERE id = @Id", new { Id = id }); + } +} diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index 7320a4b..d1ca85d 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -42,8 +42,13 @@ 대시보드 - 공지사항 - 블로그 관리 + + 고객 카드 + + + 공지사항 + 블로그 관리 + 문의 관리 설정 diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor new file mode 100644 index 0000000..2db9dbc --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientEdit.razor @@ -0,0 +1,179 @@ +@page "/admin/clients/create" +@page "/admin/clients/{Id:int}/edit" +@attribute [Authorize] +@using TaxBaik.Application.DTOs +@using TaxBaik.Application.Services +@using TaxBaik.Domain.Entities +@inject ClientService ClientService +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +@(Id.HasValue ? "고객 수정" : "고객 등록") + +
+
+ CRM + @(Id.HasValue ? "고객 수정" : "고객 등록") +
+ 목록으로 +
+ + + @if (isLoading) + { + + } + else + { + + + @* 기본 정보 *@ + + 기본 정보 + + + + + + + + + + + + + + + + @* 세무 정보 *@ + + 세무 정보 + + + + + @foreach (var t in ClientService.ServiceTypes) + { + @t + } + + + + + @foreach (var t in ClientService.TaxTypes) + { + @t + } + + + + @* 관리 정보 *@ + + 관리 정보 + + + + + 활성 + 비활성 + + + + + @foreach (var s in ClientService.Sources) + { + @s + } + + + + + + + @* 저장 버튼 *@ + + + @(isSaving ? "저장 중..." : "저장") + + + 취소 + + + + + } + + +@code { + [Parameter] public int? Id { get; set; } + + private MudForm form = null!; + private CreateClientDto dto = new() { Status = "active" }; + private bool isValid; + private bool isLoading = true; + private bool isSaving; + + protected override async Task OnInitializedAsync() + { + if (Id.HasValue) + { + var client = await ClientService.GetByIdAsync(Id.Value); + if (client is null) + { + Snackbar.Add("고객을 찾을 수 없습니다.", Severity.Error); + Navigation.NavigateTo("/taxbaik/admin/clients"); + return; + } + dto = new CreateClientDto + { + Name = client.Name, + CompanyName = client.CompanyName, + Phone = client.Phone, + Email = client.Email, + ServiceType = client.ServiceType, + TaxType = client.TaxType, + Status = client.Status, + Source = client.Source, + Memo = client.Memo + }; + } + isLoading = false; + } + + private async Task SaveAsync() + { + await form.Validate(); + if (!isValid) return; + + isSaving = true; + try + { + if (Id.HasValue) + { + await ClientService.UpdateAsync(Id.Value, dto); + Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success); + } + else + { + var newId = await ClientService.CreateAsync(dto); + Snackbar.Add("고객이 등록되었습니다.", Severity.Success); + } + Navigation.NavigateTo("/taxbaik/admin/clients"); + } + catch (Exception ex) + { + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); + } + finally + { + isSaving = false; + } + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor new file mode 100644 index 0000000..d1fc4eb --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientList.razor @@ -0,0 +1,192 @@ +@page "/admin/clients" +@attribute [Authorize] +@using TaxBaik.Application.Services +@using TaxBaik.Domain.Entities +@inject ClientService ClientService +@inject NavigationManager Navigation +@inject IDialogService DialogService +@inject ISnackbar Snackbar + +고객 관리 + +
+
+ CRM + 고객 관리 + 고객 카드를 등록하고 상담 이력을 관리합니다. +
+ + 고객 등록 + +
+ +@* 검색/필터 바 *@ + + + + + + + + 전체 + 활성 + 비활성 + + + + 검색 + + + 초기화 + + + + + + @if (clients is null) + { + + } + else if (!clients.Any()) + { +
+ + 등록된 고객이 없습니다. +
+ } + else + { + + + + 이름 + 회사명 + 연락처 + 서비스 + 세금 유형 + 상태 + 유입 경로 + 등록일 + + + + + @foreach (var c in clients) + { + + @c.Name + @(c.CompanyName ?? "—") + @(c.Phone ?? "—") + + @if (!string.IsNullOrEmpty(c.ServiceType)) + { + @c.ServiceType + } + + @(c.TaxType ?? "—") + + @if (c.Status == "active") + { + 활성 + } + else + { + 비활성 + } + + @(c.Source ?? "—") + @c.CreatedAt.ToLocalTime().ToString("yy.MM.dd") + + + + 수정 + + + 삭제 + + + + + } + + + + @* 페이징 *@ + @if (totalPages > 1) + { +
+ +
+ } + 총 @(totalCount)명 + } +
+ +@code { + private List? clients; + private string searchText = ""; + private string statusFilter = ""; + private int currentPage = 1; + private int totalCount; + private int totalPages; + private const int PageSize = 20; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + var (items, total) = await ClientService.GetPagedAsync( + currentPage, PageSize, + string.IsNullOrEmpty(statusFilter) ? null : statusFilter, + string.IsNullOrEmpty(searchText) ? null : searchText); + + clients = items.ToList(); + totalCount = total; + totalPages = (int)Math.Ceiling((double)total / PageSize); + } + + private async Task SearchAsync() + { + currentPage = 1; + await LoadAsync(); + } + + private async Task ResetAsync() + { + searchText = ""; + statusFilter = ""; + currentPage = 1; + await LoadAsync(); + } + + private async Task OnPageChanged(int page) + { + currentPage = page; + await LoadAsync(); + } + + private async Task OnSearchKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") await SearchAsync(); + } + + private async Task DeleteAsync(Client client) + { + var confirmed = await DialogService.ShowMessageBox( + "고객 삭제", + $"'{client.Name}' 고객을 삭제하시겠습니까? 관련 데이터도 함께 삭제됩니다.", + yesText: "삭제", cancelText: "취소"); + + if (confirmed != true) return; + + await ClientService.DeleteAsync(client.Id); + Snackbar.Add($"{client.Name} 고객이 삭제되었습니다.", Severity.Success); + await LoadAsync(); + } +} diff --git a/db/migrations/V006__CreateClients.sql b/db/migrations/V006__CreateClients.sql new file mode 100644 index 0000000..c33b88b --- /dev/null +++ b/db/migrations/V006__CreateClients.sql @@ -0,0 +1,19 @@ +-- 고객 카드 (Client CRM) +CREATE TABLE IF NOT EXISTS clients ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + company_name VARCHAR(200), + phone VARCHAR(30), + email VARCHAR(200), + service_type VARCHAR(50), -- 기장, 부동산, 증여·상속, 종합소득세, 기타 + tax_type VARCHAR(30), -- 개인, 법인, 면세사업자 + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, inactive + source VARCHAR(50), -- 홈페이지문의, 소개, 직접방문, 기타 + memo TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_clients_status ON clients (status); +CREATE INDEX IF NOT EXISTS idx_clients_name ON clients (name); +CREATE INDEX IF NOT EXISTS idx_clients_phone ON clients (phone);