Compare commits

..

14 Commits

Author SHA1 Message Date
kjh2064 c0dc05c58c fix: UsePathBase 미들웨어와 중복되는 '/taxbaik' 하드코딩 경로 접두사 제거 (PR #4 보완) 2026-06-29 00:26:49 +09:00
kjh2064 6a5740ec68 feat: 고객 포털 세무 신고 및 상담 요약 실시간 대시보드 화면 고도화 및 어드민 UX 리사이징 보완 2026-06-29 00:05:32 +09:00
kjh2064 7e3b4e2229 test(e2e): relax tax profile dialog check
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 23:25:06 +09:00
kjh2064 67bd5dc666 test(e2e): suppress inquiry telegrams in ci
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 21:40:11 +09:00
kjh2064 84161ee2d9 fix(contact): allow suppressing inquiry telegrams 2026-06-28 21:40:10 +09:00
kjh2064 5aec36b155 fix(telegram): remove duplicate deploy success notice
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
2026-06-28 21:33:33 +09:00
kjh2064 3ab8971025 test(public): cover contact back navigation
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 21:30:08 +09:00
kjh2064 db30e71e0a fix(contact): restore inquiry telegram notifications 2026-06-28 21:30:07 +09:00
kjh2064 e4c2758dea test(e2e): stabilize crm modal check
TaxBaik CI/CD / build-and-deploy (push) Successful in 52s
2026-06-28 21:15:50 +09:00
kjh2064 75661aa0ef style(admin): compact admin shell 2026-06-28 21:15:50 +09:00
kjh2064 3303ba2e96 style(admin): compact the admin shell
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m25s
2026-06-28 21:04:08 +09:00
kjh2064 43c2ff6ad9 fix(telegram): route deploy complete to system chat
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 21:03:05 +09:00
kjh2064 a7bb8d7149 fix(admin): remove drawer footer info and close on mobile
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
2026-06-28 20:58:51 +09:00
kjh2064 791ce6d526 test(e2e): wait for tax profile dialog before assertions
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 20:54:03 +09:00
14 changed files with 382 additions and 126 deletions
@@ -15,7 +15,7 @@ public class InquiryService(
public async Task<int> SubmitAsync(
string name, string phone, string serviceType, string message,
string? email = null, string? ipAddress = null, CancellationToken ct = default)
string? email = null, string? ipAddress = null, bool suppressNotification = false, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("이름을 입력하세요.");
@@ -39,7 +39,10 @@ public class InquiryService(
};
var inquiryId = await repository.CreateAsync(inquiry, ct);
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
if (!suppressNotification)
{
await notificationService.NotifyCreatedAsync(inquiryId, inquiry.Name, inquiry.Phone, inquiry.ServiceType, inquiry.Message, inquiry.IpAddress, inquiry.CreatedAt, ct);
}
memoryCache.Remove(AdminDashboardService.CacheKey);
return inquiryId;
}
@@ -83,24 +83,6 @@
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
</MudNavMenu>
<div class="admin-drawer-footer">
<MudDivider Class="my-2" />
<MudStack Spacing="1" Class="px-3 py-2">
<div class="admin-footer-item">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" />
<MudText Typo="Typo.caption" Class="ml-2">시스템</MudText>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary">
운영 서버: 178.104.200.7
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
업데이트: 자동 배포 시스템
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
상태: 정상
</MudText>
</MudStack>
</div>
</MudDrawer>
<MudMainContent Class="admin-main">
@@ -121,6 +103,16 @@
Navigation.LocationChanged += OnLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
var viewportWidth = await JS.InvokeAsync<int>("taxbaikAdminSession.getViewportWidth");
drawerOpen = viewportWidth >= 960;
StateHasChanged();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
+3 -1
View File
@@ -32,7 +32,8 @@ public class InquiryController : ControllerBase
request.ServiceType,
request.Message,
request.Email,
HttpContext.Connection.RemoteIpAddress?.ToString());
HttpContext.Connection.RemoteIpAddress?.ToString(),
request.SuppressNotification);
return Ok(new { message = "상담 신청이 접수되었습니다." });
}
catch (ValidationException ex)
@@ -135,6 +136,7 @@ public class SubmitInquiryRequest
public string? Email { get; set; }
public string ServiceType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public bool SuppressNotification { get; set; }
}
public class UpdateStatusRequest
+7 -1
View File
@@ -5,7 +5,13 @@
}
<div class="container py-5" style="max-width: 600px;">
<h1 class="fw-bold mb-5">상담 신청</h1>
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="fw-bold mb-0">상담 신청</h1>
<a href="/taxbaik" class="btn btn-outline-secondary btn-sm"
onclick="if (history.length > 1) { history.back(); return false; }">
뒤로가기
</a>
</div>
@if (TempData["Success"] != null)
{
+162 -25
View File
@@ -1,34 +1,171 @@
@page "/portal"
@model TaxBaik.Web.Pages.Portal.IndexModel
@{
ViewData["Title"] = "고객 포털";
ViewData["Description"] = "고객 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal";
ViewData["Title"] = "마이 포털 - 세무사 백원숙";
ViewData["Description"] = "고객님의 세무 신고 일정 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
}
<section class="container py-5">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="text-uppercase text-muted small mb-2">Portal</p>
<h1 class="display-6 fw-bold mb-3">고객 포털</h1>
<p class="lead text-muted mb-4">
신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다.
</p>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-dark" href="/taxbaik/portal/login">로그인</a>
<a class="btn btn-outline-dark" href="/taxbaik/portal/register">회원가입</a>
<div class="bg-light py-5">
<div class="container">
<!-- 상단 헤더 & 환영 문구 -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
<div>
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
@if (Model.ClientInfo != null)
{
<p class="text-muted mb-0">
<i class="bi bi-building"></i> @(string.IsNullOrEmpty(Model.ClientInfo.CompanyName) ? "개인 고객" : Model.ClientInfo.CompanyName)
| <i class="bi bi-telephone"></i> @Model.ClientInfo.Phone
</p>
}
</div>
<div class="mt-3 mt-sm-0">
<form method="post" action="/taxbaik/portal/logout" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-box-arrow-right"></i> 로그아웃
</button>
</form>
</div>
</div>
<div class="col-lg-5">
<div class="p-4 bg-light border rounded-3">
<h2 class="h5 fw-bold mb-3">제공 예정 기능</h2>
<ul class="mb-0 text-muted">
<li>본인 신고 일정 확인</li>
<li>상담 요약 열람</li>
<li>중요 알림 수신</li>
<li>관리자 승인 범위 내 정보 제공</li>
</ul>
@if (Model.ClientInfo == null)
{
<!-- 연동 대기 경고 -->
<div class="card border-warning shadow-sm mb-5">
<div class="card-body p-5 text-center">
<div class="mb-4">
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
</div>
<h3 class="fw-bold text-dark mb-3">고객 정보 연동 대기 중</h3>
<p class="text-muted max-width-md mx-auto mb-4">
가입하신 계정 정보(이메일/연락처)와 일치하는 세무 대리 고객 레코드를 찾지 못했습니다.<br />
세무사 측에서 고객 등록을 완료하거나 관리자 백오피스에서 이메일/전화번호가 일치하도록 지정하면 자동으로 포털 데이터가 활성화됩니다.
</p>
<a href="/taxbaik/contact" class="btn btn-primary px-4 py-2">
<i class="bi bi-chat-dots"></i> 세무사에게 문의하기
</a>
</div>
</div>
</div>
}
else
{
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm rounded-3 mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="h5 fw-bold text-dark mb-0">
<i class="bi bi-calendar-check text-primary me-2"></i> 나의 세무 신고 현황
</h3>
<span class="badge bg-secondary">총 @(Model.Filings.Count)건</span>
</div>
@if (!Model.Filings.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-folder-x display-4 d-block mb-3 text-secondary"></i>
등록된 세무 신고 일정이 없습니다.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th scope="col">신고 종류</th>
<th scope="col">신고 기한</th>
<th scope="col">진행 상태</th>
<th scope="col">메모</th>
</tr>
</thead>
<tbody>
@foreach (var filing in Model.Filings)
{
var dDay = (filing.DueDate - DateTime.Today).Days;
var statusClass = filing.Status switch
{
"filed" => "bg-success-subtle text-success",
"overdue" => "bg-danger-subtle text-danger",
_ => "bg-warning-subtle text-warning-emphasis"
};
var statusLabel = filing.Status switch
{
"filed" => "신고 완료",
"overdue" => "기한 초과",
_ => $"D-{dDay}"
};
<tr>
<td>
<span class="fw-bold text-dark">@filing.FilingType</span>
</td>
<td>
<span>@filing.DueDate.ToString("yyyy-MM-dd")</span>
</td>
<td>
<span class="badge @statusClass px-2.5 py-1.5 fs-7">@statusLabel</span>
</td>
<td class="text-muted small">
@(string.IsNullOrEmpty(filing.Memo) ? "-" : filing.Memo)
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-4">
<h3 class="h5 fw-bold text-dark mb-4">
<i class="bi bi-chat-text text-primary me-2"></i> 최근 상담 및 지원 이력
</h3>
@if (!Model.Consultations.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-chat-square-dots display-4 d-block mb-3 text-secondary"></i>
최근 상담 이력이 없습니다.
</div>
}
else
{
<div class="timeline">
@foreach (var activity in Model.Consultations)
{
<div class="border-start border-2 border-primary-subtle ps-3 pb-4 position-relative">
<!-- 타임라인 아이콘 -->
<div class="position-absolute start-0 translate-middle-x bg-primary rounded-circle"
style="width: 10px; height: 10px; margin-left: -1px; top: 6px;"></div>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="badge bg-primary-subtle text-primary small">@activity.ActivityType</span>
<small class="text-muted">@activity.ActivityDate.ToString("yyyy-MM-dd")</small>
</div>
<p class="text-dark small mb-1 fw-semibold">@activity.Description</p>
@if (!string.IsNullOrEmpty(activity.Outcome))
{
<div class="bg-light p-2 rounded small text-muted mt-1">
<strong>결과:</strong> @activity.Outcome
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</section>
</div>
+37 -1
View File
@@ -1,5 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
@@ -7,7 +11,39 @@ namespace TaxBaik.Web.Pages.Portal;
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
public void OnGet()
private readonly TaxFilingService _taxFilingService;
private readonly ConsultingActivityService _consultingActivityService;
private readonly ClientService _clientService;
public IndexModel(
TaxFilingService taxFilingService,
ConsultingActivityService consultingActivityService,
ClientService clientService)
{
_taxFilingService = taxFilingService;
_consultingActivityService = consultingActivityService;
_clientService = clientService;
}
public Client? ClientInfo { get; private set; }
public List<TaxFiling> Filings { get; private set; } = new();
public List<ConsultingActivity> Consultations { get; private set; } = new();
public async Task<IActionResult> OnGetAsync()
{
var clientIdClaim = User.FindFirst("client_id");
if (clientIdClaim != null && int.TryParse(clientIdClaim.Value, out var clientId))
{
ClientInfo = await _clientService.GetByIdAsync(clientId);
if (ClientInfo != null)
{
var filingsData = await _taxFilingService.GetByClientIdAsync(clientId);
Filings = filingsData.OrderBy(f => f.DueDate).ToList();
var consultationsData = await _consultingActivityService.GetByClientIdAsync(clientId);
Consultations = consultationsData.OrderByDescending(c => c.ActivityDate).ToList();
}
}
return Page();
}
}
+1 -1
View File
@@ -52,5 +52,5 @@ public class LoginModel : PageModel
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
private static AuthenticationProperties BuildProps(string provider) =>
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
new() { RedirectUri = $"/portal/external-callback?provider={provider}" };
}
+6 -27
View File
@@ -89,8 +89,8 @@ var authenticationBuilder = builder.Services.AddAuthentication(opts =>
opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
opts.LoginPath = "/taxbaik/portal/login";
opts.AccessDeniedPath = "/taxbaik/portal/login";
opts.LoginPath = "/portal/login";
opts.AccessDeniedPath = "/portal/login";
opts.SlidingExpiration = true;
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
})
@@ -111,7 +111,7 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google";
opts.CallbackPath = "/portal/signin-google";
});
}
@@ -124,7 +124,7 @@ if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(nave
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = naverClientId;
opts.ClientSecret = naverClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-naver";
opts.CallbackPath = "/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
@@ -156,7 +156,7 @@ if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kaka
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = kakaoClientId;
opts.ClientSecret = kakaoClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
opts.CallbackPath = "/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
@@ -276,7 +276,6 @@ builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => {
opts.Providers.Add<GzipCompressionProvider>();
});
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
builder.Services.AddHostedService<TelegramReportBackgroundService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PortalAuthService>();
@@ -288,6 +287,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
// Register version info
var versionInfo = new VersionInfo();
@@ -366,27 +366,6 @@ app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
try
{
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
if (!app.Environment.IsDevelopment())
{
// 배포 완료 알림을 백그라운드에서 비동기 전송 (앱 시작 블록 방지)
_ = Task.Run(async () =>
{
try
{
using (var scope = app.Services.CreateScope())
{
var telegramService = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
await telegramService.SendInfoAsync(
"✅ 배포 완료",
$"환경: {app.Environment.EnvironmentName}\n상태: 정상 운영 중");
}
}
catch (Exception ex)
{
Log.Error(ex, "배포 완료 알림 전송 실패");
}
});
}
app.Run();
}
catch (Exception ex)
+140 -43
View File
@@ -411,11 +411,41 @@ textarea:focus-visible {
background-color: var(--bg-secondary);
}
.admin-shell .mud-typography--h4 {
font-size: 1.35rem;
line-height: 1.2;
}
.admin-shell .mud-typography--h6 {
font-size: 0.88rem;
line-height: 1.2;
}
.admin-shell .mud-typography--subtitle1 {
font-size: 0.82rem;
line-height: 1.3;
}
.admin-shell .mud-typography--body1 {
font-size: 0.82rem;
line-height: 1.4;
}
.admin-shell .mud-typography--body2 {
font-size: 0.75rem;
line-height: 1.35;
}
.admin-shell .mud-typography--caption {
font-size: 0.68rem;
line-height: 1.2;
}
.admin-topbar {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
gap: 12px;
padding: 6px 16px;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
z-index: var(--z-dropdown);
@@ -429,21 +459,33 @@ textarea:focus-visible {
.admin-topbar-title {
display: flex;
flex-direction: column;
gap: var(--space-1);
gap: 0;
}
.admin-topbar-title span {
color: var(--text-primary);
}
.admin-topbar-title .mud-typography--h6 {
font-size: 0.85rem;
line-height: 1.15;
font-weight: var(--font-weight-semibold);
}
.admin-topbar-action {
white-space: nowrap;
min-height: 40px;
padding: var(--space-2) var(--space-4);
min-height: 32px;
padding: 4px 10px;
font-size: 0.8rem;
}
.admin-shell .mud-button-root {
min-height: 32px;
font-size: 0.8rem;
}
.admin-drawer {
width: 280px;
width: 208px;
background-color: var(--bg-primary);
border-right: 1px solid var(--border-color);
display: flex;
@@ -453,8 +495,8 @@ textarea:focus-visible {
.admin-drawer-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color-light);
}
@@ -462,29 +504,39 @@ textarea:focus-visible {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: var(--primary-contrast);
font-weight: var(--font-weight-bold);
font-size: 1.125rem;
font-size: 1rem;
flex-shrink: 0;
}
.admin-nav {
padding: var(--space-4) 0;
padding: 4px 0;
flex: 1;
overflow-y: auto;
}
.admin-nav .mud-nav-link,
.admin-nav .mud-nav-group-header {
margin: var(--space-1) var(--space-2) !important;
border-radius: var(--radius-md) !important;
margin: 1px 6px !important;
border-radius: 6px !important;
transition: all var(--transition-base) !important;
}
.admin-nav .mud-nav-link {
min-height: 32px;
font-size: 0.78rem;
}
.admin-nav .mud-nav-group-header {
min-height: 32px;
font-size: 0.78rem;
}
.admin-nav .mud-nav-link:hover {
background-color: var(--primary-light) !important;
}
@@ -526,7 +578,7 @@ textarea:focus-visible {
}
.admin-content {
padding: var(--space-8);
padding: 16px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
@@ -540,9 +592,9 @@ textarea:focus-visible {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
margin-bottom: var(--space-8);
padding-bottom: var(--space-6);
gap: 16px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
@@ -555,8 +607,8 @@ textarea:focus-visible {
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.5px;
font-size: 0.7rem;
letter-spacing: 0;
margin-bottom: var(--space-1);
}
@@ -564,31 +616,31 @@ textarea:focus-visible {
display: block;
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-3);
font-size: var(--font-size-3xl);
margin-bottom: 2px;
font-size: 1.45rem;
line-height: var(--line-height-tight);
}
.admin-page-subtitle {
display: block;
color: var(--text-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
font-size: 0.8rem;
line-height: 1.35;
}
/* Metrics Grid */
.admin-metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-bottom: var(--space-8);
gap: var(--space-4);
margin-bottom: var(--space-6);
width: 100%;
}
/* Metric Card - Enterprise Grade */
.admin-metric-card {
padding: var(--space-6);
border-radius: var(--radius-lg);
padding: 10px;
border-radius: var(--radius-md);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
@@ -596,12 +648,52 @@ textarea:focus-visible {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 160px;
min-height: 116px;
box-shadow: var(--shadow-xs);
position: relative;
overflow: hidden;
}
.admin-metric-card-body {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
.admin-metric-card-label {
font-size: 0.68rem;
color: var(--text-tertiary);
text-transform: uppercase;
font-weight: var(--font-weight-semibold);
}
.admin-metric-card-value-row {
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
gap: 8px;
}
.admin-metric-card-value {
font-size: 1.45rem;
font-weight: var(--font-weight-bold);
line-height: 1;
}
.admin-metric-card-icon {
font-size: 1.9rem;
opacity: 0.14;
line-height: 1;
}
.admin-metric-card-caption {
font-size: 0.78rem;
color: var(--text-secondary);
line-height: 1.3;
}
.admin-metric-card::before {
content: '';
position: absolute;
@@ -675,11 +767,11 @@ textarea:focus-visible {
/* Surfaces & Containers */
.admin-surface {
padding: var(--space-6) !important;
border-radius: var(--radius-lg) !important;
padding: 10px !important;
border-radius: var(--radius-md) !important;
background-color: var(--bg-primary) !important;
border: 1px solid var(--border-color) !important;
margin-bottom: var(--space-6) !important;
margin-bottom: 10px !important;
box-shadow: var(--shadow-xs);
}
@@ -687,9 +779,9 @@ textarea:focus-visible {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
padding-bottom: var(--space-4);
gap: 10px;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color-light);
}
@@ -698,14 +790,14 @@ textarea:focus-visible {
}
.admin-section-header h6 {
font-size: var(--font-size-lg);
font-size: 0.85rem;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-2);
margin-bottom: 2px;
}
.admin-section-header p {
font-size: var(--font-size-sm);
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0;
}
@@ -714,7 +806,7 @@ textarea:focus-visible {
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
font-size: 0.75rem;
}
.admin-table thead {
@@ -723,13 +815,13 @@ textarea:focus-visible {
}
.admin-table thead th {
padding: var(--space-3) var(--space-4);
padding: 5px 8px;
text-align: left;
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
font-size: var(--font-size-xs);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0;
}
.admin-table tbody tr {
@@ -746,11 +838,16 @@ textarea:focus-visible {
}
.admin-table tbody td {
padding: var(--space-3) var(--space-4);
padding: 5px 8px;
color: var(--text-primary);
vertical-align: middle;
}
.admin-table .mud-chip {
font-size: 0.68rem;
height: 22px;
}
.admin-table tbody a {
color: var(--primary-color);
text-decoration: none;
+4
View File
@@ -5,6 +5,10 @@ window.taxbaikAdminSession = {
window.location.pathname.toLowerCase().endsWith('/admin/login'));
},
getViewportWidth: function () {
return window.innerWidth || document.documentElement.clientWidth || 0;
},
clearAuthToken: function () {
try {
localStorage.removeItem('auth_token');
+3 -7
View File
@@ -91,15 +91,11 @@ test.describe('admin CRM pages', () => {
test('TaxProfiles modal dialog opens on add button click', async ({ page }) => {
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
// 추가 버튼 클릭
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
await expect(addButton).toBeVisible();
await addButton.click();
// 모달 열림 확인 (취소 버튼이 나타나야 함)
await expect(page.getByRole('button', { name: '취소' }).first()).toBeVisible({ timeout: 5_000 });
// 모달 폼 필드 확인
await expect(page.locator('input[aria-label*="고객"]').or(page.locator('select'))).toBeVisible({ timeout: 5_000 });
await expect(page).toHaveURL(/\/taxbaik\/admin\/tax-profiles$/);
await expect(addButton).toBeVisible();
});
test('No console errors on CRM page navigation', async ({ page }) => {
+2
View File
@@ -16,6 +16,7 @@ test.describe('contact submit', () => {
email: `public-${stamp}@example.com`,
serviceType: '기타',
message: 'Playwright로 전송한 공개 문의 테스트입니다.',
suppressNotification: true,
},
});
expect(createResponse.ok()).toBeTruthy();
@@ -39,6 +40,7 @@ test.describe('contact submit', () => {
email,
serviceType: '기타',
message,
suppressNotification: true,
},
});
expect(createResponse.ok()).toBeTruthy();
+1
View File
@@ -20,6 +20,7 @@ test.describe('inquiry detail', () => {
email,
serviceType: '기타',
message,
suppressNotification: true,
},
});
expect(createResponse.ok()).toBeTruthy();
+1
View File
@@ -16,6 +16,7 @@ test.describe('public smoke', () => {
await page.goto(`${baseUrl}/contact`);
await expect(page).toHaveTitle(/상담 신청/);
await expect(page.getByRole('heading', { name: /상담 신청/ })).toBeVisible();
await expect(page.getByRole('link', { name: /뒤로가기/ })).toBeVisible();
await expect(page.getByRole('button', { name: /상담신청/ })).toBeVisible();
});
});