Compare commits

..

2 Commits

49 changed files with 731 additions and 1887 deletions
+5 -4
View File
@@ -100,7 +100,6 @@ jobs:
- name: Package artifact
run: |
cp deploy_gb.sh ./publish/deploy_gb.sh
tar -czf taxbaik_deploy.tgz -C ./publish .
echo "✓ Package: $(du -sh taxbaik_deploy.tgz | cut -f1)"
@@ -164,9 +163,11 @@ jobs:
test -s "\$DEPLOY_DIR/appsettings.Production.json" \
|| { echo "FATAL: appsettings.Production.json 없음" >&2; exit 1; }
echo "--- [3/4] Green-Blue 배포 실행 ---"
chmod +x "\$DEPLOY_DIR/deploy_gb.sh"
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
echo "--- [3/5] 심볼릭 링크 전환 ---"
ln -sfn "\$DEPLOY_DIR" "\$DEPLOY_HOME/taxbaik_active"
echo "--- [4/5] 서비스 재시작 ---"
sudo /usr/bin/systemctl restart taxbaik
echo "--- [5/5] 헬스 체크 (최대 60초) ---"
ATTEMPTS=20
+26 -17
View File
@@ -564,24 +564,33 @@ ssh kjh2064@178.104.200.7
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
**무중단 Green-Blue 배포 아키텍처 (2026-06-30 적용 완료)**:
1. **프록시 레이어**: 포트 `5001`에서 영구 가동되는 초경량 .NET TCP 프록시([TaxBaik.Proxy])가 수신 대기합니다. Nginx는 `/taxbaik` 트래픽을 기존과 같이 `5001`로 중계합니다.
2. **동적 포트 스위칭**: 프록시는 요청이 들어올 때마다 `/home/kjh2064/taxbaik_port` 파일을 읽어 active 포트(5003 또는 5004)를 판단하고 트래픽을 포워딩합니다.
3. **배포 흐름 (`deploy_gb.sh`)**:
- Gitea Actions가 코드를 build/publish 후 압축하여 서버에 업로드합니다.
- 서버의 배포 스크립트([deploy_gb.sh])가 실행되어 현재 미사용 중인 예비 포트(Target Port: 5003 또는 5004)를 파악합니다.
- 예비 포트에서 새 .NET 웹 앱을 실행하고 `http://127.0.0.1:$target_port/taxbaik/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다.
- 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다.
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
**표준 배포 (현재)**:
1. `master` 브랜치에 push
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
**API 클라이언트 설정 (Green-Blue 대비)**:
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
- 기본값: `http://localhost:5001/taxbaik/api/`
- 배포 시 환경변수로 오버라이드 가능:
```bash
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
systemctl start taxbaik # 새 포트에 배포
```
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
**운영 규칙**:
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
- `rsync`로 직접 아티팩트를 올리지 않는다
- 배포 실패 시 CI 로그를 먼저 본다
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
**롤백**:
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
- 서버 파일을 수동으로 복구하지 않는다
- 롤백은 커밋 단위로 추적 가능해야 한다
### 3.4 서비스 파일 위치
```
@@ -1628,7 +1637,7 @@ curl http://127.0.0.1/taxbaik/admin/login
### E2E 테스트 & 반응형 검증
```bash
# 문의 폼 제출
curl -X POST http://taxbaik.com/taxbaik/contact \
curl -X POST http://178.104.200.7/taxbaik/contact \
-d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"
# 관리자 DB에서 확인
@@ -1667,7 +1676,7 @@ npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
**프로덕션 E2E 테스트**:
```bash
export E2E_BASE_URL="http://taxbaik.com/taxbaik"
export E2E_BASE_URL="http://178.104.200.7/taxbaik"
export E2E_ADMIN_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
@@ -1935,7 +1944,7 @@ else
2. **Actions run 생성 확인**
```powershell
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
$runs = Invoke-RestMethod -Headers $headers -Uri "http://gitea.taxbaik.com/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
$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`가 있어야 배포가 실제로 시작된 것이다.
+23 -130
View File
@@ -17,7 +17,7 @@
| 3.3 | [주요 Python 패키지](#33-주요-python-패키지-시스템) | 시스템/venv 패키지 구분 |
| 4 | [서비스 아키텍처](#4-서비스-아키텍처) | 포트 맵, Nginx 리버스 프록시 |
| 4.1 | [포트 맵](#41-포트-맵) | 22, 80, 2222, 3000, 5000, 5432 |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | 도메인 기반 가상 호스트 분기 (홈페이지, Gitea, Quant) |
| 4.2 | [Nginx 리버스 프록시](#42-nginx-리버스-프록시) | `/` → Gitea, `/quant/` → Blazor |
| 5 | [Gitea](#5-gitea) | Docker Compose 설정, 시크릿, 데이터 경로 |
| 5.1 | [Docker Compose](#51-docker-compose) | `gitea:1.26.4`, PG 연동 |
| 5.2 | [시크릿 관리](#52-시크릿-관리) | `/opt/stacks/gitea/.env` |
@@ -126,84 +126,16 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
### 4.2. Nginx 리버스 프록시
```nginx
# /etc/nginx/sites-available/taxbaik-domains.conf
# /etc/nginx/sites-enabled/gitea-ip.conf
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
server {
server_name taxbaik.com www.taxbaik.com;
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 512M;
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
location /admin {
return 301 $scheme://$host/taxbaik$request_uri;
}
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
location / {
proxy_pass http://127.0.0.1:5001/taxbaik/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
location /taxbaik {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 2. Gitea (gitea.taxbaik.com)
server {
server_name gitea.taxbaik.com;
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 3. QuantEngine (quant.taxbaik.com)
server {
server_name quant.taxbaik.com;
location / {
# QuantEngine Blazor Web App
location /quant/ {
proxy_pass http://127.0.0.1:5000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -215,64 +147,25 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name taxbaik.com www.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = gitea.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name gitea.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = quant.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name quant.taxbaik.com;
return 404; # managed by Certbot
# Gitea (기본)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
}
```
**라우팅 요약**:
- `http://taxbaik.com/` 또는 `http://www.taxbaik.com/` → TaxBaik 홈페이지 (내부 proxy: `http://127.0.0.1:5001/taxbaik/`)
- `http://gitea.taxbaik.com/` → Gitea Web UI (내부 proxy: `http://127.0.0.1:3000`)
- `http://quant.taxbaik.com/` → QuantEngine Blazor Admin (내부 proxy: `http://127.0.0.1:5000/`)
- `ssh://gitea.taxbaik.com:2222` → Gitea Git SSH
- `http://178.104.200.7/` → Gitea Web UI
- `http://178.104.200.7/quant/` → QuantEngine Blazor Admin
- `ssh://178.104.200.7:2222` → Gitea Git SSH
## 5. Gitea
@@ -491,7 +384,7 @@ ClientAliveCountMax 2
| **CI Runner** | Synology Act Runner | 6× `act_runner:latest` (Docker) |
| **DB** | SQLite (파일 기반) | PostgreSQL 18 + SQLite (하이브리드) |
| **웹 Admin** | 없음 | QuantEngine Blazor (.NET 10, MudBlazor) |
| **리버스 프록시** | Synology 내장 | Nginx (도메인 기반 분기 - 홈페이지, Gitea, Quant) |
| **리버스 프록시** | Synology 내장 | Nginx (`/` → Gitea, `/quant/` → Blazor) |
| **보안** | DSM 방화벽 | fail2ban + SSH 공개키 + 서비스 로컬바인드 |
| **시크릿 관리** | `.secrets/kis_real.env` | `/opt/stacks/gitea/.env` |
| **OS** | Synology DSM 7.x | Ubuntu 26.04 LTS |
+5 -9
View File
@@ -38,17 +38,13 @@ sudo systemctl enable taxbaik
### 4. Nginx 설정
```bash
# Nginx 도메인 기반 가상 호스트 설정 복사
sudo cp deploy/nginx-taxbaik-domains.conf /etc/nginx/sites-available/taxbaik-domains.conf
# 현재 Nginx 설정 확인
sudo cat /etc/nginx/sites-available/default | head -30
# 기존 설정(IP 기반 및 default) 활성화 해제
sudo rm -f /etc/nginx/sites-enabled/default
sudo rm -f /etc/nginx/sites-enabled/gitea-ip.conf
# location 블록 추가 (또는 기존 설정에 병합)
sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf
# 새 설정 활성화 (심링크 생성)
sudo ln -sfn /etc/nginx/sites-available/taxbaik-domains.conf /etc/nginx/sites-enabled/taxbaik-domains.conf
# 설정 문법 테스트 및 Nginx 서비스 리로드
# 테스트 및 재로드
sudo nginx -t
sudo systemctl reload nginx
```
-43
View File
@@ -522,46 +522,3 @@ Todo:
- WBS-UX-03/04 구현 완료
- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
---
## ── 홈페이지 · 어드민 · 포털 프리미엄 UX/UI 개편 (2026-06-30) ──────────────────
## WBS-UX-05 홈페이지 프리미엄 UI 및 마이크로 인터랙션
목표: 홈페이지 디자인을 극도로 모던하고 신뢰성 있는 프리미엄 스타일로 전면 개편한다.
성공 기준:
- Hero 섹션에 유려한 배경 그라데이션 및 부드러운 CSS 애니메이션 효과 적용
- 서비스 카드에 섀도우 및 보더 트랜지션, 골드/그린 그라데이션 호버 이펙트 추가
- 신뢰도 스트립 카드에 입체감 및 돋보이는 레이아웃 설계
- Noto Sans KR 외에 Outfit/Inter 등의 보조 영문 폰트 결합으로 타이포그래피 고급화
Todo:
- [x] `site.css` 내 Hero 섹션 그라데이션 및 CSS 애니메이션 보강
- [x] 서비스 카드 및 신뢰도 스트립 컴포넌트 프리미엄 스타일로 개편
- [x] 홈페이지 폰트 스택 확장 및 메인 레이아웃 적용
## WBS-PORTAL-01 고객 포털 UI/UX 고도화 및 글래스모피즘
목표: 고객 마이 포털 화면을 미려하고 현대적인 글래스모피즘 디자인으로 개편하여 이용 가치를 극대화한다.
성공 기준:
- 포털 메인 대시보드 카드를 Glassmorphism 스타일(blur, semi-transparent border)로 변경
- 세무 신고 현황 테이블 및 상담 이력 타임라인 컴포넌트의 모던 디자인화
Todo:
- [x] `site.css` 내 포털 전용 모던 글래스모피즘 클래스군 추가
- [x] `Portal/Index.cshtml` 레이아웃 및 컴포넌트 UI 고도화
## WBS-MAINT-02 코드 품질 및 경고 결함 차단
목표: 빌드 컴파일 타임 경고(Warnings)를 0으로 유지하여 미래 코드 결함을 방지한다.
성공 기준:
- `dotnet build` 수행 시 경고 0개 달성
Todo:
- [x] `CustomAuthenticationStateProvider.cs` Nullable 경고 수정
- [x] `Dashboard.razor` 미사용 변수 제거 및 UI 연계 바인딩 처리
@@ -27,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<RevenueTrackingService>();
services.AddScoped<TelegramReportService>();
services.AddScoped<PortalUserService>();
services.AddScoped<CommonCodeService>();
return services;
}
}
@@ -1,20 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Application.Services;
public class CommonCodeService(ICommonCodeRepository commonCodeRepository)
{
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
{
return await commonCodeRepository.GetByGroupAsync(codeGroup, ct);
}
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
return await commonCodeRepository.GetAllActiveAsync(ct);
}
}
@@ -37,10 +37,7 @@ public class TaxProfileService(ITaxProfileRepository repository)
public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod,
DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default)
{
var profile = await repository.GetByIdAsync(profileId, ct);
if (profile == null)
throw new ValidationException("세무 프로필을 찾을 수 없습니다.");
var profile = new TaxProfile { Id = profileId };
if (!string.IsNullOrWhiteSpace(businessType))
profile.BusinessType = businessType.Trim();
if (!string.IsNullOrWhiteSpace(accountingMethod))
-10
View File
@@ -1,10 +0,0 @@
namespace TaxBaik.Domain.Entities;
public class CommonCode
{
public string CodeGroup { get; set; } = string.Empty;
public string CodeValue { get; set; } = string.Empty;
public string CodeName { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -1,12 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
namespace TaxBaik.Domain.Interfaces;
public interface ICommonCodeRepository
{
Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default);
Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
}
@@ -5,7 +5,6 @@ using TaxBaik.Domain.Entities;
public interface ITaxProfileRepository
{
Task<int> CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<TaxProfile?> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default);
Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default);
@@ -27,7 +27,6 @@ public static class DependencyInjection
services.AddScoped<IConsultingActivityRepository, ConsultingActivityRepository>();
services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<IRevenueTrackingRepository, RevenueTrackingRepository>();
services.AddScoped<ICommonCodeRepository, CommonCodeRepository>();
return services;
}
@@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Infrastructure.Repositories;
public class CommonCodeRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ICommonCodeRepository
{
public async Task<IEnumerable<CommonCode>> GetByGroupAsync(string codeGroup, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<CommonCode>(
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
FROM common_codes
WHERE code_group = @CodeGroup AND is_active = TRUE
ORDER BY sort_order",
new { CodeGroup = codeGroup });
}
public async Task<IEnumerable<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<CommonCode>(
@"SELECT code_group as CodeGroup, code_value as CodeValue, code_name as CodeName, sort_order as SortOrder, is_active as IsActive
FROM common_codes
WHERE is_active = TRUE
ORDER BY code_group, sort_order");
}
}
@@ -20,17 +20,6 @@ public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : Base
profile);
}
public async Task<TaxProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<TaxProfile>(
@"SELECT id, client_id, business_registration, business_type, establishment_date,
annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date,
next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at
FROM tax_profiles WHERE id = @Id",
new { Id = id });
}
public async Task<IEnumerable<TaxProfile>> GetAllAsync(CancellationToken cancellationToken = default)
{
using var conn = Conn();
-93
View File
@@ -1,93 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private const string PortFile = "/home/kjh2064/taxbaik_port";
private static int _fallbackPort = 5003;
static async Task Main(string[] args)
{
// Allow setting fallback port via args
if (args.Length > 0 && int.TryParse(args[0], out var port))
{
_fallbackPort = port;
}
var listener = new TcpListener(IPAddress.Loopback, 5001);
listener.Start();
Console.WriteLine($"[TaxBaik Proxy] Listening on 127.0.0.1:5001 (Forwarding to target in {PortFile})");
while (true)
{
try
{
var client = await listener.AcceptTcpClientAsync();
_ = HandleClientAsync(client);
}
catch (Exception ex)
{
Console.WriteLine($"[TaxBaik Proxy] Accept error: {ex.Message}");
await Task.Delay(100);
}
}
}
private static int GetTargetPort()
{
try
{
if (File.Exists(PortFile))
{
var content = File.ReadAllText(PortFile).Trim();
if (int.TryParse(content, out var port) && port > 1024 && port < 65535)
{
return port;
}
}
}
catch { }
return _fallbackPort;
}
private static async Task HandleClientAsync(TcpClient client)
{
client.NoDelay = true;
int targetPort = GetTargetPort();
using var backend = new TcpClient();
backend.NoDelay = true;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await backend.ConnectAsync(IPAddress.Loopback, targetPort, cts.Token);
}
catch (Exception ex)
{
Console.WriteLine($"[TaxBaik Proxy] Failed to connect to backend on port {targetPort}: {ex.Message}");
client.Close();
return;
}
try
{
using var clientStream = client.GetStream();
using var backendStream = backend.GetStream();
var toBackend = clientStream.CopyToAsync(backendStream);
var toClient = backendStream.CopyToAsync(clientStream);
await Task.WhenAny(toBackend, toClient);
}
catch { }
finally
{
client.Close();
backend.Close();
}
}
}
-10
View File
@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
@* WASM 기반(M3) 검증용 컴포넌트. 라우팅/렌더모드 전면 적용은 M4에서 처리한다. *@
@rendermode InteractiveWebAssembly
<MudPaper Class="pa-6 ma-4" Elevation="2">
<MudText Typo="Typo.h5" GutterBottom="true">WebAssembly 렌더 모드 점검</MudText>
<MudText Typo="Typo.body2" Class="mb-4">이 컴포넌트가 클릭에 반응하면 Interactive WebAssembly 기반이 정상 동작하는 것입니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Increment">카운트: @count</MudButton>
</MudPaper>
@code {
private int count;
private void Increment() => count++;
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// MudBlazor (WASM 측 인터랙티브 컴포넌트용)
builder.Services.AddMudServices();
// API 호출용 HttpClient — 호스트 base(`/taxbaik/`) 기준
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TaxBaik.WasmClient</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MudBlazor
@using TaxBaik.WasmClient
@using static Microsoft.AspNetCore.Components.Web.RenderMode
+11 -8
View File
@@ -32,6 +32,9 @@
</div>
</div>
<MudThemeProvider @bind-IsDarkMode="isDarkMode" Theme="mudTheme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/admin-session.js"></script>
@@ -77,49 +80,49 @@
},
LayoutProperties = new LayoutProperties()
{
DefaultBorderRadius = "6px"
DefaultBorderRadius = "8px"
},
Typography = new Typography()
{
Default = new Default()
{
FontSize = ".8125rem",
FontSize = ".875rem",
FontWeight = 400,
LineHeight = 1.5
},
H1 = new H1()
{
FontSize = "1.75rem",
FontSize = "2.5rem",
FontWeight = 600,
LineHeight = 1.2
},
H2 = new H2()
{
FontSize = "1.5rem",
FontSize = "2rem",
FontWeight = 600,
LineHeight = 1.3
},
H3 = new H3()
{
FontSize = "1.25rem",
FontSize = "1.75rem",
FontWeight = 600,
LineHeight = 1.3
},
H4 = new H4()
{
FontSize = "1.1rem",
FontSize = "1.5rem",
FontWeight = 600,
LineHeight = 1.4
},
H5 = new H5()
{
FontSize = "0.95rem",
FontSize = "1.25rem",
FontWeight = 500,
LineHeight = 1.4
},
H6 = new H6()
{
FontSize = "0.85rem",
FontSize = "1rem",
FontWeight = 500,
LineHeight = 1.5
}
@@ -3,10 +3,6 @@
@inject IJSRuntime JS
@implements IDisposable
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout Class="admin-shell">
<MudAppBar Elevation="0" Class="admin-topbar">
<MudIconButton Icon="@Icons.Material.Filled.Menu"
@@ -14,9 +10,9 @@
Edge="Edge.Start"
Class="admin-menu-button"
OnClick="@ToggleDrawer" />
<div class="admin-topbar-title" style="display: flex; align-items: center; gap: 8px;">
<MudText Typo="Typo.body2" Class="font-weight-bold" Style="color: var(--primary-color);">[TaxBaik]</MudText>
<MudText Typo="Typo.body2" Style="font-weight: bold; color: #1E293B;">세무회계 관리 대시보드</MudText>
<div class="admin-topbar-title">
<MudText Typo="Typo.caption" Color="Color.Secondary">TaxBaik Admin</MudText>
<MudText Typo="Typo.h6">세무회계 관리 대시보드</MudText>
</div>
<MudSpacer />
@@ -22,22 +22,14 @@
</MudButton>
</section>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface" Elevation="0">
@if (announcements is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!FilteredAnnouncements.Any())
else if (!announcements.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.Campaign" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">검색 조건에 맞는 공지사항이 없습니다.</MudText>
</div>
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
}
else
{
@@ -53,7 +45,7 @@
</tr>
</thead>
<tbody>
@foreach (var item in FilteredAnnouncements)
@foreach (var item in announcements)
{
<tr>
<td>@item.Title</td>
@@ -94,9 +86,6 @@
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
</MudText>
}
</MudPaper>
@@ -105,12 +94,6 @@
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Announcement>? announcements;
private string searchQuery = "";
private IEnumerable<Announcement> FilteredAnnouncements => announcements?
.Where(a => string.IsNullOrEmpty(searchQuery) ||
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -21,8 +21,8 @@
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@@ -32,24 +32,8 @@
}
</MudSelect>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
</MudTabPanel>
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
@if (string.IsNullOrWhiteSpace(model.Content))
{
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
}
else
{
@((MarkupString)model.Content)
}
</div>
</MudTabPanel>
</MudTabs>
<MudTextField @bind-Value="model.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
@@ -32,8 +32,8 @@ else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudTextField @bind-Value="model.Title" Label="제목"
Variant="Variant.Outlined" Class="mb-4" Required="true" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@@ -43,24 +43,8 @@ else
}
</MudSelect>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
</MudTabPanel>
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
@if (string.IsNullOrWhiteSpace(model.Content))
{
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
}
else
{
@((MarkupString)model.Content)
}
</div>
</MudTabPanel>
</MudTabs>
<MudTextField @bind-Value="model.Content" Label="본문"
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
@@ -15,19 +15,14 @@
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
</section>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface mb-4" Elevation="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
</MudStack>
</MudPaper>
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Title" Title="제목" />
<PropertyColumn Property="x => x.IsPublished" Title="발행">
@@ -59,18 +54,12 @@
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
private string searchQuery = "";
private bool isLoading = true;
private int currentPage = 1;
private int totalPages = 1;
private int totalPosts = 0;
private const int PageSize = 20;
private IEnumerable<TaxBaik.Domain.Entities.BlogPost> FilteredPosts => posts?
.Where(p => string.IsNullOrEmpty(searchQuery) ||
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
(p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) ?? Enumerable.Empty<TaxBaik.Domain.Entities.BlogPost>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
+109 -143
View File
@@ -21,133 +21,116 @@
</MudText>
}
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-contract">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 계약 추가
</MudButton>
</section>
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (contracts.Count == 0)
{
<MudAlert Severity="Severity.Info">
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
계약이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="Contract"
Items="@contracts"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedContract"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
<TemplateColumn Title="계약기간">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy-MM-dd")
@if (context.Item.EndDate.HasValue)
{
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="상태">
<CellTemplate>
@{
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
}
@if (isActive)
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "계약 상세 정보" : "새 계약 추가")</MudText>
@if (isEditMode)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
새로 작성
</MudButton>
}
</div>
<MudForm @ref="form">
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
@foreach (var client in clients)
<MudPaper Class="admin-surface" Elevation="0">
@if (contracts is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (contracts.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">
<MudIcon Icon="@Icons.Material.Filled.Description" Class="me-2" />
계약이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="Contract"
Items="@contracts"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</MudSelect>
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" />
<div class="d-flex justify-end gap-2">
@if (isEditMode)
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ContractNumber" Title="계약번호" />
<PropertyColumn Property="x => x.ServiceType" Title="서비스 유형" />
<PropertyColumn Property="x => x.MonthlyFee" Title="월 수수료" Format="C" />
<TemplateColumn Title="계약기간">
<CellTemplate>
@context.Item.StartDate.ToString("yyyy-MM-dd")
@if (context.Item.EndDate.HasValue)
{
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteContract(selectedContract?.Id ?? 0))">삭제</MudButton>
<span>~@context.Item.EndDate.Value.ToString("yyyy-MM-dd")</span>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="상태">
<CellTemplate>
@{
var isActive = !context.Item.EndDate.HasValue || context.Item.EndDate.Value >= DateTime.Today;
}
@if (isActive)
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">활성</MudChip>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveContract" id="btn-save-contract">저장</MudButton>
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">만료</MudChip>
}
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
OnClick="@(async () => await DeleteContract(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
<!-- Create Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 계약 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int?" @bind-Value="contractForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="contractForm.ContractNumber" Label="계약번호" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudSelect T="string" @bind-Value="contractForm.ServiceType" Label="서비스 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("개인 기장대리")">개인 기장대리</MudSelectItem>
<MudSelectItem Value="@("법인 기장대리")">법인 기장대리</MudSelectItem>
<MudSelectItem Value="@("세무조정 대행")">세무조정 대행</MudSelectItem>
<MudSelectItem Value="@("양도세 신고대리")">양도세 신고대리</MudSelectItem>
<MudSelectItem Value="@("상속·증여 자문")">상속·증여 자문</MudSelectItem>
<MudSelectItem Value="@("세무조사 대응")">세무조사 대응</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="contractForm.StartDate" Label="계약 시작일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="decimal?" @bind-Value="contractForm.MonthlyFee" Label="월 수수료" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveContract">저장</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
@@ -158,8 +141,7 @@ else
private Dictionary<int, string> clientMap = new();
private decimal mrr = 0;
private MudForm? form;
private bool isEditMode;
private Contract? selectedContract;
private bool isDialogOpen;
private ContractForm contractForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -172,7 +154,6 @@ else
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
@@ -195,30 +176,14 @@ else
}
}
private void PrepareCreate()
private void OpenCreateDialog()
{
selectedContract = null;
isEditMode = false;
contractForm = new ContractForm
{
ClientId = clients.FirstOrDefault()?.Id,
StartDate = DateTime.Today
};
}
private void OnRowSelected(Contract contract)
{
if (contract == null) return;
selectedContract = contract;
isEditMode = true;
contractForm = new ContractForm
{
ClientId = contract.ClientId,
ContractNumber = contract.ContractNumber,
ServiceType = contract.ServiceType,
StartDate = contract.StartDate,
MonthlyFee = contract.MonthlyFee
};
isDialogOpen = true;
}
private async Task SaveContract()
@@ -246,7 +211,7 @@ else
if (newId > 0)
{
Snackbar.Add("계약이 추가되었습니다.", Severity.Success);
PrepareCreate();
CloseDialog();
await LoadData();
}
}
@@ -274,10 +239,6 @@ else
{
await ContractClient.DeleteAsync(id);
Snackbar.Add("계약이 삭제되었습니다.", Severity.Success);
if (selectedContract?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -286,13 +247,18 @@ else
}
}
private void CloseDialog()
{
isDialogOpen = false;
contractForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class ContractForm
{
public int? ClientId { get; set; }
@@ -17,58 +17,49 @@
</MudButton>
</section>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@errorMessage</MudAlert>
}
@if (isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<!-- Metrics Grid -->
<!-- Metrics Grid - Pure HTML div instead of MudGrid to ensure proper layout -->
<div class="admin-metric-grid">
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">이번달 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--primary-dark);">@summary.ThisMonthInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--primary-color);">💬</span>
<div class="admin-metric-card accent-blue cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">이번달 문의</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #1565c0;">@summary.ThisMonthInquiries</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #1976d2;">💬</span>
</div>
<span class="admin-metric-card-caption">월간 상담 유입 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">월간 상담 유입 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">신규 문의</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--tertiary-dark);">@summary.NewInquiries</span>
<span class="admin-metric-card-icon" style="color: var(--tertiary-color);">⚠️</span>
<div class="admin-metric-card accent-amber cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/inquiries?status=new"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">신규 문의</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #e65100;">@summary.NewInquiries</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #f57c00;">⚠️</span>
</div>
<span class="admin-metric-card-caption">처리 대기 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">처리 대기 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">전체 포스트</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: #455a64;">@summary.TotalPosts</span>
<span class="admin-metric-card-icon" style="color: #607d8b;">📄</span>
<div class="admin-metric-card accent-slate cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">전체 포스트</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #455a64;">@summary.TotalPosts</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #607d8b;">📄</span>
</div>
<span class="admin-metric-card-caption">콘텐츠 자산 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">콘텐츠 자산 (클릭 시 이동)</span>
</div>
</div>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))'>
<div class="admin-metric-card-body">
<span class="admin-metric-card-label">발행된 포스트</span>
<div class="admin-metric-card-value-row">
<span class="admin-metric-card-value" style="color: var(--secondary-dark);">@summary.PublishedPosts</span>
<span class="admin-metric-card-icon" style="color: var(--secondary-color);">🌐</span>
<div class="admin-metric-card accent-green cursor-pointer" @onclick='(() => Nav.NavigateTo("/taxbaik/admin/blog"))' style="cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
<span style="font-size: 0.75rem; color: #999; text-transform: uppercase; font-weight: 600;">발행된 포스트</span>
<div style="display: flex; justify-content: space-between; align-items: center; flex: 1;">
<span style="font-size: 2rem; font-weight: 700; color: #2e7d32;">@summary.PublishedPosts</span>
<span style="font-size: 2.5rem; opacity: 0.15; color: #388e3c;">🌐</span>
</div>
<span class="admin-metric-card-caption">검색 노출 대상 (클릭 시 이동)</span>
<span style="font-size: 0.9rem; color: #666;">검색 노출 대상 (클릭 시 이동)</span>
</div>
</div>
</div>
@@ -22,21 +22,16 @@
</MudButton>
</section>
<div class="d-flex pa-4 gap-4 align-center">
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
</div>
<MudPaper Class="admin-surface" Elevation="0">
@if (faqs is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (!FilteredFaqs.Any())
else if (!faqs.Any())
{
<div class="pa-6 text-center">
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
</div>
}
else
@@ -44,7 +39,7 @@
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th style="width:110px;">순서</th>
<th style="width:60px;">순서</th>
<th>질문</th>
<th style="width:130px;">카테고리</th>
<th style="width:90px;">상태</th>
@@ -52,15 +47,11 @@
</tr>
</thead>
<tbody>
@foreach (var item in FilteredFaqs)
@foreach (var item in faqs)
{
<tr>
<td>
<div class="d-flex align-center justify-start gap-1">
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropDown" Size="Size.Small" OnClick="@(() => MoveDownAsync(item))" Style="padding:2px;" Dense="true" />
</div>
<td class="text-center">
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
</td>
<td>
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
@@ -86,10 +77,10 @@
<td>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
수정
수정
</MudButton>
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
삭제
삭제
</MudButton>
</MudButtonGroup>
</td>
@@ -98,7 +89,7 @@
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
</MudText>
}
</MudPaper>
@@ -108,13 +99,6 @@
private Task<AuthenticationState>? AuthStateTask { get; set; }
private List<Faq>? faqs;
private string searchQuery = "";
private IEnumerable<Faq> FilteredFaqs => faqs?
.Where(f => string.IsNullOrEmpty(searchQuery) ||
f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -136,7 +120,7 @@
{
try
{
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
faqs = (await FaqClient.GetAllAsync()).ToList();
}
catch (Exception ex)
{
@@ -145,66 +129,6 @@
}
}
private async Task MoveUpAsync(Faq item)
{
if (faqs == null) return;
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
var index = sorted.IndexOf(item);
if (index <= 0) return;
var prev = sorted[index - 1];
var temp = item.SortOrder;
item.SortOrder = prev.SortOrder;
prev.SortOrder = temp;
if (item.SortOrder == prev.SortOrder)
{
prev.SortOrder = item.SortOrder + 1;
}
try
{
await FaqClient.UpdateAsync(item.Id, item);
await FaqClient.UpdateAsync(prev.Id, prev);
Snackbar.Add("순서가 상향되었습니다.", Severity.Success);
await LoadAsync();
}
catch (Exception ex)
{
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
}
}
private async Task MoveDownAsync(Faq item)
{
if (faqs == null) return;
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
var index = sorted.IndexOf(item);
if (index < 0 || index >= sorted.Count - 1) return;
var next = sorted[index + 1];
var temp = item.SortOrder;
item.SortOrder = next.SortOrder;
next.SortOrder = temp;
if (item.SortOrder == next.SortOrder)
{
next.SortOrder = item.SortOrder + 1;
}
try
{
await FaqClient.UpdateAsync(item.Id, item);
await FaqClient.UpdateAsync(next.Id, next);
Snackbar.Add("순서가 하향되었습니다.", Severity.Success);
await LoadAsync();
}
catch (Exception ex)
{
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
}
}
private async Task DeleteAsync(Faq item)
{
var confirmed = await DialogService.ShowMessageBox(
@@ -14,163 +14,141 @@
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 마감일과 처리 상태를 한 화면에서 관리합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-schedule">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenCreateDialog"
StartIcon="@Icons.Material.Filled.Add">
새 일정 추가
</MudButton>
</section>
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (schedules.Count == 0)
{
<MudAlert Severity="Severity.Info">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
신고 일정이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="TaxFilingSchedule"
Items="@schedules"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedSchedule"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
<TemplateColumn Title="마감일">
<CellTemplate>
@{
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
}
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
@context.Item.DueDate.ToString("yyyy-MM-dd")
@if (daysLeft >= 0)
{
<span class="ms-1">(D-@daysLeft)</span>
}
else
{
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
}
</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
<TemplateColumn Title="상태">
<CellTemplate>
@if (context.Item.Status == "completed")
{
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
}
else
{
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.Status != "completed")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
Title="완료" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
Title="삭제" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "신고 일정 상세" : "새 신고 일정 추가")</MudText>
@if (isEditMode)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
새로 작성
</MudButton>
}
</div>
<MudForm @ref="form">
<MudSelect T="int?"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="@true"
Class="mb-3"
RequiredError="고객을 선택하세요."
Disabled="@isEditMode">
@foreach (var client in clients)
<MudPaper Class="admin-surface" Elevation="0">
@if (schedules is null)
{
<MudProgressLinear Indeterminate="true" />
}
else if (schedules.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">
<MudIcon Icon="@Icons.Material.Filled.EventBusy" Class="me-2" />
신고 일정이 없습니다.
</MudAlert>
}
else
{
<MudDataGrid T="TaxFilingSchedule"
Items="@schedules"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</MudSelect>
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem>
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem>
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem>
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem>
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem>
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem>
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem>
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true" />
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-4" Required="true" />
<div class="d-flex justify-end gap-2">
@if (isEditMode && selectedSchedule?.Status != "completed")
{
<MudButton Variant="Variant.Outlined" Color="Color.Success" OnClick="@(async () => await CompleteSchedule(selectedSchedule?.Id ?? 0))">완료 처리</MudButton>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingType" Title="신고 유형" />
<TemplateColumn Title="마감일">
<CellTemplate>
@{
var daysLeft = (context.Item.DueDate.Date - DateTime.Today).Days;
var statusColor = daysLeft < 0 ? Color.Error : daysLeft <= 7 ? Color.Warning : Color.Success;
}
@if (isEditMode)
<MudChip Size="Size.Small" Color="@statusColor" Variant="Variant.Filled">
@context.Item.DueDate.ToString("yyyy-MM-dd")
@if (daysLeft >= 0)
{
<span class="ms-1">(D-@daysLeft)</span>
}
else
{
<span class="ms-1">(마감 @Math.Abs(daysLeft)일 경과)</span>
}
</MudChip>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.FilingYear" Title="신고연도" />
<TemplateColumn Title="상태">
<CellTemplate>
@if (context.Item.Status == "completed")
{
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteSchedule(selectedSchedule?.Id ?? 0))">삭제</MudButton>
<MudChip Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">완료</MudChip>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSchedule" id="btn-save-schedule">저장</MudButton>
<MudChip Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">대기</MudChip>
}
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
@if (context.Item.Status != "completed")
{
<MudIconButton Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
OnClick="@(async () => await CompleteSchedule(context.Item.Id))"
Title="완료" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(async () => await DeleteSchedule(context.Item.Id))"
Title="삭제" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudPaper>
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">새 신고 일정 추가</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int?"
@bind-Value="scheduleForm.ClientId"
Label="고객"
Required="true"
Variant="Variant.Outlined"
FullWidth="true"
Class="mb-4"
RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="scheduleForm.FilingType" Label="신고 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("종합소득세")">종합소득세</MudSelectItem>
<MudSelectItem Value="@("부가가치세")">부가가치세</MudSelectItem>
<MudSelectItem Value="@("법인세")">법인세</MudSelectItem>
<MudSelectItem Value="@("원천세")">원천세</MudSelectItem>
<MudSelectItem Value="@("종합부동산세")">종합부동산세</MudSelectItem>
<MudSelectItem Value="@("양도소득세")">양도소득세</MudSelectItem>
<MudSelectItem Value="@("상속·증여세")">상속·증여세</MudSelectItem>
<MudSelectItem Value="@("세무조정")">세무조정</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="scheduleForm.DueDate" Label="마감일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
<MudNumericField T="int" @bind-Value="scheduleForm.FilingYear" Label="신고연도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveSchedule">저장</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
@@ -180,8 +158,7 @@ else
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private MudForm? form;
private bool isEditMode;
private TaxFilingSchedule? selectedSchedule;
private bool isDialogOpen;
private TaxFilingScheduleForm scheduleForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -194,7 +171,6 @@ else
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
@@ -216,30 +192,15 @@ else
}
}
private void PrepareCreate()
private void OpenCreateDialog()
{
selectedSchedule = null;
isEditMode = false;
scheduleForm = new TaxFilingScheduleForm
{
FilingYear = DateTime.Now.Year,
DueDate = DateTime.Today,
ClientId = clients.FirstOrDefault()?.Id
};
}
private void OnRowSelected(TaxFilingSchedule schedule)
{
if (schedule == null) return;
selectedSchedule = schedule;
isEditMode = true;
scheduleForm = new TaxFilingScheduleForm
{
ClientId = schedule.ClientId,
FilingType = schedule.FilingType,
DueDate = schedule.DueDate,
FilingYear = schedule.FilingYear
};
isDialogOpen = true;
}
private async Task SaveSchedule()
@@ -266,7 +227,7 @@ else
if (newId > 0)
{
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
PrepareCreate();
CloseDialog();
await LoadData();
}
else
@@ -286,10 +247,6 @@ else
{
await TaxFilingClient.MarkCompletedAsync(id);
Snackbar.Add("신고 일정이 완료 처리되었습니다.", Severity.Success);
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -315,10 +272,6 @@ else
{
await TaxFilingClient.DeleteAsync(id);
Snackbar.Add("신고 일정이 삭제되었습니다.", Severity.Success);
if (selectedSchedule?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -327,13 +280,18 @@ else
}
}
private void CloseDialog()
{
isDialogOpen = false;
scheduleForm = new();
}
private static string GetClientDisplayName(Client client)
=> !string.IsNullOrWhiteSpace(client.CompanyName)
? client.CompanyName
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxFilingScheduleForm
{
public int? ClientId { get; set; }
@@ -2,7 +2,6 @@
@using TaxBaik.Web.Services.AdminClients
@inject ITaxProfileBrowserClient TaxProfileClient
@inject IClientBrowserClient ClientClient
@inject ICommonCodeBrowserClient CommonCodeClient
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@attribute [Authorize]
@@ -15,7 +14,7 @@
<MudText Typo="Typo.h4" Class="admin-page-title">세무 프로필</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세무 프로필, 신고 일정, 위험도 추적</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="PrepareCreate" StartIcon="@Icons.Material.Filled.Add" id="btn-add-profile">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenCreateDialog" StartIcon="@Icons.Material.Filled.Add">
새 프로필 추가
</MudButton>
</section>
@@ -24,110 +23,99 @@
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (profiles.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">세무 프로필이 없습니다.</MudAlert>
}
else
{
<MudGrid Spacing="2" Class="mt-2">
<!-- Left: Dense Grid List -->
<MudItem XS="12" MD="8">
@if (profiles.Count == 0)
{
<MudAlert Severity="Severity.Info">세무 프로필이 없습니다.</MudAlert>
}
else
{
<MudDataGrid T="TaxProfile"
Items="@profiles"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
SelectedItem="@selectedProfile"
SelectedItemChanged="OnRowSelected"
Class="admin-grid">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
@clientName
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
<TemplateColumn Title="위험도">
<CellTemplate>
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
@context.Item.TaxRiskLevel
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 신고">
<CellTemplate>
@if (context.Item.NextFilingDueDate.HasValue)
{
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudItem>
<!-- Right: Detail Form Panel (Inline Editor) -->
<MudItem XS="12" MD="4">
<MudPaper Class="pa-4 admin-surface admin-editor-panel" Elevation="0" Style="border: 1px solid var(--border-color); min-height: 400px;">
<div class="d-flex align-center justify-space-between mb-4">
<MudText Typo="Typo.h6" Class="font-weight-bold">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
@if (isEditMode)
<MudDataGrid T="TaxProfile"
Items="@profiles"
Dense="true"
Hover="true"
Striped="true"
Virtualize="true"
RowsPerPage="30"
Class="admin-grid mt-4">
<Columns>
<PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
<TemplateColumn Title="고객">
<CellTemplate>
@if (clientMap.TryGetValue(context.Item.ClientId, out var clientName))
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="PrepareCreate">
새로 작성
</MudButton>
<MudLink Href="@($"/taxbaik/admin/clients/{context.Item.ClientId}")" Color="Color.Primary">
@clientName
</MudLink>
}
</div>
<MudForm @ref="form">
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" RequiredError="고객을 선택하세요." Disabled="@isEditMode">
@foreach (var client in clients)
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" Required="true">
@foreach (var type in businessTypes)
{
<MudSelectItem Value="@type.CodeValue">@type.CodeName</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3">
@foreach (var level in riskLevels)
{
<MudSelectItem Value="@level.CodeValue">@level.CodeName</MudSelectItem>
}
</MudSelect>
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="@true" Class="mb-3" />
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="@true" Lines="3" Class="mb-4" />
<div class="d-flex justify-end gap-2">
@if (isEditMode)
{
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="@(async () => await DeleteProfile(selectedProfile?.Id ?? 0))">삭제</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveProfile" id="btn-save-profile">저장</MudButton>
</div>
</MudForm>
</MudPaper>
</MudItem>
</MudGrid>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.BusinessType" Title="사업 유형" />
<TemplateColumn Title="위험도">
<CellTemplate>
<MudChip Size="Size.Small" Color="@GetRiskColor(context.Item.TaxRiskLevel)" Variant="Variant.Filled">
@context.Item.TaxRiskLevel
</MudChip>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="다음 신고">
<CellTemplate>
@if (context.Item.NextFilingDueDate.HasValue)
{
@context.Item.NextFilingDueDate.Value.ToString("yyyy-MM-dd")
}
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="작업" Sortable="false">
<CellTemplate>
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await OpenEditDialog(context.Item))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(async () => await DeleteProfile(context.Item.Id))" />
</MudButtonGroup>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
<!-- Create/Edit Dialog -->
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
<TitleContent>
<MudText Typo="Typo.h6">@(isEditMode ? "세무 프로필 수정" : "새 세무 프로필 추가")</MudText>
</TitleContent>
<DialogContent>
<MudForm @ref="form">
<MudSelect T="int?" @bind-Value="profileForm.ClientId" Label="고객" Required="true" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
<MudSelectItem Value="@((int?)client.Id)">@GetClientDisplayName(client)</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.BusinessType" Label="사업 유형" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" Required="true">
<MudSelectItem Value="@("일반제조업")">일반제조업</MudSelectItem>
<MudSelectItem Value="@("도소매업")">도소매업</MudSelectItem>
<MudSelectItem Value="@("서비스업")">서비스업</MudSelectItem>
<MudSelectItem Value="@("정보통신업")">정보통신업</MudSelectItem>
<MudSelectItem Value="@("부동산업")">부동산업</MudSelectItem>
<MudSelectItem Value="@("건설업")">건설업</MudSelectItem>
<MudSelectItem Value="@("음식점업")">음식점업</MudSelectItem>
<MudSelectItem Value="@("프리랜서")">프리랜서</MudSelectItem>
<MudSelectItem Value="@("기타")">기타</MudSelectItem>
</MudSelect>
<MudSelect T="string" @bind-Value="profileForm.TaxRiskLevel" Label="위험도" Variant="Variant.Outlined" FullWidth="true" Class="mb-4">
<MudSelectItem Value="@("low")">낮음</MudSelectItem>
<MudSelectItem Value="@("normal")">보통</MudSelectItem>
<MudSelectItem Value="@("high")">높음</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="profileForm.NextFilingDueDate" Label="다음 신고 예정일" Variant="Variant.Outlined" FullWidth="true" Class="mb-4" />
<MudTextField T="string" @bind-Value="profileForm.SpecialNotes" Label="특수 사항" Variant="Variant.Outlined" FullWidth="true" Lines="2" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDialog">취소</MudButton>
<MudButton Color="Color.Primary" OnClick="SaveProfile">저장</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
@@ -135,11 +123,10 @@ else
private List<TaxProfile>? profiles;
private List<Client> clients = [];
private Dictionary<int, string> clientMap = new();
private List<CommonCode> businessTypes = [];
private List<CommonCode> riskLevels = [];
private MudForm? form;
private bool isDialogOpen;
private bool isEditMode;
private TaxProfile? selectedProfile;
private TaxProfile? editingProfile;
private TaxProfileForm profileForm = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -152,7 +139,6 @@ else
if (authState.User.Identity?.IsAuthenticated == true)
{
await LoadData();
PrepareCreate();
StateHasChanged();
}
}
@@ -167,32 +153,6 @@ else
var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
businessTypes = await CommonCodeClient.GetByGroupAsync("BUSINESS_TYPE");
if (businessTypes.Count == 0)
{
businessTypes = [
new() { CodeValue = "일반제조업", CodeName = "일반제조업" },
new() { CodeValue = "도소매업", CodeName = "도소매업" },
new() { CodeValue = "서비스업", CodeName = "서비스업" },
new() { CodeValue = "정보통신업", CodeName = "정보통신업" },
new() { CodeValue = "부동산업", CodeName = "부동산업" },
new() { CodeValue = "건설업", CodeName = "건설업" },
new() { CodeValue = "음식점업", CodeName = "음식점업" },
new() { CodeValue = "프리랜서", CodeName = "프리랜서" },
new() { CodeValue = "기타", CodeName = "기타" }
];
}
riskLevels = await CommonCodeClient.GetByGroupAsync("TAX_RISK_LEVEL");
if (riskLevels.Count == 0)
{
riskLevels = [
new() { CodeValue = "low", CodeName = "낮음" },
new() { CodeValue = "normal", CodeName = "보통" },
new() { CodeValue = "high", CodeName = "높음" }
];
}
}
catch (Exception ex)
{
@@ -200,23 +160,23 @@ else
}
}
private void PrepareCreate()
private void OpenCreateDialog()
{
selectedProfile = null;
isEditMode = false;
editingProfile = null;
profileForm = new TaxProfileForm
{
ClientId = clients.FirstOrDefault()?.Id,
TaxRiskLevel = "normal",
NextFilingDueDate = DateTime.Today.AddMonths(1)
};
isDialogOpen = true;
}
private void OnRowSelected(TaxProfile profile)
private async Task OpenEditDialog(TaxProfile profile)
{
if (profile == null) return;
selectedProfile = profile;
isEditMode = true;
editingProfile = profile;
profileForm = new TaxProfileForm
{
ClientId = profile.ClientId,
@@ -225,6 +185,7 @@ else
NextFilingDueDate = profile.NextFilingDueDate,
SpecialNotes = profile.SpecialNotes
};
isDialogOpen = true;
}
private async Task SaveProfile()
@@ -234,16 +195,16 @@ else
await form.Validate();
if (!form.IsValid)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
}
try
{
if (isEditMode && selectedProfile != null)
if (isEditMode && editingProfile != null)
{
await TaxProfileClient.UpdateAsync(selectedProfile.Id, profileForm.BusinessType,
await TaxProfileClient.UpdateAsync(editingProfile.Id, profileForm.BusinessType,
null, profileForm.NextFilingDueDate, profileForm.TaxRiskLevel);
Snackbar.Add("세무 프로필이 수정되었습니다.", Severity.Success);
}
@@ -259,6 +220,7 @@ else
profileForm.BusinessType);
if (newId > 0)
{
// 생성 후 상태 업데이트 처리
await TaxProfileClient.UpdateAsync(
newId,
profileForm.BusinessType,
@@ -268,7 +230,7 @@ else
Snackbar.Add("세무 프로필이 추가되었습니다.", Severity.Success);
}
}
PrepareCreate();
CloseDialog();
await LoadData();
}
catch (Exception ex)
@@ -293,10 +255,6 @@ else
{
await TaxProfileClient.DeleteAsync(id);
Snackbar.Add("세무 프로필이 삭제되었습니다.", Severity.Success);
if (selectedProfile?.Id == id)
{
PrepareCreate();
}
await LoadData();
}
catch (Exception ex)
@@ -305,6 +263,14 @@ else
}
}
private void CloseDialog()
{
isDialogOpen = false;
isEditMode = false;
editingProfile = null;
profileForm = new();
}
private Color GetRiskColor(string riskLevel) => riskLevel switch
{
"high" => Color.Error,
@@ -319,7 +285,6 @@ else
: !string.IsNullOrWhiteSpace(client.Name)
? client.Name
: $"Client #{client.Id}";
private class TaxProfileForm
{
public int? ClientId { get; set; }
@@ -1,39 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CommonCodeController(CommonCodeService commonCodeService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAllActive()
{
try
{
var codes = await commonCodeService.GetAllActiveAsync();
return Ok(codes);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "공통코드 조회 실패", message = ex.Message });
}
}
[HttpGet("group/{group}")]
public async Task<IActionResult> GetByGroup(string group)
{
try
{
var codes = await commonCodeService.GetByGroupAsync(group);
return Ok(codes);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "그룹별 공통코드 조회 실패", message = ex.Message });
}
}
}
+8 -8
View File
@@ -81,7 +81,7 @@ else
<span class="badge bg-primary-badge mb-3">경험 있는 세무사의 맞춤 전략</span>
<h1 class="mb-3">
세금과 자산<br/>
<span style="color: #E8E4D8;">한 번에 해결하는</span>
<span style="color: #FFD54F;">한 번에 해결하는</span>
</h1>
<p class="fs-5 mb-4" style="line-height: 1.8; opacity: 0.95;">
사업자 세무, 부동산 거래, 가족자산 관리를 위한<br/>
@@ -229,7 +229,7 @@ else
</section>
<!-- 상담 프로세스 -->
<section class="py-5" style="background: #F9F7F3;">
<section class="py-5" style="background: #F7F9FC;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">상담 과정</h2>
@@ -237,28 +237,28 @@ else
<div class="row align-items-center">
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #1B4F8A 0%, #133970 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📞
</div>
<h4>1단계: 무료 상담</h4>
<p class="text-muted small">상황 파악 및<br/>현재 문제점 확인</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #1B4F8A 0%, #133970 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
📋
</div>
<h4>2단계: 세무진단</h4>
<p class="text-muted small">자료 분석 및<br/>최적 방안 도출</p>
</div>
<div class="col-md-3 text-center mb-4 mb-md-0">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #1B4F8A 0%, #133970 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
💡
</div>
<h4>3단계: 맞춤제안</h4>
<p class="text-muted small">절세 전략 및<br/>실행 계획 제시</p>
</div>
<div class="col-md-3 text-center">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #C89D6E 0%, #A67C52 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
<div style="width: 80px; height: 80px; margin: 0 auto 1rem; background: linear-gradient(135deg, #1B4F8A 0%, #133970 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 32px;">
</div>
<h4>4단계: 실행지원</h4>
@@ -355,7 +355,7 @@ else
<!-- 자주 묻는 질문 (DB 연동) -->
@if (Model.ActiveFaqs.Count > 0)
{
<section class="py-5" style="background: #F9F7F3;">
<section class="py-5" style="background: #F7F9FC;">
<div class="container">
<div class="text-center mb-5">
<h2 class="section-title">자주 묻는 질문</h2>
@@ -392,7 +392,7 @@ else
}
<!-- 최종 CTA -->
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
<section class="py-5" style="background: linear-gradient(135deg, #1B4F8A 0%, #133970 100%); color: white;">
<div class="container text-center">
@if (season != null)
{
+8 -4
View File
@@ -54,7 +54,7 @@
<div class="row g-4">
<!-- 왼쪽: 세무 신고 현황 (Tax Filings) -->
<div class="col-lg-8">
<div class="card glass-card mb-4">
<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">
@@ -124,7 +124,7 @@
<!-- 오른쪽: 상담 이력 요약 (Consulting Activities) -->
<div class="col-lg-4">
<div class="card glass-card">
<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> 최근 상담 및 지원 이력
@@ -139,10 +139,14 @@
}
else
{
<div class="timeline ps-2">
<div class="timeline">
@foreach (var activity in Model.Consultations)
{
<div class="timeline-item-modern">
<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>
+2 -2
View File
@@ -25,11 +25,11 @@
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#C89D6E" />
<meta name="theme-color" content="#1B4F8A" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "http://178.104.200.7/taxbaik/")" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
+5 -7
View File
@@ -54,7 +54,9 @@ builder.Services.AddHealthChecks();
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = true;
@@ -256,17 +258,11 @@ builder.Services.AddHttpClient<IRevenueTrackingBrowserClient, RevenueTrackingBro
client.BaseAddress = new Uri(apiBaseUrl);
});
builder.Services.AddHttpClient<ICommonCodeBrowserClient, CommonCodeBrowserClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
// UI & 캐시 (MudBlazor Theme Customization)
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.HideTransitionDuration = 400;
config.SnackbarConfiguration.ShowTransitionDuration = 300;
config.PopoverOptions.ThrowOnDuplicateProvider = false;
});
builder.Services.AddMemoryCache();
builder.Services.AddResponseCompression(opts => {
@@ -354,6 +350,8 @@ app.MapRazorPages();
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents<TaxBaik.Web.Components.Admin.App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly)
.AllowAnonymous();
// 애플리케이션 시작/종료 로깅
@@ -1,56 +0,0 @@
namespace TaxBaik.Web.Services.AdminClients;
using System.Collections.Generic;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TaxBaik.Domain.Entities;
using Microsoft.Extensions.Logging;
public interface ICommonCodeBrowserClient
{
Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default);
Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default);
}
public class CommonCodeBrowserClient(HttpClient httpClient, ITokenStore tokenStore, ILogger<CommonCodeBrowserClient> logger) : ICommonCodeBrowserClient
{
private const string BaseUrl = "/api/commoncode";
private void EnsureAuthHeader()
{
if (!string.IsNullOrEmpty(tokenStore.AccessToken))
httpClient.DefaultRequestHeaders.Authorization = new("Bearer", tokenStore.AccessToken);
else
httpClient.DefaultRequestHeaders.Authorization = null;
}
public async Task<List<CommonCode>> GetAllActiveAsync(CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get all active common codes");
return [];
}
}
public async Task<List<CommonCode>> GetByGroupAsync(string group, CancellationToken ct = default)
{
try
{
EnsureAuthHeader();
return await httpClient.GetFromJsonAsync<List<CommonCode>>($"{BaseUrl}/group/{group}", ct) ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get common codes for group {Group}", group);
return [];
}
}
}
@@ -79,7 +79,7 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
var principal = _authService.ValidateToken(accessToken ?? string.Empty);
var principal = _authService.ValidateToken(accessToken);
if (principal == null)
{
await LogoutAsync();
@@ -115,13 +115,13 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
if (_tokenStore.TokenExpiryTicks <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
+2
View File
@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\TaxBaik.Application\TaxBaik.Application.csproj" />
<ProjectReference Include="..\TaxBaik.Infrastructure\TaxBaik.Infrastructure.csproj" />
<ProjectReference Include="..\TaxBaik.Web.Client\TaxBaik.Web.Client.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -12,6 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" />
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
+25 -81
View File
@@ -64,35 +64,35 @@
/* Spacing Scale */
--space-0: 0;
--space-1: 3px;
--space-2: 6px;
--space-3: 10px;
--space-4: 12px;
--space-5: 16px;
--space-6: 20px;
--space-7: 24px;
--space-8: 28px;
--space-10: 34px;
--space-12: 40px;
--space-16: 52px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Border Radius */
--radius-sm: 3px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* Typography Scale */
--font-family-base: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-size-xs: 0.7rem;
--font-size-sm: 0.75rem;
--font-size-base: 0.82rem;
--font-size-lg: 0.95rem;
--font-size-xl: 1.1rem;
--font-size-2xl: 1.3rem;
--font-size-3xl: 1.6rem;
--font-size-4xl: 2rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-weight-regular: 400;
--font-weight-medium: 500;
@@ -445,12 +445,11 @@ textarea:focus-visible {
display: flex;
align-items: center;
gap: 12px;
padding: 0px 12px;
height: 38px !important;
padding: 6px 16px;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
z-index: var(--z-dropdown);
box-shadow: none !important;
box-shadow: var(--shadow-xs);
}
.admin-menu-button {
@@ -1642,58 +1641,3 @@ textarea:focus-visible {
margin-right: -8px;
}
}
/* ============================================================================
더존 ERP 스타일 최적화 (Douzone ERP High-Density Desktop Style)
- 프레임워크 고유 레이아웃과 이벤트를 방해하는 와일드카드 및 강제 강하 스타일 제거
- MudBlazor 테마 설정을 기반으로 하며 레이아웃 및 서체 스택만 안전하게 제어
============================================================================ */
html, body {
background-color: #E2E8F0 !important;
color: #1E293B !important;
font-family: 'Malgun Gothic', '맑은 고딕', 'Segoe UI', sans-serif !important;
}
/* 어드민 드로워 및 탑바 테마 컬러 보완 */
.mud-drawer {
border-right: 1px solid #CBD5E1 !important;
}
.mud-drawer-header {
border-bottom: 1px solid #1E293B !important;
padding: 8px 12px !important;
}
.mud-nav-link {
font-size: 12px !important;
}
/* 데이터그리드 헤더 가시성 보완 */
.mud-table-head th {
background-color: #F1F5F9 !important;
font-weight: bold !important;
color: #0F172A !important;
}
/* 페이지 헤더 영역 */
.admin-page-hero {
padding: 12px 16px !important;
background-color: #F8FAFC !important;
border-bottom: 1px solid #E2E8F0 !important;
margin-bottom: 12px !important;
}
.admin-page-title {
font-size: 16px !important;
font-weight: bold !important;
}
.admin-page-subtitle {
font-size: 12px !important;
color: #64748B !important;
}
.admin-eyebrow {
display: none !important;
}
+91 -257
View File
@@ -1,18 +1,22 @@
/* TaxBaik — 워밍-프로페셔널 디자인 시스템 */
/* TaxBaik — Navy Blue 디자인 시스템 */
:root {
/* 워밍-프로페셔널 팔레트 */
--color-primary: #C89D6E; /* 따뜻한 골드/브론즈 */
--color-primary-dark: #A67C52; /* 진한 브론즈 */
--color-secondary: #2E5C4E; /* 따뜻한 초록 */
--color-secondary-dark: #1F3A30; /* 어두운 초록 */
--color-accent: #E8E4D8; /* 따뜻한 베이지 */
--color-accent-dark: #D9D3C4; /* 더 진한 베이지 */
--color-bg: #F9F7F3; /* 따뜻한 화이트 */
--color-bg-alt: #EFE9DD; /* 대체 배경 */
--color-text: #3D2817; /* 따뜻한 갈색 */
--color-text-light: #6B5D4F; /* 밝은 갈색 */
--color-border: #D9D3C4; /* 경계선 */
/* Navy Blue 팔레트 */
--color-primary: #1B4F8A; /* 네이비 */
--color-primary-dark: #133970; /* 진한 네이비 */
--color-primary-light: #2E5FA3; /* 밝은 네이비 */
--color-secondary: #2E5FA3; /* 보조 (밝은 네이비) */
--color-secondary-dark: #1B4F8A; /* 보조 진한 */
--color-accent: #E8F1F8; /* 연한 블루 배경 */
--color-accent-dark: #D8E2EE; /* 더 진한 연블루 */
--color-bg: #F7F9FC; /* 차가운 화이트 */
--color-bg-alt: #EAF1F8; /* 대체 배경 */
--color-text: #1A1A2E; /* 본문 텍스트 */
--color-text-light: #5A6A7A; /* 보조 텍스트 */
--color-border: #D8E2EE; /* 경계선 */
--color-cta: #E05A2B; /* 오렌지 CTA */
--color-cta-dark: #D45A1F; /* 진한 오렌지 */
--color-gold: #C9A227; /* 골드 액센트 */
--color-success: #2E7D32;
--color-warning: #F57C00;
--color-danger: #C62828;
@@ -30,10 +34,10 @@
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 3px rgba(61, 40, 23, 0.08);
--shadow-md: 0 4px 12px rgba(61, 40, 23, 0.12);
--shadow-lg: 0 8px 24px rgba(61, 40, 23, 0.15);
--shadow-xl: 0 12px 48px rgba(61, 40, 23, 0.18);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 12px 32px rgba(0, 0, 0, 0.15);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -52,10 +56,9 @@ html {
body {
color: var(--color-text);
background-color: var(--color-bg);
line-height: 1.8;
font-size: clamp(0.9rem, 2.5vw, 1rem);
letter-spacing: 0.3px;
background-color: #fff;
line-height: 1.75;
font-size: clamp(0.875rem, 2.5vw, 1rem);
}
/* ===== 타이포그래피 ===== */
@@ -64,12 +67,11 @@ h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
color: var(--color-text);
margin-bottom: var(--spacing-lg);
letter-spacing: -0.5px;
}
h1 { font-size: clamp(2rem, 6vw, 3.5rem); font-weight: 800; }
h2 { font-size: clamp(1.5rem, 5vw, 2.5rem); }
h3 { font-size: clamp(1.25rem, 4vw, 2rem); }
h1 { font-size: clamp(1.75rem, 5vw, 3rem); font-weight: 700; }
h2 { font-size: clamp(1.5rem, 4vw, 2.5rem); }
h3 { font-size: clamp(1.25rem, 3.5vw, 2rem); }
h4 { font-size: 1.35rem; }
h5 { font-size: 1.15rem; }
h6 { font-size: 1rem; }
@@ -77,17 +79,17 @@ h6 { font-size: 1rem; }
p {
margin-bottom: var(--spacing-md);
color: var(--color-text-light);
line-height: 1.85;
line-height: 1.8;
}
a {
color: var(--color-primary);
text-decoration: none;
transition: all var(--transition-fast);
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-secondary);
color: var(--color-primary-dark);
text-decoration: none;
}
@@ -95,12 +97,11 @@ a:hover {
.btn {
border-radius: var(--radius-md);
font-weight: 600;
transition: all var(--transition-normal);
transition: all var(--transition-fast);
cursor: pointer;
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
letter-spacing: 0.3px;
display: inline-block;
text-align: center;
text-decoration: none;
@@ -111,27 +112,29 @@ a:hover {
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--color-primary-dark) 0%, #8B5E3C 100%);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
background-color: var(--color-primary-dark);
border-color: var(--color-primary-dark);
color: white;
box-shadow: 0 4px 12px rgba(27, 79, 138, 0.25);
}
.btn-warning {
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
background-color: var(--color-cta);
border-color: var(--color-cta);
color: white;
box-shadow: var(--shadow-md);
}
.btn-warning:hover {
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #0D1E1A 100%);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
background-color: var(--color-cta-dark);
border-color: var(--color-cta-dark);
color: white;
box-shadow: 0 4px 12px rgba(224, 90, 43, 0.25);
}
.btn-outline-primary {
@@ -158,7 +161,7 @@ a:hover {
/* ===== 카드 ===== */
.card {
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
transition: all var(--transition-normal);
box-shadow: var(--shadow-sm);
background: white;
@@ -166,7 +169,7 @@ a:hover {
}
.card:hover {
transform: translateY(-6px);
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-primary);
}
@@ -176,8 +179,8 @@ a:hover {
}
.card-title {
font-weight: 700;
color: var(--color-text);
font-weight: 600;
color: var(--color-primary);
margin-bottom: var(--spacing-md);
font-size: 1.25rem;
}
@@ -189,39 +192,28 @@ a:hover {
/* ===== 히어로 섹션 ===== */
.hero-section {
padding: clamp(3rem, 20vh, 6rem) 0;
background: linear-gradient(135deg, var(--color-secondary) 0%, #1F3A30 100%);
padding: clamp(2rem, 15vh, 5rem) 0;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: white;
position: relative;
overflow: hidden;
border-bottom: 4px solid var(--color-primary);
}
.hero-section::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 600px;
height: 600px;
background: rgba(200, 157, 110, 0.1);
border-radius: 50%;
}
.hero-section::after {
content: '';
position: absolute;
bottom: -30%;
left: -10%;
top: 0;
right: 0;
width: 500px;
height: 500px;
background: rgba(232, 228, 216, 0.05);
background: rgba(255, 255, 255, 0.05);
border-radius: 50%;
transform: translate(30%, -30%);
}
.hero-section h1 {
font-size: clamp(2rem, 8vw, 3.5rem);
font-weight: 800;
font-size: clamp(1.75rem, 6vw, 3rem);
font-weight: 700;
margin-bottom: var(--spacing-lg);
position: relative;
z-index: 1;
@@ -243,12 +235,12 @@ a:hover {
}
.bg-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
background-color: var(--color-primary) !important;
}
.section-title {
font-size: clamp(1.75rem, 5vw, 2.75rem);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--spacing-xl);
text-align: center;
@@ -264,14 +256,14 @@ a:hover {
display: block;
width: 60px;
height: 4px;
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%);
background: var(--color-primary);
margin: var(--spacing-md) auto 0;
border-radius: 2px;
}
/* ===== 휴스 스트립 (신뢰도) ===== */
/* ===== 신뢰도 스트립 ===== */
.trust-strip {
background: linear-gradient(135deg, var(--color-bg-alt) 0%, var(--color-accent) 100%);
background-color: var(--color-accent);
padding: var(--spacing-3xl) 0;
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
@@ -305,11 +297,10 @@ a:hover {
font-size: 0.85rem;
font-weight: 600;
display: inline-block;
letter-spacing: 0.2px;
}
.bg-primary-badge {
background-color: rgba(200, 157, 110, 0.15);
background-color: rgba(27, 79, 138, 0.1);
color: var(--color-primary);
}
@@ -326,7 +317,7 @@ a:hover {
.form-control:focus, .form-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.1);
box-shadow: 0 0 0 3px rgba(27, 79, 138, 0.1);
outline: none;
}
@@ -340,31 +331,28 @@ a:hover {
border-top: 2px solid var(--color-primary);
padding: var(--spacing-md);
z-index: 1000;
box-shadow: 0 -4px 12px rgba(61, 40, 23, 0.1);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.btn-kakao-mobile {
display: block;
width: 100%;
padding: 0.85rem;
background: linear-gradient(135deg, #FFE812 0%, #FDD835 100%);
background: #FFE812;
color: #000;
text-decoration: none;
border-radius: var(--radius-md);
font-weight: 700;
font-weight: 600;
text-align: center;
border: none;
cursor: pointer;
font-size: 0.95rem;
transition: all var(--transition-fast);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
letter-spacing: 0.3px;
transition: background var(--transition-fast);
}
.btn-kakao-mobile:hover {
background: linear-gradient(135deg, #FDD835 0%, #FBC02D 100%);
background: #FDD835;
text-decoration: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
body.with-mobile-cta {
@@ -379,24 +367,26 @@ body.with-mobile-cta {
}
.navbar-brand {
font-weight: 800;
font-weight: 700;
color: var(--color-primary) !important;
font-size: 1.35rem;
letter-spacing: -0.5px;
font-size: 1.25rem;
}
.nav-link {
color: var(--color-text) !important;
font-weight: 600;
transition: all var(--transition-fast);
font-weight: 500;
transition: color var(--transition-fast);
margin: 0 var(--spacing-sm);
letter-spacing: 0.2px;
}
.nav-link:hover {
color: var(--color-primary) !important;
}
.nav-link.active {
color: var(--color-cta) !important;
}
/* ===== 반응형 ===== */
@media (max-width: 767.98px) {
h1 { font-size: 1.75rem; }
@@ -484,6 +474,10 @@ body.with-mobile-cta {
}
/* ===== 일반 유틸리티 ===== */
.text-primary {
color: var(--color-primary) !important;
}
.text-muted {
color: var(--color-text-light) !important;
}
@@ -522,7 +516,7 @@ img {
.service-card .card-title {
font-size: 1.4rem;
margin-bottom: 1rem;
color: var(--color-text);
color: var(--color-primary);
}
.service-card ul li {
@@ -538,20 +532,20 @@ img {
.blog-placeholder {
height: 180px;
background: linear-gradient(135deg, rgba(200, 157, 110, 0.1) 0%, rgba(46, 92, 78, 0.1) 100%);
background: rgba(27, 79, 138, 0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
color: rgba(200, 157, 110, 0.3);
color: rgba(27, 79, 138, 0.3);
}
.blog-card:hover .blog-placeholder {
background: linear-gradient(135deg, rgba(200, 157, 110, 0.2) 0%, rgba(46, 92, 78, 0.2) 100%);
background: rgba(27, 79, 138, 0.14);
}
.bg-primary-badge {
background-color: rgba(200, 157, 110, 0.15) !important;
background-color: rgba(27, 79, 138, 0.1) !important;
color: var(--color-primary) !important;
}
@@ -581,7 +575,7 @@ img {
/* ===== 시즌 Hero ===== */
.hero-section--seasonal {
background: linear-gradient(135deg, #1F3A30 0%, #2E5C4E 60%, #3D7A68 100%);
background: linear-gradient(135deg, #133970 0%, #1B4F8A 60%, #2E5FA3 100%);
}
.bg-danger-badge {
background-color: rgba(198, 40, 40, 0.85) !important;
@@ -599,9 +593,8 @@ img {
height: 220px;
border-radius: 50%;
border: 4px solid rgba(255,255,255,0.25);
background: rgba(255,255,255,0.08);
background: rgba(255,255,255,0.1);
color: white;
backdrop-filter: blur(4px);
}
.deadline-label {
font-size: 0.85rem;
@@ -649,7 +642,7 @@ img {
}
.seasonal-blog-tag {
display: inline-block;
background: linear-gradient(135deg, #C62828 0%, #B71C1C 100%);
background: var(--color-cta);
color: white;
font-size: 0.82rem;
font-weight: 700;
@@ -723,7 +716,7 @@ img {
padding: 1.1rem 1.5rem;
}
.faq-question:not(.collapsed) {
color: var(--color-secondary);
color: var(--color-primary);
background: white;
box-shadow: none;
}
@@ -731,10 +724,10 @@ img {
filter: none;
}
.faq-question:focus {
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.2);
box-shadow: 0 0 0 3px rgba(27, 79, 138, 0.2);
}
.faq-answer {
background: #fdfcfa;
background: #F7F9FC;
color: var(--color-text-light);
line-height: 1.85;
padding: 1rem 1.5rem 1.25rem;
@@ -746,162 +739,3 @@ img {
.faq-answer ul li {
margin-bottom: 0.4rem;
}
/* ===== 프리미엄 고도화 & 마이크로 인터랙션 (2026-06-30) ===== */
/* 영어/숫자용 폰트 클래스 */
.font-numeric, .font-heading-en {
font-family: 'Outfit', 'Inter', 'Noto Sans KR', sans-serif;
}
/* 히어로 섹션 프리미엄 개편 (메쉬 그라데이션 및 CSS 애니메이션) */
.hero-section {
background: radial-gradient(circle at 10% 20%, rgba(46, 92, 78, 1) 0%, rgba(31, 58, 48, 1) 44%, rgba(13, 30, 26, 1) 100%) !important;
position: relative;
overflow: hidden;
}
.hero-section::before {
content: '';
position: absolute;
top: -30%;
right: -10%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(200, 157, 110, 0.25) 0%, rgba(200, 157, 110, 0) 70%);
border-radius: 50%;
animation: floatAnimation 8s ease-in-out infinite;
pointer-events: none;
}
.hero-section::after {
content: '';
position: absolute;
bottom: -20%;
left: -10%;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(232, 228, 216, 0.15) 0%, rgba(232, 228, 216, 0) 70%);
border-radius: 50%;
animation: floatAnimation2 12s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes floatAnimation {
0% { transform: translateY(0px) scale(1); }
50% { transform: translateY(-30px) scale(1.05); }
100% { transform: translateY(0px) scale(1); }
}
@keyframes floatAnimation2 {
0% { transform: translateX(0px) rotate(0deg); }
50% { transform: translateX(20px) translateY(15px) rotate(10deg); }
100% { transform: translateX(0px) rotate(0deg); }
}
/* 신뢰도 스트립 카드 리뉴얼 */
.trust-strip {
background-color: var(--color-bg-alt);
padding: 3rem 0;
margin-top: -1.5rem;
position: relative;
z-index: 10;
}
.trust-item {
background: white;
border-radius: var(--radius-lg);
padding: 2rem 1.5rem;
box-shadow: 0 10px 30px rgba(61, 40, 23, 0.05);
border: 1px solid rgba(200, 157, 110, 0.15);
transition: all var(--transition-normal);
text-align: center;
}
.trust-item:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(61, 40, 23, 0.1);
border-color: var(--color-primary);
}
.trust-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
display: inline-block;
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
transition: transform var(--transition-fast);
}
.trust-item:hover .trust-icon {
transform: scale(1.15) rotate(5deg);
}
/* 서비스 카드 고도화 */
.service-card {
border: 1px solid rgba(217, 211, 196, 0.6) !important;
box-shadow: 0 10px 25px rgba(61, 40, 23, 0.03) !important;
transition: all var(--transition-normal) !important;
}
.service-card:hover {
transform: translateY(-8px) !important;
box-shadow: 0 20px 40px rgba(61, 40, 23, 0.1) !important;
border-color: var(--color-primary) !important;
}
.service-card--featured {
background: linear-gradient(180deg, #FFFFFF 0%, #FAF8F5 100%) !important;
border-left: 4px solid var(--color-primary) !important;
}
/* 글래스모피즘 포털 클래스 (Glassmorphism Portal Classes) */
.glass-card {
background: rgba(255, 255, 255, 0.7) !important;
backdrop-filter: blur(12px) saturate(180%) !important;
-webkit-backdrop-filter: blur(12px) saturate(180%) !important;
border: 1px solid rgba(255, 255, 255, 0.4) !important;
box-shadow: 0 8px 32px 0 rgba(61, 40, 23, 0.05) !important;
border-radius: var(--radius-lg);
transition: all var(--transition-normal);
}
.glass-card:hover {
background: rgba(255, 255, 255, 0.8) !important;
box-shadow: 0 8px 32px 0 rgba(61, 40, 23, 0.08) !important;
}
.portal-welcome-strip {
background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #152A22 100%);
border-radius: var(--radius-lg);
color: white;
padding: 2.5rem;
box-shadow: var(--shadow-lg);
border-bottom: 3px solid var(--color-primary);
}
/* 타임라인 컴포넌트 뷰티화 */
.timeline-item-modern {
border-left: 2px solid rgba(200, 157, 110, 0.4);
position: relative;
padding-left: 1.5rem;
padding-bottom: 2rem;
}
.timeline-item-modern::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-primary);
left: -7px;
top: 6px;
box-shadow: 0 0 0 4px rgba(200, 157, 110, 0.25);
transition: all var(--transition-fast);
}
.timeline-item-modern:hover::after {
background: var(--color-secondary);
box-shadow: 0 0 0 6px rgba(46, 92, 78, 0.3);
transform: scale(1.1);
}
-42
View File
@@ -100,45 +100,3 @@ window.taxbaikAdminSession = {
.observe(modal, { attributes: true, attributeFilter: ['class'] });
}
};
// 더존 ERP 스타일 엔터 키 포커스 이동 및 단축키 바인딩
document.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
const active = document.activeElement;
if (!active) return;
// 특정 영역(편집 폼 또는 다이얼로그) 내의 입력 필드만 포커스 이동 처리
const container = active.closest('.admin-editor-panel, .mud-form, .mud-dialog');
if (!container) return;
// textarea나 button, submit 타입 등은 기본 동작(줄바꿈/제출) 유지
if (active.tagName === 'TEXTAREA' ||
active.tagName === 'BUTTON' ||
active.getAttribute('type') === 'submit' ||
active.classList.contains('mud-button-root')) {
return;
}
e.preventDefault();
// 포커스 이동 가능한 모든 입력 요소 수집
const focusables = Array.from(container.querySelectorAll('input, select, textarea, button'))
.filter(el => {
const style = window.getComputedStyle(el);
return el.tabIndex >= 0 &&
!el.disabled &&
el.getAttribute('aria-disabled') !== 'true' &&
style.display !== 'none' &&
style.visibility !== 'hidden';
});
const index = focusables.indexOf(active);
if (index > -1 && index < focusables.length - 1) {
const nextEl = focusables[index + 1];
nextEl.focus();
if (typeof nextEl.select === 'function') {
nextEl.select();
}
}
}
});
+10 -10
View File
@@ -8,8 +8,8 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; }
body {
background: #F9F7F3;
color: #3D2817;
background: #F7F9FC;
color: #1A1A2E;
display: flex;
align-items: center;
justify-content: center;
@@ -23,12 +23,12 @@
background: #fff;
border-radius: 16px;
padding: 3rem 2.5rem;
box-shadow: 0 8px 32px rgba(61,40,23,.10);
box-shadow: 0 8px 32px rgba(0,0,0,.10);
}
.icon { font-size: 3.5rem; margin-bottom: 1.25rem; }
.badge {
display: inline-block;
background: #C89D6E;
background: #1B4F8A;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
@@ -37,13 +37,13 @@
border-radius: 20px;
margin-bottom: 1.5rem;
}
h1 { font-size: 1.6rem; color: #2E5C4E; font-weight: 800; margin-bottom: 1rem; line-height: 1.35; }
p { color: #6B5D4F; line-height: 1.85; font-size: 0.95rem; }
.divider { border: none; border-top: 1px solid #EFE9DD; margin: 1.75rem 0; }
h1 { font-size: 1.6rem; color: #1B4F8A; font-weight: 800; margin-bottom: 1rem; line-height: 1.35; }
p { color: #5A6A7A; line-height: 1.85; font-size: 0.95rem; }
.divider { border: none; border-top: 1px solid #D8E2EE; margin: 1.75rem 0; }
.kakao-btn {
display: inline-block;
background: #FEE500;
color: #3D2817;
color: #1A1A2E;
text-decoration: none;
font-weight: 700;
padding: 0.65rem 1.5rem;
@@ -51,8 +51,8 @@
font-size: 0.95rem;
margin-top: 0.5rem;
}
.timer { font-size: 0.78rem; color: #A09080; margin-top: 1.5rem; }
.footer { font-size: 0.75rem; color: #C0ADA0; margin-top: 2rem; }
.timer { font-size: 0.78rem; color: #8A99A8; margin-top: 1.5rem; }
.footer { font-size: 0.75rem; color: #A8B5C2; margin-top: 2rem; }
</style>
</head>
<body>
+14
View File
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web", "TaxBaik.Web\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Application.Tests", "TaxBaik.Application.Tests\TaxBaik.Application.Tests.csproj", "{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaxBaik.Web.Client", "TaxBaik.Web.Client\TaxBaik.Web.Client.csproj", "{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +85,18 @@ Global
{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x64.Build.0 = Release|Any CPU
{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x86.ActiveCfg = Release|Any CPU
{47D1F07D-F11B-4343-A3C3-1872F0C46AE3}.Release|x86.Build.0 = Release|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Debug|x64.ActiveCfg = Debug|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Debug|x64.Build.0 = Debug|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Debug|x86.ActiveCfg = Debug|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Debug|x86.Build.0 = Debug|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Release|Any CPU.Build.0 = Release|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Release|x64.ActiveCfg = Release|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Release|x64.Build.0 = Release|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Release|x86.ActiveCfg = Release|Any CPU
{F3DEFE23-E849-4BE6-9E18-C1AF1CDDC7EB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
-48
View File
@@ -1,48 +0,0 @@
-- Create common_codes table
CREATE TABLE IF NOT EXISTS common_codes (
code_group VARCHAR(50) NOT NULL,
code_value VARCHAR(50) NOT NULL,
code_name VARCHAR(100) NOT NULL,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
PRIMARY KEY (code_group, code_value)
);
-- Seed data for BUSINESS_TYPE
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('BUSINESS_TYPE', '일반제조업', '일반제조업', 10),
('BUSINESS_TYPE', '도소매업', '도소매업', 20),
('BUSINESS_TYPE', '서비스업', '서비스업', 30),
('BUSINESS_TYPE', '정보통신업', '정보통신업', 40),
('BUSINESS_TYPE', '부동산업', '부동산업', 50),
('BUSINESS_TYPE', '건설업', '건설업', 60),
('BUSINESS_TYPE', '음식점업', '음식점업', 70),
('BUSINESS_TYPE', '프리랜서', '프리랜서', 80),
('BUSINESS_TYPE', '기타', '기타', 90)
ON CONFLICT (code_group, code_value) DO NOTHING;
-- Seed data for TAX_RISK_LEVEL
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('TAX_RISK_LEVEL', 'low', '낮음', 10),
('TAX_RISK_LEVEL', 'normal', '보통', 20),
('TAX_RISK_LEVEL', 'high', '높음', 30)
ON CONFLICT (code_group, code_value) DO NOTHING;
-- Seed data for FILING_TYPE
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('FILING_TYPE', '종합소득세', '종합소득세', 10),
('FILING_TYPE', '부가가치세', '부가가치세', 20),
('FILING_TYPE', '법인세', '법인세', 30),
('FILING_TYPE', '원천세', '원천세', 40),
('FILING_TYPE', '양도소득세', '양도소득세', 50),
('FILING_TYPE', '상속/증여세', '상속/증여세', 60)
ON CONFLICT (code_group, code_value) DO NOTHING;
-- Seed data for SERVICE_TYPE
INSERT INTO common_codes (code_group, code_value, code_name, sort_order) VALUES
('SERVICE_TYPE', '개인 기장대리', '개인 기장대리', 10),
('SERVICE_TYPE', '법인 기장대리', '법인 기장대리', 20),
('SERVICE_TYPE', '세무조정', '세무조정', 30),
('SERVICE_TYPE', '세무컨설팅', '세무컨설팅', 40),
('SERVICE_TYPE', '불복청구', '불복청구', 50)
ON CONFLICT (code_group, code_value) DO NOTHING;
-138
View File
@@ -1,138 +0,0 @@
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
server {
server_name taxbaik.com www.taxbaik.com;
client_max_body_size 512M;
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
location /admin {
return 301 $scheme://$host/taxbaik$request_uri;
}
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
location / {
proxy_pass http://127.0.0.1:5001/taxbaik/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# /taxbaik/ 하위로 들어오는 리소스 및 페이지 요청 처리
location /taxbaik {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 2. Gitea (gitea.taxbaik.com)
server {
server_name gitea.taxbaik.com;
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# 3. QuantEngine (quant.taxbaik.com)
server {
server_name quant.taxbaik.com;
location / {
proxy_pass http://127.0.0.1:5000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name taxbaik.com www.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = gitea.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name gitea.taxbaik.com;
return 404; # managed by Certbot
}
server {
if ($host = quant.taxbaik.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name quant.taxbaik.com;
return 404; # managed by Certbot
}
-96
View File
@@ -1,96 +0,0 @@
#!/bin/bash
set -e
DEPLOY_HOME="/home/kjh2064"
PORT_FILE="$DEPLOY_HOME/taxbaik_port"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "===== 🚀 TaxBaik Green/Blue Deployment Script ====="
# 1. Determine active port
ACTIVE_PORT=5003
if [ -f "$PORT_FILE" ]; then
ACTIVE_PORT=$(cat "$PORT_FILE" | tr -d '[:space:]')
fi
# 2. Determine target port
TARGET_PORT=5003
if [ "$ACTIVE_PORT" -eq 5003 ]; then
TARGET_PORT=5004
else
TARGET_PORT=5003
fi
echo "Active Port: $ACTIVE_PORT"
echo "Target Port: $TARGET_PORT"
# 3. New deploy dir is passed as first argument
DEPLOY_DIR="$1"
if [ -z "$DEPLOY_DIR" ]; then
echo "Error: Deployment directory argument required"
exit 1
fi
echo "Deploy Directory: $DEPLOY_DIR"
# 4. Start the new app on the target port
echo "=== Starting New App on Port $TARGET_PORT ==="
cd "$DEPLOY_DIR"
export ASPNETCORE_ENVIRONMENT=Production
export ASPNETCORE_URLS="http://127.0.0.1:$TARGET_PORT"
# Run dotnet process
nohup /usr/bin/dotnet TaxBaik.Web.dll > "web_${TARGET_PORT}.log" 2>&1 &
NEW_PID=$!
sleep 2
# Verify process is running
if ! ps -p $NEW_PID > /dev/null; then
echo "❌ Failed to start dotnet process on port $TARGET_PORT"
exit 1
fi
# 5. Health Check Loop
echo "=== Health Checking Port $TARGET_PORT ==="
ATTEMPTS=20
SUCCESS=false
for i in $(seq 1 $ATTEMPTS); do
STATUS=$(curl -sf -o /dev/null -w '%{http_code}' "http://127.0.0.1:${TARGET_PORT}/taxbaik/healthz" 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then
echo "✓ Health check passed on port $TARGET_PORT (Attempt $i/$ATTEMPTS)"
SUCCESS=true
break
fi
echo " Waiting for health check... ($i/$ATTEMPTS, Status: $STATUS)"
sleep 2
done
if [ "$SUCCESS" = "false" ]; then
echo "❌ Health check failed. Rolling back..."
kill -9 $NEW_PID || true
exit 1
fi
# 6. Switch Traffic
echo "=== Switching Traffic to Port $TARGET_PORT ==="
echo "$TARGET_PORT" > "$PORT_FILE"
echo "✓ Traffic routed to $TARGET_PORT"
# 7. Terminate Old App
echo "=== Stopping Old App on Port $ACTIVE_PORT ==="
# Find PID listening on ACTIVE_PORT
OLD_PID=$(ss -tlnp | grep ":$ACTIVE_PORT " | grep -oP 'pid=\K\d+' | head -n1)
if [ -n "$OLD_PID" ]; then
echo "Killing old process PID: $OLD_PID"
kill -15 $OLD_PID || kill -9 $OLD_PID
echo "✓ Old process terminated"
else
echo "No old process found on port $ACTIVE_PORT"
fi
# 8. Cleanup old deployment directories (Keep last 5)
echo "=== Cleaning Up Old Deployments ==="
ls -1dt $DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null | tail -n +6 | xargs rm -rf 2>/dev/null || true
echo "✓ Cleanup completed"
echo "===== ✅ Green/Blue Deployment Completed Successfully ====="
+20 -21
View File
@@ -90,13 +90,14 @@ test.describe('admin CRM pages', () => {
}
});
test('TaxProfiles editor panel is visible on add button click', async ({ page }) => {
test('TaxProfiles modal dialog opens on add button click', async ({ page }) => {
await navigateInBlazor(page, `${baseUrl}/admin/tax-profiles`);
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
await expect(addButton).toBeVisible();
await addButton.click();
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
await expect(page).toHaveURL(/\/taxbaik\/admin\/tax-profiles$/);
await expect(addButton).toBeVisible();
});
test('No console errors on CRM page navigation', async ({ page }) => {
@@ -128,15 +129,16 @@ test.describe('admin CRM pages', () => {
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
const addButton = page.getByRole('button', { name: /새 프로필 추가/ });
await addButton.click();
// 분할 편집기(admin-editor-panel) 노출 대기
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
// JS 네이티브 클릭으로 강제 격발하여 오프셋 씹힘 소멸
await addButton.evaluate(el => (el as HTMLButtonElement).click());
// mud-select 내의 input 클릭 (이벤트 핸들러 격발 유도)
const select = page.locator('.admin-editor-panel .mud-select').filter({ hasText: '사업 유형' }).first();
await page.waitForTimeout(1500);
await select.locator('input').click();
// 대화상자(MudDialog) 자체의 노출 대기
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
// mud-select 컨테이너 자체 클릭 (이벤트 핸들러 직접 격발)
const select = page.locator('.mud-select').filter({ hasText: '사업 유형' }).first();
await select.evaluate(el => (el as HTMLDivElement).click());
// 활성화된 팝오버(.mud-popover-open) 내에서 텍스트 노출 검증
const popover = page.locator('.mud-popover-open');
@@ -150,13 +152,12 @@ test.describe('admin CRM pages', () => {
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
const addButton = page.getByRole('button', { name: /새 일정 추가/ });
await addButton.click();
await addButton.evaluate(el => (el as HTMLButtonElement).click());
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
const select = page.locator('.admin-editor-panel .mud-select').filter({ hasText: '신고 유형' }).first();
await page.waitForTimeout(1500);
await select.locator('input').click();
const select = page.locator('.mud-select').filter({ hasText: '신고 유형' }).first();
await select.evaluate(el => (el as HTMLDivElement).click());
const popover = page.locator('.mud-popover-open');
await expect(popover.getByText('종합소득세')).toBeVisible({ timeout: 5000 });
@@ -168,17 +169,15 @@ test.describe('admin CRM pages', () => {
await expect(page.locator('.admin-grid, .mud-alert')).toBeVisible({ timeout: 15000 });
const addButton = page.getByRole('button', { name: /새 계약 추가/ });
await addButton.click();
await addButton.evaluate(el => (el as HTMLButtonElement).click());
await expect(page.locator('.admin-editor-panel')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.mud-dialog')).toBeVisible({ timeout: 5000 });
const select = page.locator('.admin-editor-panel .mud-select').filter({ hasText: '서비스 유형' }).first();
await page.waitForTimeout(1500);
await select.locator('input').click();
const select = page.locator('.mud-select').filter({ hasText: '서비스 유형' }).first();
await select.evaluate(el => (el as HTMLDivElement).click());
const popover = page.locator('.mud-popover-open');
await expect(popover.getByText('개인 기장대리')).toBeVisible({ timeout: 5000 });
await expect(popover.getByText('법인 기장대리')).toBeVisible({ timeout: 5000 });
});
}
);
});
+2 -2
View File
@@ -15,7 +15,7 @@ const TEST_PASSWORD = process.env.E2E_ADMIN_PASSWORD || 'TestAdmin@123456';
* - 운영(localhost): http://localhost/taxbaik (Nginx 라우팅 → active 포트)
* - 로컬 직접 테스트: http://127.0.0.1:5001/taxbaik (개발 포트)
*/
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://localhost/taxbaik').replace(/\/$/, '');
/**
* API를 통한 테스트 데이터 생성
@@ -121,7 +121,7 @@ test.describe('admin responsive design (test_admin account)', () => {
return window.getComputedStyle(el).fontSize;
});
const size = parseFloat(fontSize);
expect(size).toBeGreaterThanOrEqual(9.5); // ERP 최소 9.5px
expect(size).toBeGreaterThanOrEqual(11); // 최소 11px
}
console.log(`${device.name} - PASS`);