Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a04592499c | |||
| f569211967 | |||
| c8306e2ac7 | |||
| bad2f47ffe | |||
| 943fe9c819 | |||
| 6a5740ec68 | |||
| 3c8f30af6d |
@@ -24,11 +24,11 @@
|
|||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||||
Variant="Variant.Outlined" Class="mb-4">
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
@foreach (var category in categories)
|
@foreach (var category in categories)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ else
|
|||||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||||
|
|
||||||
<MudSelect @bind-Value="model.CategoryId" Label="카테고리"
|
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||||
Variant="Variant.Outlined" Class="mb-4">
|
Variant="Variant.Outlined" Class="mb-4">
|
||||||
@foreach (var category in categories)
|
@foreach (var category in categories)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Logging;
|
||||||
|
|
||||||
|
public class TelegramSink : ILogEventSink
|
||||||
|
{
|
||||||
|
private readonly string _botToken;
|
||||||
|
private readonly string _chatId;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public TelegramSink(string botToken, string chatId)
|
||||||
|
{
|
||||||
|
_botToken = botToken;
|
||||||
|
_chatId = chatId;
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
if (logEvent.Level < LogEventLevel.Error)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit is a synchronous method, so we dispatch the network call asynchronously
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var timestamp = logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz");
|
||||||
|
var level = logEvent.Level.ToString().ToUpper();
|
||||||
|
var message = logEvent.RenderMessage();
|
||||||
|
var exceptionDetails = logEvent.Exception?.ToString();
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"<b>🚨 [{level}] 에러 발생</b>");
|
||||||
|
sb.AppendLine($"<b>시간:</b> {timestamp}");
|
||||||
|
sb.AppendLine($"<b>메시지:</b> {EscapeHtml(message)}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(exceptionDetails))
|
||||||
|
{
|
||||||
|
var escapedException = EscapeHtml(exceptionDetails);
|
||||||
|
if (escapedException.Length > 3000)
|
||||||
|
{
|
||||||
|
escapedException = escapedException.Substring(0, 3000) + "\n[이하 생략]";
|
||||||
|
}
|
||||||
|
sb.AppendLine($"<b>Exception 상세:</b>\n<pre>{escapedException}</pre>");
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"https://api.telegram.org/bot{_botToken}/sendMessage";
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
chat_id = _chatId,
|
||||||
|
text = sb.ToString(),
|
||||||
|
parse_mode = "HTML"
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(url, payload);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorResponse = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($"[TelegramSink] Failed to send log to Telegram: {response.StatusCode} - {errorResponse}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[TelegramSink] Error in TelegramSink: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeHtml(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
return text.Replace("&", "&")
|
||||||
|
.Replace("<", "<")
|
||||||
|
.Replace(">", ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,171 @@
|
|||||||
@page "/portal"
|
@page "/portal"
|
||||||
@model TaxBaik.Web.Pages.Portal.IndexModel
|
@model TaxBaik.Web.Pages.Portal.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "고객 포털";
|
ViewData["Title"] = "마이 포털 - 세무사 백원숙";
|
||||||
ViewData["Description"] = "고객이 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다.";
|
ViewData["Description"] = "고객님의 세무 신고 일정과 상담 이력을 실시간으로 확인하실 수 있는 마이페이지입니다.";
|
||||||
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="container py-5">
|
<div class="bg-light py-5">
|
||||||
<div class="row g-4 align-items-start">
|
<div class="container">
|
||||||
<div class="col-lg-7">
|
<!-- 상단 헤더 & 환영 문구 -->
|
||||||
<p class="text-uppercase text-muted small mb-2">Portal</p>
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-5 pb-4 border-bottom">
|
||||||
<h1 class="display-6 fw-bold mb-3">고객 포털</h1>
|
<div>
|
||||||
<p class="lead text-muted mb-4">
|
<p class="text-primary fw-bold mb-1">TaxBaik My Portal</p>
|
||||||
신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다.
|
<h1 class="display-6 fw-bold text-dark">안녕하세요, @(User.Identity?.Name)님!</h1>
|
||||||
</p>
|
@if (Model.ClientInfo != null)
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
{
|
||||||
<a class="btn btn-dark" href="/taxbaik/portal/login">로그인</a>
|
<p class="text-muted mb-0">
|
||||||
<a class="btn btn-outline-dark" href="/taxbaik/portal/register">회원가입</a>
|
<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>
|
</div>
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="p-4 bg-light border rounded-3">
|
@if (Model.ClientInfo == null)
|
||||||
<h2 class="h5 fw-bold mb-3">제공 예정 기능</h2>
|
{
|
||||||
<ul class="mb-0 text-muted">
|
<!-- 연동 대기 경고 -->
|
||||||
<li>본인 신고 일정 확인</li>
|
<div class="card border-warning shadow-sm mb-5">
|
||||||
<li>상담 요약 열람</li>
|
<div class="card-body p-5 text-center">
|
||||||
<li>중요 알림 수신</li>
|
<div class="mb-4">
|
||||||
<li>관리자 승인 범위 내 정보 제공</li>
|
<span class="display-1 text-warning"><i class="bi bi-exclamation-triangle-fill"></i></span>
|
||||||
</ul>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using TaxBaik.Application.Services;
|
||||||
|
using TaxBaik.Domain.Entities;
|
||||||
using TaxBaik.Web.Services;
|
using TaxBaik.Web.Services;
|
||||||
|
|
||||||
namespace TaxBaik.Web.Pages.Portal;
|
namespace TaxBaik.Web.Pages.Portal;
|
||||||
@@ -7,7 +11,39 @@ namespace TaxBaik.Web.Pages.Portal;
|
|||||||
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
|
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
|
||||||
public class IndexModel : PageModel
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ builder.Host.UseSerilog((context, config) =>
|
|||||||
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
|
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName);
|
||||||
|
|
||||||
|
var botToken = context.Configuration["Telegram:BotToken"];
|
||||||
|
var systemChatId = context.Configuration["Telegram:SystemChatId"] ?? context.Configuration["Telegram:ChatId"];
|
||||||
|
if (!string.IsNullOrEmpty(botToken) && !string.IsNullOrEmpty(systemChatId))
|
||||||
|
{
|
||||||
|
config.WriteTo.Sink(new TaxBaik.Web.Logging.TelegramSink(botToken, systemChatId), Serilog.Events.LogEventLevel.Error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Controllers (API)
|
// Controllers (API)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface ITelegramNotificationService
|
|||||||
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
|
Task SendInfoAsync(string title, string message, CancellationToken ct = default);
|
||||||
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
|
Task SendInquiryNotificationAsync(string message, CancellationToken ct = default);
|
||||||
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
|
Task SendSystemNotificationAsync(string message, CancellationToken ct = default);
|
||||||
|
Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TelegramNotificationService : ITelegramNotificationService
|
public class TelegramNotificationService : ITelegramNotificationService
|
||||||
@@ -96,4 +97,10 @@ public class TelegramNotificationService : ITelegramNotificationService
|
|||||||
var text = $"<b>ℹ️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
var text = $"<b>ℹ️ {title}</b>\n\n{message}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
||||||
await SendMessageAsync(text, ct);
|
await SendMessageAsync(text, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendReportAsync(string reportTitle, string reportContent, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var text = $"<b>📊 {reportTitle}</b>\n\n{reportContent}\n\n<i>{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</i>";
|
||||||
|
await SendToChat(_systemChatId, text, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public class TelegramReportBackgroundService(
|
|||||||
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||||
|
|
||||||
var report = await reportService.BuildDailyReportAsync(date, ct);
|
var report = await reportService.BuildDailyReportAsync(date, ct);
|
||||||
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
|
await telegram.SendReportAsync("일간 세무/상담 현황 리포트", TelegramReportService.FormatDailyMessage(report), ct);
|
||||||
_lastDailyReportDate = date;
|
_lastDailyReportDate = date;
|
||||||
logger.LogInformation("Daily telegram report sent for {Date}", date);
|
logger.LogInformation("Daily telegram report sent for {Date}", date);
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ public class TelegramReportBackgroundService(
|
|||||||
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
var telegram = scope.ServiceProvider.GetRequiredService<ITelegramNotificationService>();
|
||||||
|
|
||||||
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
|
var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
|
||||||
await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
|
await telegram.SendReportAsync("주간 세무/매출 종합 리포트", TelegramReportService.FormatWeeklyMessage(report), ct);
|
||||||
_lastWeeklyReportWeekStart = weekStart;
|
_lastWeeklyReportWeekStart = weekStart;
|
||||||
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
|
logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,11 +411,41 @@ textarea:focus-visible {
|
|||||||
background-color: var(--bg-secondary);
|
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 {
|
.admin-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: 12px;
|
||||||
padding: 8px 20px;
|
padding: 6px 16px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
@@ -429,7 +459,7 @@ textarea:focus-visible {
|
|||||||
.admin-topbar-title {
|
.admin-topbar-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar-title span {
|
.admin-topbar-title span {
|
||||||
@@ -437,20 +467,25 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar-title .mud-typography--h6 {
|
.admin-topbar-title .mud-typography--h6 {
|
||||||
font-size: 0.95rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.2;
|
line-height: 1.15;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar-action {
|
.admin-topbar-action {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-height: 36px;
|
min-height: 32px;
|
||||||
padding: 6px 12px;
|
padding: 4px 10px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .mud-button-root {
|
||||||
|
min-height: 32px;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-drawer {
|
.admin-drawer {
|
||||||
width: 224px;
|
width: 208px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -460,8 +495,8 @@ textarea:focus-visible {
|
|||||||
.admin-drawer-brand {
|
.admin-drawer-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--border-color-light);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,37 +504,37 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||||
color: var(--primary-contrast);
|
color: var(--primary-contrast);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
padding: 6px 0;
|
padding: 4px 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav .mud-nav-link,
|
.admin-nav .mud-nav-link,
|
||||||
.admin-nav .mud-nav-group-header {
|
.admin-nav .mud-nav-group-header {
|
||||||
margin: 1px 8px !important;
|
margin: 1px 6px !important;
|
||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
transition: all var(--transition-base) !important;
|
transition: all var(--transition-base) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav .mud-nav-link {
|
.admin-nav .mud-nav-link {
|
||||||
min-height: 36px;
|
min-height: 32px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav .mud-nav-group-header {
|
.admin-nav .mud-nav-group-header {
|
||||||
min-height: 36px;
|
min-height: 32px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav .mud-nav-link:hover {
|
.admin-nav .mud-nav-link:hover {
|
||||||
@@ -543,7 +578,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -557,9 +592,9 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 10px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,8 +607,8 @@ textarea:focus-visible {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: var(--font-size-xs);
|
font-size: 0.7rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0;
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,16 +616,16 @@ textarea:focus-visible {
|
|||||||
display: block;
|
display: block;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 2px;
|
||||||
font-size: 1.75rem;
|
font-size: 1.45rem;
|
||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-page-subtitle {
|
.admin-page-subtitle {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
line-height: var(--line-height-normal);
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metrics Grid */
|
/* Metrics Grid */
|
||||||
@@ -604,7 +639,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
/* Metric Card - Enterprise Grade */
|
/* Metric Card - Enterprise Grade */
|
||||||
.admin-metric-card {
|
.admin-metric-card {
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -613,12 +648,52 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 128px;
|
min-height: 116px;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 {
|
.admin-metric-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -692,11 +767,11 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
/* Surfaces & Containers */
|
/* Surfaces & Containers */
|
||||||
.admin-surface {
|
.admin-surface {
|
||||||
padding: 12px !important;
|
padding: 10px !important;
|
||||||
border-radius: var(--radius-md) !important;
|
border-radius: var(--radius-md) !important;
|
||||||
background-color: var(--bg-primary) !important;
|
background-color: var(--bg-primary) !important;
|
||||||
border: 1px solid var(--border-color) !important;
|
border: 1px solid var(--border-color) !important;
|
||||||
margin-bottom: 12px !important;
|
margin-bottom: 10px !important;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,9 +779,9 @@ textarea:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 10px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 6px;
|
||||||
border-bottom: 1px solid var(--border-color-light);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,14 +790,14 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-header h6 {
|
.admin-section-header h6 {
|
||||||
font-size: 0.95rem;
|
font-size: 0.85rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-header p {
|
.admin-section-header p {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -731,7 +806,7 @@ textarea:focus-visible {
|
|||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table thead {
|
.admin-table thead {
|
||||||
@@ -740,13 +815,13 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table thead th {
|
.admin-table thead th {
|
||||||
padding: 6px 10px;
|
padding: 5px 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table tbody tr {
|
.admin-table tbody tr {
|
||||||
@@ -763,11 +838,16 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table tbody td {
|
.admin-table tbody td {
|
||||||
padding: 6px 10px;
|
padding: 5px 8px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-table .mud-chip {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table tbody a {
|
.admin-table tbody a {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user