feat: WBS-CRM-01 고객 카드 (Client Card) Phase 1 구현
DB: - V006__CreateClients.sql: clients 테이블 (name, company_name, phone, email, service_type, tax_type, status, source, memo) Domain: - Client 엔티티 - IClientRepository (GetPagedAsync 이름/연락처/회사명 검색 + 상태 필터) Infrastructure: - ClientRepository: ILIKE 검색, 페이징, CRUD Application: - ClientService: ServiceTypes/TaxTypes/Sources 상수 정의 - CreateClientDto Admin UI: - ClientList.razor: 검색바 + 상태 필터 + 페이징 테이블 - ClientEdit.razor: 기본정보/세무정보/관리정보 섹션 폼 - MainLayout: 고객 관리 NavGroup 추가, 홈페이지 메뉴 그룹화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<CategoryService>();
|
services.AddScoped<CategoryService>();
|
||||||
services.AddScoped<AnnouncementService>();
|
services.AddScoped<AnnouncementService>();
|
||||||
services.AddSingleton<SeasonalMarketingService>();
|
services.AddSingleton<SeasonalMarketingService>();
|
||||||
|
services.AddScoped<ClientService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Client> 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<Client?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
await repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<int> 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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TaxBaik.Domain.Interfaces;
|
||||||
|
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
|
|
||||||
|
public interface IClientRepository
|
||||||
|
{
|
||||||
|
Task<(IEnumerable<Client> Items, int Total)> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? status = null, string? search = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
Task<Client?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<int> CreateAsync(Client client, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Client client, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
services.AddScoped<IInquiryRepository, InquiryRepository>();
|
||||||
services.AddScoped<ISiteSettingRepository, SiteSettingRepository>();
|
services.AddScoped<ISiteSettingRepository, SiteSettingRepository>();
|
||||||
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
|
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
|
||||||
|
services.AddScoped<IClientRepository, ClientRepository>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Client> 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<Client>()).ToList();
|
||||||
|
var total = await reader.ReadFirstAsync<int>();
|
||||||
|
return (items, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Client?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<Client>(
|
||||||
|
$"SELECT {SelectColumns} FROM clients WHERE id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Client client, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = Conn();
|
||||||
|
return await conn.QueryFirstAsync<int>(
|
||||||
|
@"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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,8 +42,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<MudNavMenu Class="admin-nav">
|
<MudNavMenu Class="admin-nav">
|
||||||
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
|
||||||
|
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" Expanded="true">
|
||||||
|
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
|
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" Expanded="false">
|
||||||
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
|
||||||
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
<PageTitle>@(Id.HasValue ? "고객 수정" : "고객 등록")</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</MudText>
|
||||||
|
<MudText Typo="Typo.h4" Class="admin-page-title">@(Id.HasValue ? "고객 수정" : "고객 등록")</MudText>
|
||||||
|
</div>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients"
|
||||||
|
StartIcon="@Icons.Material.Filled.ArrowBack">목록으로</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0" Style="max-width:720px;">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudForm @ref="form" @bind-IsValid="isValid">
|
||||||
|
<MudGrid Spacing="3">
|
||||||
|
@* 기본 정보 *@
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">기본 정보</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.Name" Label="고객명 *" Required="true"
|
||||||
|
RequiredError="고객명을 입력하세요." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.CompanyName" Label="회사명 (선택)" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.Phone" Label="연락처"
|
||||||
|
Placeholder="010-0000-0000" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="dto.Email" Label="이메일" InputType="InputType.Email" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 세무 정보 *@
|
||||||
|
<MudItem xs="12" Class="mt-2">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">세무 정보</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.ServiceType" Label="서비스 유형" T="string" Clearable="true">
|
||||||
|
@foreach (var t in ClientService.ServiceTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.TaxType" Label="세금 유형" T="string" Clearable="true">
|
||||||
|
@foreach (var t in ClientService.TaxTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@t">@t</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 관리 정보 *@
|
||||||
|
<MudItem xs="12" Class="mt-2">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="fw-bold mb-1">관리 정보</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.Status" Label="상태 *" T="string" Required="true">
|
||||||
|
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudSelect @bind-Value="dto.Source" Label="유입 경로" T="string" Clearable="true">
|
||||||
|
@foreach (var s in ClientService.Sources)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@s">@s</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="dto.Memo" Label="메모"
|
||||||
|
Lines="4" AutoGrow="true"
|
||||||
|
Placeholder="상담 배경, 특이사항, 중요 날짜 등 자유롭게 기록하세요" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* 저장 버튼 *@
|
||||||
|
<MudItem xs="12" Class="d-flex gap-2 mt-2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="@SaveAsync" Disabled="@isSaving">
|
||||||
|
@(isSaving ? "저장 중..." : "저장")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/taxbaik/admin/clients">
|
||||||
|
취소
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudForm>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<PageTitle>고객 관리</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-page-hero">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.caption" Class="admin-eyebrow">CRM</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.PersonAdd"
|
||||||
|
Href="/taxbaik/admin/clients/create">
|
||||||
|
고객 등록
|
||||||
|
</MudButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* 검색/필터 바 *@
|
||||||
|
<MudPaper Class="admin-surface mb-3 pa-3" Elevation="0">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudTextField @bind-Value="searchText" Label="검색 (이름·연락처·회사명)"
|
||||||
|
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
Immediate="false" OnKeyUp="@OnSearchKeyUp" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect @bind-Value="statusFilter" Label="상태" T="string">
|
||||||
|
<MudSelectItem Value="@("")">전체</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("active")">활성</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("inactive")">비활성</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="@SearchAsync" FullWidth="true">검색</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||||
|
<MudButton Variant="Variant.Text" OnClick="@ResetAsync" FullWidth="true">초기화</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="admin-surface" Elevation="0">
|
||||||
|
@if (clients is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else if (!clients.Any())
|
||||||
|
{
|
||||||
|
<div class="pa-6 text-center">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.PeopleAlt" Style="font-size:3rem; opacity:.3;" />
|
||||||
|
<MudText Class="mt-2 text-muted">등록된 고객이 없습니다.</MudText>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>회사명</th>
|
||||||
|
<th>연락처</th>
|
||||||
|
<th>서비스</th>
|
||||||
|
<th>세금 유형</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>유입 경로</th>
|
||||||
|
<th>등록일</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in clients)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><strong>@c.Name</strong></td>
|
||||||
|
<td>@(c.CompanyName ?? "—")</td>
|
||||||
|
<td>@(c.Phone ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(c.ServiceType))
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Primary">@c.ServiceType</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(c.TaxType ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@if (c.Status == "active")
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Success">활성</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip Size="Size.Small" Color="Color.Default">비활성</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(c.Source ?? "—")</td>
|
||||||
|
<td class="small">@c.CreatedAt.ToLocalTime().ToString("yy.MM.dd")</td>
|
||||||
|
<td>
|
||||||
|
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||||
|
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/clients/{c.Id}/edit"))">
|
||||||
|
수정
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(c))">
|
||||||
|
삭제
|
||||||
|
</MudButton>
|
||||||
|
</MudButtonGroup>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
|
||||||
|
@* 페이징 *@
|
||||||
|
@if (totalPages > 1)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-center pa-3">
|
||||||
|
<MudPagination BoundaryCount="1" MiddleCount="3"
|
||||||
|
Count="@totalPages" Selected="@currentPage"
|
||||||
|
SelectedChanged="@OnPageChanged" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.caption" Class="pa-2 text-muted">총 @(totalCount)명</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Client>? 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user