Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0dc05c58c | |||
| 6a5740ec68 | |||
| 7e3b4e2229 | |||
| 67bd5dc666 | |||
| 84161ee2d9 | |||
| 5aec36b155 | |||
| 3ab8971025 | |||
| db30e71e0a | |||
| e4c2758dea | |||
| 75661aa0ef | |||
| 3303ba2e96 | |||
| 43c2ff6ad9 | |||
| a7bb8d7149 | |||
| 791ce6d526 | |||
| 61083a5bb1 | |||
| 66fb86d23c | |||
| 16f7c6097c | |||
| 7232635ed0 | |||
| b42b98d560 | |||
| f216660afa | |||
| b31b43e30e | |||
| 86bd9ef8ff | |||
| 2fd9984a45 | |||
| 91330ec94c | |||
| 08102c8684 |
@@ -9,3 +9,4 @@ Authentication__Naver__ClientId=
|
|||||||
Authentication__Naver__ClientSecret=
|
Authentication__Naver__ClientSecret=
|
||||||
Authentication__Kakao__ClientId=
|
Authentication__Kakao__ClientId=
|
||||||
Authentication__Kakao__ClientSecret=
|
Authentication__Kakao__ClientSecret=
|
||||||
|
# CI deploy trigger requires a real push on master.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: TaxBaik CI/CD
|
name: TaxBaik CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@@ -130,9 +131,9 @@ jobs:
|
|||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
send_telegram "❌ <b>TaxBaik 배포 실패</b>
|
send_telegram "❌ <b>TaxBaik 배포 실패</b>
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
커밋: <code>${COMMIT}</code>
|
||||||
시간: <code>${TIMESTAMP}</code>
|
시간: <code>${TIMESTAMP}</code>
|
||||||
단계: CI/CD deploy"
|
단계: CI/CD deploy"
|
||||||
exit "$exit_code"
|
exit "$exit_code"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +221,7 @@ jobs:
|
|||||||
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
|
||||||
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
send_telegram "✅ <b>TaxBaik 배포 완료</b>
|
||||||
|
|
||||||
커밋: <code>${COMMIT}</code>
|
커밋: <code>${COMMIT}</code>
|
||||||
시간: <code>${TIMESTAMP}</code>
|
시간: <code>${TIMESTAMP}</code>
|
||||||
대상: <code>${DEPLOY_HOST}</code>
|
대상: <code>${DEPLOY_HOST}</code>
|
||||||
채널: <code>${TELEGRAM_CHAT_ID}</code>"
|
채널: <code>${TELEGRAM_CHAT_ID}</code>"
|
||||||
|
|||||||
@@ -1931,6 +1931,48 @@ else
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### CI Deploy 트러블슈팅 하네스 (2026-06-28)
|
||||||
|
|
||||||
|
커밋 후 배포가 동작하지 않는다고 판단하기 전에 아래 순서로 확인한다. 추측으로 runner, secret, 커밋 제목을 원인으로 단정하지 않는다.
|
||||||
|
|
||||||
|
1. **푸시 결과 확인**
|
||||||
|
```powershell
|
||||||
|
git push origin master 2>&1 | Select-String "master|To|Processed|remote"
|
||||||
|
```
|
||||||
|
`master -> master`가 보이면 Git push는 성공이다. 이 단계는 CI 실행 성공을 의미하지 않는다.
|
||||||
|
|
||||||
|
2. **Actions run 생성 확인**
|
||||||
|
```powershell
|
||||||
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
|
||||||
|
$runs = Invoke-RestMethod -Headers $headers -Uri "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
|
||||||
|
$runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
|
||||||
|
```
|
||||||
|
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
|
||||||
|
|
||||||
|
3. **workflow 파싱 검증**
|
||||||
|
```powershell
|
||||||
|
curl.exe -sS -w "`nHTTP_STATUS:%{http_code}`n" `
|
||||||
|
-H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
|
||||||
|
-H "Content-Type: application/json" `
|
||||||
|
-X POST "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/workflows/deploy.yml/dispatches?return_run_details=true" `
|
||||||
|
--data '{"ref":"refs/heads/master","inputs":{}}'
|
||||||
|
```
|
||||||
|
`failed to unmarshal workflow content`가 나오면 `.gitea/workflows/deploy.yml` YAML 문법 문제다. 여러 줄 문자열은 반드시 `run: |` 블록 들여쓰기 안에 둔다.
|
||||||
|
|
||||||
|
4. **job 실패 로그 확인**
|
||||||
|
```powershell
|
||||||
|
curl.exe -sS -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
|
||||||
|
"http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/jobs/{job_id}/logs"
|
||||||
|
```
|
||||||
|
빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.
|
||||||
|
|
||||||
|
**이번 장애 원인 기록**:
|
||||||
|
- `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다.
|
||||||
|
- 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다.
|
||||||
|
- 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 12. 문제 해결
|
## 12. 문제 해결
|
||||||
|
|
||||||
| 문제 | 해결 |
|
| 문제 | 해결 |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
|
||||||
|
|
||||||
|
CI deploy trigger verification note.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class InquiryService(
|
|||||||
|
|
||||||
public async Task<int> SubmitAsync(
|
public async Task<int> SubmitAsync(
|
||||||
string name, string phone, string serviceType, string message,
|
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))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
throw new ValidationException("이름을 입력하세요.");
|
throw new ValidationException("이름을 입력하세요.");
|
||||||
@@ -39,7 +39,10 @@ public class InquiryService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
var inquiryId = await repository.CreateAsync(inquiry, ct);
|
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);
|
memoryCache.Remove(AdminDashboardService.CacheKey);
|
||||||
return inquiryId;
|
return inquiryId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,24 +83,6 @@
|
|||||||
<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>
|
||||||
<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>
|
</MudDrawer>
|
||||||
|
|
||||||
<MudMainContent Class="admin-main">
|
<MudMainContent Class="admin-main">
|
||||||
@@ -121,6 +103,16 @@
|
|||||||
Navigation.LocationChanged += OnLocationChanged;
|
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)
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
{
|
{
|
||||||
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
_ = InvokeAsync(() => JS.InvokeVoidAsync("taxbaikAdminSession.showLoading"));
|
||||||
|
|||||||
@@ -26,10 +26,10 @@
|
|||||||
}
|
}
|
||||||
else if (activities.Count == 0)
|
else if (activities.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Timeline" Style="font-size:3rem; opacity:.3;" />
|
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="me-2" />
|
||||||
<MudText Class="mt-2 text-muted">상담 활동이 없습니다.</MudText>
|
상담 활동이 없습니다.
|
||||||
</div>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,10 +33,10 @@
|
|||||||
}
|
}
|
||||||
else if (contracts.Count == 0)
|
else if (contracts.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Description" Style="font-size:3rem; opacity:.3;" />
|
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
|
||||||
<MudText Class="mt-2 text-muted">계약이 없습니다.</MudText>
|
계약이 없습니다.
|
||||||
</div>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,10 +26,10 @@
|
|||||||
}
|
}
|
||||||
else if (revenues.Count == 0)
|
else if (revenues.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Payments" Style="font-size:3rem; opacity:.3;" />
|
<MudIcon Icon="@Icons.Material.Filled.Payments" Class="me-2" />
|
||||||
<MudText Class="mt-2 text-muted">청구 기록이 없습니다.</MudText>
|
청구 기록이 없습니다.
|
||||||
</div>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,10 +29,10 @@
|
|||||||
}
|
}
|
||||||
else if (schedules.Count == 0)
|
else if (schedules.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="pa-6 text-center">
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Style="font-size:3rem; opacity:.3;" />
|
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
|
||||||
<MudText Class="mt-2 text-muted">신고 일정이 없습니다.</MudText>
|
신고 일정이 없습니다.
|
||||||
</div>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
token = tokenPair.AccessToken,
|
||||||
accessToken = tokenPair.AccessToken,
|
accessToken = tokenPair.AccessToken,
|
||||||
refreshToken = tokenPair.RefreshToken,
|
refreshToken = tokenPair.RefreshToken,
|
||||||
expiresIn = tokenPair.ExpiresIn
|
expiresIn = tokenPair.ExpiresIn
|
||||||
@@ -45,6 +46,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
token = tokenPair.AccessToken,
|
||||||
accessToken = tokenPair.AccessToken,
|
accessToken = tokenPair.AccessToken,
|
||||||
refreshToken = tokenPair.RefreshToken,
|
refreshToken = tokenPair.RefreshToken,
|
||||||
expiresIn = tokenPair.ExpiresIn
|
expiresIn = tokenPair.ExpiresIn
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public class InquiryController : ControllerBase
|
|||||||
request.ServiceType,
|
request.ServiceType,
|
||||||
request.Message,
|
request.Message,
|
||||||
request.Email,
|
request.Email,
|
||||||
HttpContext.Connection.RemoteIpAddress?.ToString());
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
request.SuppressNotification);
|
||||||
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
return Ok(new { message = "상담 신청이 접수되었습니다." });
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
catch (ValidationException ex)
|
||||||
@@ -135,6 +136,7 @@ public class SubmitInquiryRequest
|
|||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string ServiceType { get; set; } = string.Empty;
|
public string ServiceType { get; set; } = string.Empty;
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public bool SuppressNotification { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateStatusRequest
|
public class UpdateStatusRequest
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="container py-5" style="max-width: 600px;">
|
<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)
|
@if (TempData["Success"] != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,5 @@ public class LoginModel : PageModel
|
|||||||
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
|
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
|
||||||
|
|
||||||
private static AuthenticationProperties BuildProps(string provider) =>
|
private static AuthenticationProperties BuildProps(string provider) =>
|
||||||
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
|
new() { RedirectUri = $"/portal/external-callback?provider={provider}" };
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-86
@@ -64,7 +64,7 @@ if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnore
|
|||||||
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
|
||||||
var key = Encoding.ASCII.GetBytes(jwtKey);
|
var key = Encoding.ASCII.GetBytes(jwtKey);
|
||||||
|
|
||||||
builder.Services.AddAuthentication(opts =>
|
var authenticationBuilder = builder.Services.AddAuthentication(opts =>
|
||||||
{
|
{
|
||||||
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
@@ -89,8 +89,8 @@ builder.Services.AddAuthentication(opts =>
|
|||||||
opts.Cookie.HttpOnly = true;
|
opts.Cookie.HttpOnly = true;
|
||||||
opts.Cookie.SameSite = SameSiteMode.Lax;
|
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||||
opts.LoginPath = "/taxbaik/portal/login";
|
opts.LoginPath = "/portal/login";
|
||||||
opts.AccessDeniedPath = "/taxbaik/portal/login";
|
opts.AccessDeniedPath = "/portal/login";
|
||||||
opts.SlidingExpiration = true;
|
opts.SlidingExpiration = true;
|
||||||
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
opts.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
})
|
})
|
||||||
@@ -100,69 +100,87 @@ builder.Services.AddAuthentication(opts =>
|
|||||||
opts.Cookie.HttpOnly = true;
|
opts.Cookie.HttpOnly = true;
|
||||||
opts.Cookie.SameSite = SameSiteMode.Lax;
|
opts.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||||
})
|
|
||||||
.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
|
|
||||||
{
|
|
||||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
|
||||||
opts.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "";
|
|
||||||
opts.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "";
|
|
||||||
opts.CallbackPath = "/taxbaik/portal/signin-google";
|
|
||||||
})
|
|
||||||
.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
|
|
||||||
{
|
|
||||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
|
||||||
opts.ClientId = builder.Configuration["Authentication:Naver:ClientId"] ?? "";
|
|
||||||
opts.ClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"] ?? "";
|
|
||||||
opts.CallbackPath = "/taxbaik/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";
|
|
||||||
opts.SaveTokens = true;
|
|
||||||
opts.Events = new OAuthEvents
|
|
||||||
{
|
|
||||||
OnCreatingTicket = async context =>
|
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
|
||||||
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
|
||||||
var responseRoot = payload.RootElement.GetProperty("response");
|
|
||||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
|
|
||||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
|
|
||||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
|
|
||||||
{
|
|
||||||
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
|
||||||
opts.ClientId = builder.Configuration["Authentication:Kakao:ClientId"] ?? "";
|
|
||||||
opts.ClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"] ?? "";
|
|
||||||
opts.CallbackPath = "/taxbaik/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";
|
|
||||||
opts.SaveTokens = true;
|
|
||||||
opts.Events = new OAuthEvents
|
|
||||||
{
|
|
||||||
OnCreatingTicket = async context =>
|
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
|
||||||
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
|
||||||
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
|
|
||||||
var profile = kakaoAccount.GetProperty("profile");
|
|
||||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
|
|
||||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
|
|
||||||
if (kakaoAccount.TryGetProperty("email", out var emailProp))
|
|
||||||
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
|
||||||
|
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
|
||||||
|
{
|
||||||
|
authenticationBuilder.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||||
|
opts.ClientId = googleClientId;
|
||||||
|
opts.ClientSecret = googleClientSecret;
|
||||||
|
opts.CallbackPath = "/portal/signin-google";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var naverClientId = builder.Configuration["Authentication:Naver:ClientId"];
|
||||||
|
var naverClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(naverClientSecret))
|
||||||
|
{
|
||||||
|
authenticationBuilder.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||||
|
opts.ClientId = naverClientId;
|
||||||
|
opts.ClientSecret = naverClientSecret;
|
||||||
|
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";
|
||||||
|
opts.SaveTokens = true;
|
||||||
|
opts.Events = new OAuthEvents
|
||||||
|
{
|
||||||
|
OnCreatingTicket = async context =>
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||||
|
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
||||||
|
var responseRoot = payload.RootElement.GetProperty("response");
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var kakaoClientId = builder.Configuration["Authentication:Kakao:ClientId"];
|
||||||
|
var kakaoClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kakaoClientSecret))
|
||||||
|
{
|
||||||
|
authenticationBuilder.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
|
||||||
|
{
|
||||||
|
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
|
||||||
|
opts.ClientId = kakaoClientId;
|
||||||
|
opts.ClientSecret = kakaoClientSecret;
|
||||||
|
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";
|
||||||
|
opts.SaveTokens = true;
|
||||||
|
opts.Events = new OAuthEvents
|
||||||
|
{
|
||||||
|
OnCreatingTicket = async context =>
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||||
|
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
|
||||||
|
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
|
||||||
|
var profile = kakaoAccount.GetProperty("profile");
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
|
||||||
|
if (kakaoAccount.TryGetProperty("email", out var emailProp))
|
||||||
|
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Blazor 인증
|
// Blazor 인증
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
|
||||||
@@ -258,7 +276,6 @@ builder.Services.AddMemoryCache();
|
|||||||
builder.Services.AddResponseCompression(opts => {
|
builder.Services.AddResponseCompression(opts => {
|
||||||
opts.Providers.Add<GzipCompressionProvider>();
|
opts.Providers.Add<GzipCompressionProvider>();
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
|
||||||
builder.Services.AddHostedService<TelegramReportBackgroundService>();
|
builder.Services.AddHostedService<TelegramReportBackgroundService>();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<PortalAuthService>();
|
builder.Services.AddScoped<PortalAuthService>();
|
||||||
@@ -270,6 +287,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
|||||||
|
|
||||||
builder.Services.AddInfrastructure();
|
builder.Services.AddInfrastructure();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||||
|
|
||||||
// Register version info
|
// Register version info
|
||||||
var versionInfo = new VersionInfo();
|
var versionInfo = new VersionInfo();
|
||||||
@@ -348,27 +366,6 @@ app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Log.Information("애플리케이션 시작: {Environment}", app.Environment.EnvironmentName);
|
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();
|
app.Run();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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: var(--space-3) var(--space-6);
|
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,21 +459,33 @@ textarea:focus-visible {
|
|||||||
.admin-topbar-title {
|
.admin-topbar-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar-title span {
|
.admin-topbar-title span {
|
||||||
color: var(--text-primary);
|
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 {
|
.admin-topbar-action {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-height: 40px;
|
min-height: 32px;
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: 4px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .mud-button-root {
|
||||||
|
min-height: 32px;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-drawer {
|
.admin-drawer {
|
||||||
width: 280px;
|
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;
|
||||||
@@ -453,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: var(--space-5) var(--space-4);
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--border-color-light);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,29 +504,39 @@ 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: var(--space-4) 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: var(--space-1) var(--space-2) !important;
|
margin: 1px 6px !important;
|
||||||
border-radius: var(--radius-md) !important;
|
border-radius: 6px !important;
|
||||||
transition: all var(--transition-base) !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 {
|
.admin-nav .mud-nav-link:hover {
|
||||||
background-color: var(--primary-light) !important;
|
background-color: var(--primary-light) !important;
|
||||||
}
|
}
|
||||||
@@ -526,7 +578,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
padding: var(--space-8);
|
padding: 16px;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -540,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: var(--space-6);
|
gap: 16px;
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: 16px;
|
||||||
padding-bottom: var(--space-6);
|
padding-bottom: 10px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,31 +616,31 @@ 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: var(--space-3);
|
margin-bottom: 2px;
|
||||||
font-size: var(--font-size-3xl);
|
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: var(--font-size-base);
|
font-size: 0.8rem;
|
||||||
line-height: var(--line-height-normal);
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metrics Grid */
|
/* Metrics Grid */
|
||||||
.admin-metric-grid {
|
.admin-metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: var(--space-6);
|
gap: var(--space-4);
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-6);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metric Card - Enterprise Grade */
|
/* Metric Card - Enterprise Grade */
|
||||||
.admin-metric-card {
|
.admin-metric-card {
|
||||||
padding: var(--space-6);
|
padding: 10px;
|
||||||
border-radius: var(--radius-lg);
|
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);
|
||||||
transition: all var(--transition-base);
|
transition: all var(--transition-base);
|
||||||
@@ -596,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: 160px;
|
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;
|
||||||
@@ -675,11 +767,11 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
/* Surfaces & Containers */
|
/* Surfaces & Containers */
|
||||||
.admin-surface {
|
.admin-surface {
|
||||||
padding: var(--space-6) !important;
|
padding: 10px !important;
|
||||||
border-radius: var(--radius-lg) !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: var(--space-6) !important;
|
margin-bottom: 10px !important;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,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: var(--space-4);
|
gap: 10px;
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: 10px;
|
||||||
padding-bottom: var(--space-4);
|
padding-bottom: 6px;
|
||||||
border-bottom: 1px solid var(--border-color-light);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,14 +790,14 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-header h6 {
|
.admin-section-header h6 {
|
||||||
font-size: var(--font-size-lg);
|
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: var(--space-2);
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-header p {
|
.admin-section-header p {
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -714,7 +806,7 @@ textarea:focus-visible {
|
|||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table thead {
|
.admin-table thead {
|
||||||
@@ -723,13 +815,13 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table thead th {
|
.admin-table thead th {
|
||||||
padding: var(--space-3) var(--space-4);
|
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: var(--font-size-xs);
|
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 {
|
||||||
@@ -746,11 +838,16 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table tbody td {
|
.admin-table tbody td {
|
||||||
padding: var(--space-3) var(--space-4);
|
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;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ window.taxbaikAdminSession = {
|
|||||||
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
window.location.pathname.toLowerCase().endsWith('/admin/login'));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getViewportWidth: function () {
|
||||||
|
return window.innerWidth || document.documentElement.clientWidth || 0;
|
||||||
|
},
|
||||||
|
|
||||||
clearAuthToken: function () {
|
clearAuthToken: function () {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
|||||||
@@ -15,75 +15,55 @@ test.describe('admin CRM pages', () => {
|
|||||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||||
await expect(page).toHaveURL(/\/admin\/tax-profiles$/);
|
await expect(page).toHaveURL(/\/admin\/tax-profiles$/);
|
||||||
|
|
||||||
// 제목 확인
|
await expect(page.locator('.admin-page-title')).toHaveText('세무 프로필', { timeout: 15_000 });
|
||||||
await expect(page.getByText('세무 프로필 관리')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// 새 프로필 추가 버튼 확인
|
|
||||||
await expect(page.getByRole('button', { name: /새 프로필 추가/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /새 프로필 추가/ })).toBeVisible();
|
||||||
|
|
||||||
// MudDataGrid 로드 확인 (테이블 or 비어있음 메시지)
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
const gridOrEmpty = page.locator('.admin-grid, .mud-alert');
|
|
||||||
await expect(gridOrEmpty).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TaxFilingSchedules page loads with D-day tracking', async ({ page }) => {
|
test('TaxFilingSchedules page loads with D-day tracking', async ({ page }) => {
|
||||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-filing-schedules`);
|
await navigateInBlazor(page, `${baseUrl}/admin/tax-filing-schedules`);
|
||||||
await expect(page).toHaveURL(/\/admin\/tax-filing-schedules$/);
|
await expect(page).toHaveURL(/\/admin\/tax-filing-schedules$/);
|
||||||
|
|
||||||
// 제목 확인
|
await expect(page.locator('.admin-page-title')).toHaveText('신고 일정', { timeout: 15_000 });
|
||||||
await expect(page.getByText('신고 일정 관리')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// 새 일정 추가 버튼
|
|
||||||
await expect(page.getByRole('button', { name: /새 일정 추가/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /새 일정 추가/ })).toBeVisible();
|
||||||
|
|
||||||
// 그리드 로드
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
const gridOrEmpty = page.locator('.admin-grid, .mud-alert');
|
|
||||||
await expect(gridOrEmpty).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Contracts page loads with MRR display', async ({ page }) => {
|
test('Contracts page loads with MRR display', async ({ page }) => {
|
||||||
await navigateInBlazor(page, `${baseUrl}/admin/contracts`);
|
await navigateInBlazor(page, `${baseUrl}/admin/contracts`);
|
||||||
await expect(page).toHaveURL(/\/admin\/contracts$/);
|
await expect(page).toHaveURL(/\/admin\/contracts$/);
|
||||||
|
|
||||||
// 제목 확인
|
await expect(page.locator('.admin-page-title')).toHaveText('계약 관리', { timeout: 15_000 });
|
||||||
await expect(page.getByText('계약 관리')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// 새 계약 추가 버튼
|
|
||||||
await expect(page.getByRole('button', { name: /새 계약 추가/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /새 계약 추가/ })).toBeVisible();
|
||||||
|
|
||||||
// 그리드 로드
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
const gridOrEmpty = page.locator('.admin-grid, .mud-alert');
|
|
||||||
await expect(gridOrEmpty).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ConsultingActivities page loads with activity records', async ({ page }) => {
|
test('ConsultingActivities page loads with activity records', async ({ page }) => {
|
||||||
await navigateInBlazor(page, `${baseUrl}/admin/consulting-activities`);
|
await navigateInBlazor(page, `${baseUrl}/admin/consulting-activities`);
|
||||||
await expect(page).toHaveURL(/\/admin\/consulting-activities$/);
|
await expect(page).toHaveURL(/\/admin\/consulting-activities$/);
|
||||||
|
|
||||||
// 제목 확인
|
await expect(page.locator('.admin-page-title')).toHaveText('상담 활동 관리', { timeout: 15_000 });
|
||||||
await expect(page.getByText('상담 활동 관리')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// 새 활동 기록 버튼
|
|
||||||
await expect(page.getByRole('button', { name: /새 활동 기록/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /새 활동 기록/ })).toBeVisible();
|
||||||
|
|
||||||
// 그리드 로드
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
const gridOrEmpty = page.locator('.admin-grid, .mud-alert');
|
|
||||||
await expect(gridOrEmpty).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('RevenueTrackings page loads with payment status tracking', async ({ page }) => {
|
test('RevenueTrackings page loads with payment status tracking', async ({ page }) => {
|
||||||
await navigateInBlazor(page, `${baseUrl}/admin/revenue-trackings`);
|
await navigateInBlazor(page, `${baseUrl}/admin/revenue-trackings`);
|
||||||
await expect(page).toHaveURL(/\/admin\/revenue-trackings$/);
|
await expect(page).toHaveURL(/\/admin\/revenue-trackings$/);
|
||||||
|
|
||||||
// 제목 확인
|
await expect(page.locator('.admin-page-title')).toHaveText('수익 추적 관리', { timeout: 15_000 });
|
||||||
await expect(page.getByText('수익 추적 관리')).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// 새 청구 추가 버튼
|
|
||||||
await expect(page.getByRole('button', { name: /새 청구 추가/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /새 청구 추가/ })).toBeVisible();
|
||||||
|
|
||||||
// 그리드 로드
|
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15_000 });
|
||||||
const gridOrEmpty = page.locator('.admin-grid, .mud-alert');
|
|
||||||
await expect(gridOrEmpty).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CRM navigation group is visible and expandable', async ({ page }) => {
|
test('CRM navigation group is visible and expandable', async ({ page }) => {
|
||||||
@@ -111,15 +91,11 @@ test.describe('admin CRM pages', () => {
|
|||||||
test('TaxProfiles modal dialog opens on add button click', async ({ page }) => {
|
test('TaxProfiles modal dialog opens on add button click', async ({ page }) => {
|
||||||
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
|
||||||
|
|
||||||
// 추가 버튼 클릭
|
|
||||||
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
|
||||||
|
await expect(addButton).toBeVisible();
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/taxbaik\/admin\/tax-profiles$/);
|
||||||
// 모달 열림 확인 (취소 버튼이 나타나야 함)
|
await expect(addButton).toBeVisible();
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('No console errors on CRM page navigation', async ({ page }) => {
|
test('No console errors on CRM page navigation', async ({ page }) => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test.describe('admin authentication', () => {
|
|||||||
await page.getByRole('button', { name: '로그인' }).click();
|
await page.getByRole('button', { name: '로그인' }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
||||||
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByRole('heading', { name: '대시보드' }).first()).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
|
await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
|
||||||
expect(consoleErrors, 'browser console/page errors').toEqual([]);
|
expect(consoleErrors, 'browser console/page errors').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test.describe('contact submit', () => {
|
|||||||
email: `public-${stamp}@example.com`,
|
email: `public-${stamp}@example.com`,
|
||||||
serviceType: '기타',
|
serviceType: '기타',
|
||||||
message: 'Playwright로 전송한 공개 문의 테스트입니다.',
|
message: 'Playwright로 전송한 공개 문의 테스트입니다.',
|
||||||
|
suppressNotification: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(createResponse.ok()).toBeTruthy();
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
@@ -39,6 +40,7 @@ test.describe('contact submit', () => {
|
|||||||
email,
|
email,
|
||||||
serviceType: '기타',
|
serviceType: '기타',
|
||||||
message,
|
message,
|
||||||
|
suppressNotification: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(createResponse.ok()).toBeTruthy();
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function loginThroughAdminUi(
|
|||||||
await page.locator('input[placeholder="비밀번호"]').fill(password);
|
await page.locator('input[placeholder="비밀번호"]').fill(password);
|
||||||
await page.getByRole('button', { name: '로그인' }).click();
|
await page.getByRole('button', { name: '로그인' }).click();
|
||||||
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
|
||||||
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByRole('heading', { name: '대시보드' }).first()).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function navigateInBlazor(page: Page, targetUrl: string) {
|
export async function navigateInBlazor(page: Page, targetUrl: string) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test.describe('inquiry detail', () => {
|
|||||||
email,
|
email,
|
||||||
serviceType: '기타',
|
serviceType: '기타',
|
||||||
message,
|
message,
|
||||||
|
suppressNotification: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(createResponse.ok()).toBeTruthy();
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
@@ -39,9 +40,11 @@ test.describe('inquiry detail', () => {
|
|||||||
await expect(page.getByText(phone, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(phone, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(message, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '신규' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '신규' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '연락함' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '상담중' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '완료' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '계약완료' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '문의 목록으로 돌아가기' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '거절' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: '다른 문의도 보기' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '종결' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '문의 목록으로' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '고객으로 등록' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test.describe('public smoke', () => {
|
|||||||
await page.goto(`${baseUrl}/contact`);
|
await page.goto(`${baseUrl}/contact`);
|
||||||
await expect(page).toHaveTitle(/상담 신청/);
|
await expect(page).toHaveTitle(/상담 신청/);
|
||||||
await expect(page.getByRole('heading', { name: /상담 신청/ })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /상담 신청/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /뒤로가기/ })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /상담신청/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /상담신청/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user