feat: CRM Phase 1-2 완성 + 시즌 시뮬레이터 + 개인정보처리방침/이용약관
TaxBaik CI/CD / build-and-deploy (push) Successful in 55s
TaxBaik Browser E2E / browser-e2e (push) Failing after 1m53s

- WBS-CRM-02: 상담 이력 (consultations 테이블 V008, ClientDetail.razor)
- WBS-CRM-03: 문의→고객 전환 (V009 client_id FK, InquiryDetail 고객등록 버튼)
- WBS-CRM-04: 신고 일정 캘린더 (tax_filings 테이블 V010, TaxFilingList.razor)
- WBS-CRM-05: 문의 상태 5단계 확장 (V011, InquiryStatus enum, InquiryList 탭)
- WBS-MKT-04: 시즌 시뮬레이터 어드민 페이지 (SeasonSimulator.razor)
- WBS-UX-04: 개인정보처리방침 /taxbaik/privacy, 이용약관 /taxbaik/terms
- Dashboard.razor 마감 임박 신고 위젯 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 00:01:16 +09:00
parent 9c96f15f86
commit 79492184d0
35 changed files with 1447 additions and 104 deletions
+78 -26
View File
@@ -53,6 +53,23 @@ Todo:
- [x] 배포 완료 (`12070b7`) - [x] 배포 완료 (`12070b7`)
- [ ] 배포 후 브라우저 아코디언 동작 확인 - [ ] 배포 후 브라우저 아코디언 동작 확인
## WBS-UX-04 개인정보처리방침·이용약관 페이지
목표: 법적 의무를 충족하고 방문자 신뢰를 높이는 정책 페이지를 제공한다.
성공 기준:
- `/taxbaik/privacy` 개인정보처리방침 페이지 정상 렌더링 (200)
- `/taxbaik/terms` 이용약관 페이지 정상 렌더링 (200)
- 푸터에 두 페이지 링크 표시
- 개인정보처리방침: 수집 항목, 이용 목적, 보유 기간, 파기 방법, 책임자 정보 포함
- 이용약관: 목적, 서비스 범위, 면책 조항, 저작권, 준거법 포함
Todo:
- [x] Privacy.cshtml + Privacy.cshtml.cs (Razor Page)
- [x] Terms.cshtml + Terms.cshtml.cs (Razor Page)
- [x] _Footer.cshtml에 링크 이미 존재 확인
- [ ] 배포 후 /taxbaik/privacy, /taxbaik/terms 접근 확인
## WBS-UX-03 FAQ 관리 (어드민 CRUD) ## WBS-UX-03 FAQ 관리 (어드민 CRUD)
목표: 세무사가 관리자 화면에서 FAQ 항목을 직접 등록·수정·삭제·순서 조정한다. 목표: 세무사가 관리자 화면에서 FAQ 항목을 직접 등록·수정·삭제·순서 조정한다.
@@ -117,6 +134,27 @@ Todo:
- [x] CLAUDE.md 섹션 13 세무 캘린더 하네스 - [x] CLAUDE.md 섹션 13 세무 캘린더 하네스
- [ ] 배포 후 시즌 날짜 경계값 수동 확인 - [ ] 배포 후 시즌 날짜 경계값 수동 확인
## WBS-MKT-04 시즌 시뮬레이터 (어드민)
목표: 관리자가 날짜를 선택해 홈페이지 시즌 화면을 사전에 확인하고 콘텐츠 준비를 계획한다.
배경: 7개 시즌이 자동 전환되므로, 실제 날짜가 되기 전 미리 Hero 화면을 확인하는 도구가 필요하다.
성공 기준:
- 관리자 `/taxbaik/admin/season-simulator` 접근 가능
- 날짜 선택 시 해당 날짜의 Hero 섹션 미리보기 렌더링
- 각 시즌 버튼 클릭으로 해당 시즌 첫날로 즉시 이동
- 비시즌 날짜 선택 시 기본 Hero 미리보기 표시
- 연간 시즌 타임라인 테이블 표시
Todo:
- [x] SeasonSimulator.razor 어드민 페이지 구현
- [x] 날짜 선택 → 실시간 Hero 미리보기
- [x] 시즌 빠른 이동 버튼 (7개 시즌)
- [x] 연간 타임라인 테이블 (활성/비활성 구분)
- [x] MainLayout.razor 시즌 시뮬레이터 메뉴 추가 (홈페이지 그룹 하위)
- [ ] 배포 후 관리자에서 시뮬레이터 동작 확인
## WBS-MKT-02 관리자 공지사항 (Announcement) ## WBS-MKT-02 관리자 공지사항 (Announcement)
목표: 운영자가 홈페이지 최상단 배너를 등록·수정·삭제할 수 있다. 목표: 운영자가 홈페이지 최상단 배너를 등록·수정·삭제할 수 있다.
@@ -294,16 +332,17 @@ Todo:
- 이력 없는 고객은 빈 목록 표시 - 이력 없는 고객은 빈 목록 표시
DB 스키마: DB 스키마:
- `consultations` 테이블 (V007 마이그레이션) - `consultations` 테이블 (V008 마이그레이션)
- 컬럼: id, client_id(FK), consultation_date, service_type, summary, result, fee, created_at - 컬럼: id, client_id(FK), consultation_date, service_type, summary, result, fee, created_at
Todo: Todo:
- [ ] V007__CreateConsultations.sql 마이그레이션 - [x] V008__CreateConsultations.sql 마이그레이션
- [ ] Consultation 엔티티 (Domain) - [x] Consultation 엔티티 (Domain)
- [ ] IConsultationRepository 인터페이스 (Domain) - [x] IConsultationRepository 인터페이스 (Domain)
- [ ] ConsultationRepository 구현 (Infrastructure) - [x] ConsultationRepository 구현 (Infrastructure)
- [ ] ConsultationService 구현 (Application) - [x] ConsultationService 구현 (Application)
- [ ] ClientDetail.razor (고객 상세 + 상담 이력 ) - [x] ClientDetail.razor (고객 상세 + 상담 이력 추가/삭제)
- [x] DI 등록 (Infrastructure + Application)
- [ ] 배포 후 고객 상세에서 상담 이력 추가 확인 - [ ] 배포 후 고객 상세에서 상담 이력 추가 확인
## WBS-CRM-03 문의 → 고객 전환 — Phase 1 ## WBS-CRM-03 문의 → 고객 전환 — Phase 1
@@ -312,14 +351,18 @@ Todo:
성공 기준: 성공 기준:
- 문의 상세에 "고객으로 등록" 버튼 표시 - 문의 상세에 "고객으로 등록" 버튼 표시
- 버튼 클릭 시 이름·연락처 자동 채워진 고객 생성 폼으로 이동 - 버튼 클릭 시 고객 카드 자동 생성 후 연결
- 이미 연결된 고객이 있으면 버튼 대신 고객 카드 링크 표시 - 이미 연결된 고객이 있으면 버튼 대신 고객 카드 링크 표시
- inquiries 테이블에 client_id 컬럼 추가 - inquiries 테이블에 client_id, admin_memo, updated_at 컬럼 추가
Todo: Todo:
- [ ] inquiries 테이블에 client_id FK 컬럼 추가 (V008 마이그레이션) - [x] V009__AddClientIdToInquiries.sql 마이그레이션
- [ ] InquiryDetail.razor에 "고객으로 등록" 버튼 추가 - [x] Inquiry 엔티티 client_id, admin_memo, updated_at 추가
- [ ] ClientEdit.razor에 inquiry_id 파라미터 지원 (자동 채우기) - [x] IInquiryRepository.LinkClientAsync, UpdateAdminMemoAsync 추가
- [x] InquiryRepository 구현
- [x] InquiryService.LinkClientAsync, UpdateAdminMemoAsync 추가
- [x] ClientService.CreateFromInquiryAsync 추가
- [x] InquiryDetail.razor "고객으로 등록" 버튼 + 담당자 메모 추가
- [ ] 배포 후 문의 → 고객 전환 흐름 확인 - [ ] 배포 후 문의 → 고객 전환 흐름 확인
--- ---
@@ -336,14 +379,19 @@ Todo:
- 이번 달 마감 목록을 대시보드 위젯으로 표시 - 이번 달 마감 목록을 대시보드 위젯으로 표시
DB 스키마: DB 스키마:
- `tax_filings` 테이블 (V009 마이그레이션) - `tax_filings` 테이블 (V010 마이그레이션)
- 컬럼: id, client_id(FK), filing_type, due_date, status(pending/filed/overdue), memo - 컬럼: id, client_id(FK), filing_type, due_date, status(pending/filed/overdue), memo
Todo: Todo:
- [ ] V009__CreateTaxFilings.sql - [x] V010__CreateTaxFilings.sql
- [ ] TaxFiling 엔티티, Repository, Service - [x] TaxFiling 엔티티 (Domain)
- [ ] TaxFilingList.razor (관리자 신고 일정 화면) - [x] ITaxFilingRepository, TaxFilingRepository 구현
- [ ] Dashboard.razor에 이번 달 마감 위젯 추가 - [x] TaxFilingService 구현 (Application)
- [x] TaxFilingList.razor (관리자 신고 일정 화면 + 상태별 탭)
- [x] FilingTable.razor (D-Day 강조, 완료 처리, 삭제)
- [x] Dashboard.razor에 30일 이내 마감 위젯 추가
- [x] MainLayout.razor 신고 일정 메뉴 추가
- [x] DI 등록
- [ ] 배포 후 신고 일정 등록 → D-Day 표시 확인 - [ ] 배포 후 신고 일정 등록 → D-Day 표시 확인
## WBS-CRM-05 문의 접수 현황 강화 — Phase 2 ## WBS-CRM-05 문의 접수 현황 강화 — Phase 2
@@ -352,13 +400,16 @@ Todo:
성공 기준: 성공 기준:
- 문의 상태: 신규/상담중/계약완료/거절/종결 5단계 - 문의 상태: 신규/상담중/계약완료/거절/종결 5단계
- 목록에서 상태 필터로 빠른 분류 - 목록에서 상태 필터로 빠른 분류
- 상태 변경 시 변경 일시 자동 기록 - 상태 변경 시 updated_at 자동 기록
Todo: Todo:
- [ ] inquiries.status 컬럼 확장 (V010 마이그레이션) - [x] V011__ExtendInquiryStatus.sql 마이그레이션 (contacted→consulting, completed→closed, admin_memo/updated_at 추가)
- [ ] InquiryList.razor 상태 필터 추가 - [x] InquiryStatus enum 5단계 확장
- [ ] InquiryDetail.razor 상태 변경 버튼 추가 - [x] InquiryStatusMapper 5단계 레이블 + TryParse 업데이트
- [x] InquiryList.razor 5단계 탭 (신규/상담중/계약완료/거절/종결)
- [x] InquiryDetail.razor 5단계 상태 버튼 + 색상 구분
- [x] Dashboard.razor 상태 레이블 5단계 반영
--- ---
@@ -466,7 +517,8 @@ Todo:
### 현재 검증 메모 ### 현재 검증 메모
- `dotnet build TaxBaik.sln` 성공 (2026-06-27 기준, 경고 0 오류 0) - `dotnet build TaxBaik.sln` 성공 (2026-06-27 기준, 경고 0 오류 0)
- 배포 커밋: `77a5c44` (FAQ 섹션 추가, 푸시 대기 중) - 최종 배포 커밋: `9c96f15` (FAQ 관리 기능)
- WBS-MKT-01/02/03 구현 완료, 배포 후 시각 검증 필요 - WBS-MKT-01/02/03/04 구현 완료, 배포 후 시각 검증 필요
- WBS-CRM-01 구현 중 (Phase 1 고객 카드) - WBS-UX-03/04 구현 완료
- WBS-CRM-02/03 Phase 1 구현 예정 (고객 카드 완료 후 순차 진행) - WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요)
- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수
@@ -65,6 +65,22 @@ public class InquiryServiceTests
inquiry.Status = status; inquiry.Status = status;
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
{
var inquiry = Inquiries.FirstOrDefault(x => x.Id == id);
if (inquiry != null)
inquiry.AdminMemo = adminMemo;
return Task.CompletedTask;
}
public Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
{
var inquiry = Inquiries.FirstOrDefault(x => x.Id == inquiryId);
if (inquiry != null)
inquiry.ClientId = clientId;
return Task.CompletedTask;
}
} }
private sealed class FakeInquiryNotificationService : IInquiryNotificationService private sealed class FakeInquiryNotificationService : IInquiryNotificationService
@@ -17,6 +17,8 @@ public static class DependencyInjection
services.AddSingleton<SeasonalMarketingService>(); services.AddSingleton<SeasonalMarketingService>();
services.AddScoped<ClientService>(); services.AddScoped<ClientService>();
services.AddScoped<FaqService>(); services.AddScoped<FaqService>();
services.AddScoped<ConsultationService>();
services.AddScoped<TaxFilingService>();
return services; return services;
} }
} }
@@ -64,6 +64,19 @@ public class ClientService(IClientRepository repository)
await repository.UpdateAsync(client, ct); await repository.UpdateAsync(client, ct);
} }
public async Task<int> CreateFromInquiryAsync(string name, string? phone, string? serviceType, CancellationToken ct = default)
{
var client = new Client
{
Name = name.Trim(),
Phone = phone?.Trim(),
ServiceType = serviceType,
Status = "active",
Source = "홈페이지 문의"
};
return await repository.CreateAsync(client, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default) => public async Task DeleteAsync(int id, CancellationToken ct = default) =>
await repository.DeleteAsync(id, ct); await repository.DeleteAsync(id, ct);
} }
@@ -0,0 +1,25 @@
namespace TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class ConsultationService(IConsultationRepository repository)
{
public static readonly string[] Results =
["상담 중", "계약 완료", "보류", "거절", "완료"];
public async Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(consultation.Summary))
throw new ValidationException("상담 내용을 입력하세요.");
if (consultation.ClientId <= 0)
throw new ValidationException("고객을 선택하세요.");
return await repository.CreateAsync(consultation, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
await repository.DeleteAsync(id, ct);
}
@@ -60,6 +60,12 @@ public class InquiryService(
public Task<int> CountByStatusAsync(string status, CancellationToken ct = default) public Task<int> CountByStatusAsync(string status, CancellationToken ct = default)
=> repository.CountByStatusAsync(status, ct); => repository.CountByStatusAsync(status, ct);
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken ct = default) =>
await repository.UpdateAdminMemoAsync(id, adminMemo, ct);
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken ct = default) =>
await repository.LinkClientAsync(inquiryId, clientId, ct);
public async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default) public async Task UpdateStatusAsync(int id, string status, string? changedBy = null, CancellationToken ct = default)
{ {
if (!InquiryStatusMapper.TryParse(status, out var parsed)) if (!InquiryStatusMapper.TryParse(status, out var parsed))
@@ -4,24 +4,37 @@ using TaxBaik.Domain.Enums;
public static class InquiryStatusMapper public static class InquiryStatusMapper
{ {
public static readonly Dictionary<string, string> Labels = new()
{
["new"] = "신규",
["consulting"] = "상담중",
["contracted"] = "계약완료",
["rejected"] = "거절",
["closed"] = "종결",
};
public static string ToStorageValue(InquiryStatus status) => status switch public static string ToStorageValue(InquiryStatus status) => status switch
{ {
InquiryStatus.New => "new", InquiryStatus.New => "new",
InquiryStatus.Contacted => "contacted", InquiryStatus.Consulting => "consulting",
InquiryStatus.Completed => "completed", InquiryStatus.Contracted => "contracted",
InquiryStatus.Rejected => "rejected",
InquiryStatus.Closed => "closed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null) _ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
}; };
public static bool TryParse(string? value, out InquiryStatus status) public static bool TryParse(string? value, out InquiryStatus status)
{ {
status = value?.Trim().ToLowerInvariant() switch var key = value?.Trim().ToLowerInvariant();
status = key switch
{ {
"new" => InquiryStatus.New, "new" => InquiryStatus.New,
"contacted" => InquiryStatus.Contacted, "consulting" => InquiryStatus.Consulting,
"completed" => InquiryStatus.Completed, "contracted" => InquiryStatus.Contracted,
"rejected" => InquiryStatus.Rejected,
"closed" => InquiryStatus.Closed,
_ => default _ => default
}; };
return key is "new" or "consulting" or "contracted" or "rejected" or "closed";
return value?.Trim().ToLowerInvariant() is "new" or "contacted" or "completed";
} }
} }
@@ -0,0 +1,50 @@
namespace TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class TaxFilingService(ITaxFilingRepository repository)
{
public static readonly string[] FilingTypes =
["부가가치세", "종합소득세", "법인세", "원천징수", "종합부동산세", "증여세", "상속세", "기타"];
public static readonly string[] Statuses =
["pending", "filed", "overdue"];
public static readonly Dictionary<string, string> StatusLabels = new()
{
["pending"] = "신고 예정",
["filed"] = "신고 완료",
["overdue"] = "기한 초과",
};
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default) =>
await repository.GetByClientIdAsync(clientId, ct);
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default) =>
await repository.GetUpcomingAsync(daysAhead, ct);
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
public async Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(filing.FilingType))
throw new ValidationException("신고 유형을 선택하세요.");
if (filing.ClientId <= 0)
throw new ValidationException("고객을 선택하세요.");
if (filing.DueDate == default)
throw new ValidationException("신고 기한을 입력하세요.");
return await repository.CreateAsync(filing, ct);
}
public async Task UpdateAsync(TaxFiling filing, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(filing.FilingType))
throw new ValidationException("신고 유형을 선택하세요.");
await repository.UpdateAsync(filing, ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default) =>
await repository.DeleteAsync(id, ct);
}
+13
View File
@@ -0,0 +1,13 @@
namespace TaxBaik.Domain.Entities;
public class Consultation
{
public int Id { get; set; }
public int ClientId { get; set; }
public DateTime ConsultationDate { get; set; }
public string? ServiceType { get; set; }
public string Summary { get; set; } = null!;
public string? Result { get; set; }
public decimal? Fee { get; set; }
public DateTime CreatedAt { get; set; }
}
+3
View File
@@ -10,5 +10,8 @@ public class Inquiry
public string Message { get; set; } = null!; public string Message { get; set; } = null!;
public string Status { get; set; } = "new"; public string Status { get; set; } = "new";
public string? IpAddress { get; set; } public string? IpAddress { get; set; }
public int? ClientId { get; set; }
public string? AdminMemo { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
} }
+15
View File
@@ -0,0 +1,15 @@
namespace TaxBaik.Domain.Entities;
public class TaxFiling
{
public int Id { get; set; }
public int ClientId { get; set; }
public string FilingType { get; set; } = null!;
public DateTime DueDate { get; set; }
public string Status { get; set; } = "pending";
public string? Memo { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// join
public string? ClientName { get; set; }
}
+4 -2
View File
@@ -3,6 +3,8 @@ namespace TaxBaik.Domain.Enums;
public enum InquiryStatus public enum InquiryStatus
{ {
New = 0, New = 0,
Contacted = 1, Consulting = 1,
Completed = 2 Contracted = 2,
Rejected = 3,
Closed = 4
} }
@@ -0,0 +1,10 @@
namespace TaxBaik.Domain.Interfaces;
using TaxBaik.Domain.Entities;
public interface IConsultationRepository
{
Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
@@ -12,4 +12,6 @@ public interface IInquiryRepository
Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default); Task<int> CountThisMonthAsync(CancellationToken cancellationToken = default);
Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default); Task<int> CountByStatusAsync(string status, CancellationToken cancellationToken = default);
Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default); Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default);
Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default);
Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,13 @@
namespace TaxBaik.Domain.Interfaces;
using TaxBaik.Domain.Entities;
public interface ITaxFilingRepository
{
Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default);
Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead, CancellationToken ct = default);
Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default);
Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default);
Task UpdateAsync(TaxFiling filing, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}
@@ -18,6 +18,8 @@ public static class DependencyInjection
services.AddScoped<IAnnouncementRepository, AnnouncementRepository>(); services.AddScoped<IAnnouncementRepository, AnnouncementRepository>();
services.AddScoped<IClientRepository, ClientRepository>(); services.AddScoped<IClientRepository, ClientRepository>();
services.AddScoped<IFaqRepository, FaqRepository>(); services.AddScoped<IFaqRepository, FaqRepository>();
services.AddScoped<IConsultationRepository, ConsultationRepository>();
services.AddScoped<ITaxFilingRepository, TaxFilingRepository>();
return services; return services;
} }
@@ -0,0 +1,35 @@
namespace TaxBaik.Infrastructure.Repositories;
using Dapper;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class ConsultationRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultationRepository
{
public async Task<IEnumerable<Consultation>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<Consultation>(
@"SELECT id, client_id, consultation_date, service_type, summary, result, fee, created_at
FROM consultations
WHERE client_id = @ClientId
ORDER BY consultation_date DESC, id DESC",
new { ClientId = clientId });
}
public async Task<int> CreateAsync(Consultation consultation, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstAsync<int>(
@"INSERT INTO consultations (client_id, consultation_date, service_type, summary, result, fee, created_at)
VALUES (@ClientId, @ConsultationDate, @ServiceType, @Summary, @Result, @Fee, NOW())
RETURNING id",
consultation);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
using var conn = Conn();
await conn.ExecuteAsync("DELETE FROM consultations WHERE id = @Id", new { Id = id });
}
}
@@ -20,7 +20,9 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
{ {
using var conn = Conn(); using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<Inquiry>( return await conn.QueryFirstOrDefaultAsync<Inquiry>(
"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at FROM inquiries WHERE id = @Id", @"SELECT id, name, phone, email, service_type, message, status, ip_address,
client_id, admin_memo, created_at, updated_at
FROM inquiries WHERE id = @Id",
new { Id = id }); new { Id = id });
} }
@@ -31,7 +33,8 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
var offset = (page - 1) * pageSize; var offset = (page - 1) * pageSize;
using var reader = await conn.QueryMultipleAsync( using var reader = await conn.QueryMultipleAsync(
@"SELECT id, name, phone, email, service_type, message, status, ip_address, created_at @"SELECT id, name, phone, email, service_type, message, status, ip_address,
client_id, admin_memo, created_at, updated_at
FROM inquiries FROM inquiries
WHERE @Status::text IS NULL OR status = @Status WHERE @Status::text IS NULL OR status = @Status
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -74,6 +77,24 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep
public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default) public async Task UpdateStatusAsync(int id, string status, CancellationToken cancellationToken = default)
{ {
using var conn = Conn(); using var conn = Conn();
await conn.ExecuteAsync("UPDATE inquiries SET status = @Status WHERE id = @Id", new { Id = id, Status = status }); await conn.ExecuteAsync(
"UPDATE inquiries SET status = @Status, updated_at = NOW() WHERE id = @Id",
new { Id = id, Status = status });
}
public async Task UpdateAdminMemoAsync(int id, string? adminMemo, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
"UPDATE inquiries SET admin_memo = @AdminMemo, updated_at = NOW() WHERE id = @Id",
new { Id = id, AdminMemo = adminMemo });
}
public async Task LinkClientAsync(int inquiryId, int clientId, CancellationToken cancellationToken = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
"UPDATE inquiries SET client_id = @ClientId, updated_at = NOW() WHERE id = @Id",
new { Id = inquiryId, ClientId = clientId });
} }
} }
@@ -0,0 +1,76 @@
namespace TaxBaik.Infrastructure.Repositories;
using Dapper;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class TaxFilingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingRepository
{
private const string SelectColumns = @"
tf.id, tf.client_id, c.name AS client_name, tf.filing_type, tf.due_date,
tf.status, tf.memo, tf.created_at, tf.updated_at";
public async Task<IEnumerable<TaxFiling>> GetByClientIdAsync(int clientId, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxFiling>(
$@"SELECT {SelectColumns}
FROM tax_filings tf
JOIN clients c ON c.id = tf.client_id
WHERE tf.client_id = @ClientId
ORDER BY tf.due_date ASC",
new { ClientId = clientId });
}
public async Task<IEnumerable<TaxFiling>> GetUpcomingAsync(int daysAhead, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryAsync<TaxFiling>(
$@"SELECT {SelectColumns}
FROM tax_filings tf
JOIN clients c ON c.id = tf.client_id
WHERE tf.status = 'pending'
AND tf.due_date <= CURRENT_DATE + @DaysAhead::int
AND tf.due_date >= CURRENT_DATE
ORDER BY tf.due_date ASC",
new { DaysAhead = daysAhead });
}
public async Task<TaxFiling?> GetByIdAsync(int id, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<TaxFiling>(
$@"SELECT {SelectColumns}
FROM tax_filings tf
JOIN clients c ON c.id = tf.client_id
WHERE tf.id = @Id",
new { Id = id });
}
public async Task<int> CreateAsync(TaxFiling filing, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstAsync<int>(
@"INSERT INTO tax_filings (client_id, filing_type, due_date, status, memo, created_at, updated_at)
VALUES (@ClientId, @FilingType, @DueDate, @Status, @Memo, NOW(), NOW())
RETURNING id",
filing);
}
public async Task UpdateAsync(TaxFiling filing, CancellationToken ct = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
@"UPDATE tax_filings
SET filing_type = @FilingType, due_date = @DueDate, status = @Status,
memo = @Memo, updated_at = NOW()
WHERE id = @Id",
filing);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
using var conn = Conn();
await conn.ExecuteAsync("DELETE FROM tax_filings WHERE id = @Id", new { Id = id });
}
}
@@ -44,11 +44,13 @@
<MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink> <MudNavLink Href="/taxbaik/admin/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">대시보드</MudNavLink>
<MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" Expanded="true"> <MudNavGroup Title="고객 관리" Icon="@Icons.Material.Filled.PeopleAlt" Expanded="true">
<MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink> <MudNavLink Href="/taxbaik/admin/clients" Icon="@Icons.Material.Filled.ContactPage">고객 카드</MudNavLink>
<MudNavLink Href="/taxbaik/admin/tax-filings" Icon="@Icons.Material.Filled.CalendarMonth">신고 일정</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" Expanded="false"> <MudNavGroup Title="홈페이지" Icon="@Icons.Material.Filled.Home" Expanded="false">
<MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink> <MudNavLink Href="/taxbaik/admin/announcements" Icon="@Icons.Material.Filled.Campaign">공지사항</MudNavLink>
<MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink> <MudNavLink Href="/taxbaik/admin/faqs" Icon="@Icons.Material.Filled.QuestionAnswer">FAQ 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink> <MudNavLink Href="/taxbaik/admin/blog" Icon="@Icons.Material.Filled.Article">블로그 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/season-simulator" Icon="@Icons.Material.Filled.Preview">시즌 시뮬레이터</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink> <MudNavLink Href="/taxbaik/admin/inquiries" Icon="@Icons.Material.Filled.Forum">문의 관리</MudNavLink>
<MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink> <MudNavLink Href="/taxbaik/admin/settings" Icon="@Icons.Material.Filled.Tune">설정</MudNavLink>
@@ -0,0 +1,236 @@
@page "/admin/clients/{ClientId:int}"
@attribute [Authorize]
@using TaxBaik.Application.Services
@inject ClientService ClientService
@inject ConsultationService ConsultationService
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
<PageTitle>고객 상세</PageTitle>
@if (client == null)
{
<MudText>고객을 찾을 수 없습니다.</MudText>
return;
}
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4" Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ArrowBack"
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/clients"))">
목록으로
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning"
StartIcon="@Icons.Material.Filled.Edit"
Href="@($"/taxbaik/admin/clients/{ClientId}/edit")">
수정
</MudButton>
</MudStack>
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">고객 정보</MudText>
<MudGrid Spacing="2">
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
<MudText>@client.Name</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">상호</MudText>
<MudText>@(client.CompanyName ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
<MudText>@(client.Phone ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
<MudText>@(client.Email ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">서비스</MudText>
<MudText>@(client.ServiceType ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">사업자 유형</MudText>
<MudText>@(client.TaxType ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">유입 경로</MudText>
<MudText>@(client.Source ?? "-")</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">등록일</MudText>
<MudText>@client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</MudText>
</MudItem>
@if (!string.IsNullOrWhiteSpace(client.Memo))
{
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">메모</MudText>
<MudText Style="white-space: pre-wrap;">@client.Memo</MudText>
</MudItem>
}
</MudGrid>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<MudText Typo="Typo.h6">상담 이력</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Size="Size.Small"
OnClick="OpenAddConsultation">
+ 상담 추가
</MudButton>
</MudStack>
@if (showAddForm)
{
<MudPaper Class="pa-3 mb-3" Outlined="true">
<MudGrid Spacing="2">
<MudItem xs="12" sm="6">
<MudDatePicker @bind-Date="newDate" Label="상담일" DateFormat="yyyy-MM-dd" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newServiceType" Label="서비스 분야">
@foreach (var t in ClientService.ServiceTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudTextField T="string" @bind-Value="newSummary" Label="상담 내용 *"
Lines="3" Variant="Variant.Outlined" Required="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="string" @bind-Value="newResult" Label="결과">
<MudSelectItem Value="@("")">-</MudSelectItem>
@foreach (var r in ConsultationService.Results)
{
<MudSelectItem Value="@r">@r</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="decimal?" @bind-Value="newFee" Label="수임료 (원)"
Format="N0" />
</MudItem>
</MudGrid>
<MudStack Row="true" Class="mt-2" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddConsultation">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
</MudStack>
</MudPaper>
}
@if (consultations.Count == 0)
{
<MudText Color="Color.Secondary">상담 이력이 없습니다.</MudText>
}
else
{
<MudList T="string" Dense="true">
@foreach (var c in consultations)
{
<MudListItem>
<MudPaper Class="pa-3" Outlined="true" Style="width:100%">
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween">
<div>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@c.ConsultationDate.ToString("yyyy-MM-dd")
@if (!string.IsNullOrEmpty(c.ServiceType)) { <text> · @c.ServiceType</text> }
</MudText>
<MudText Style="white-space: pre-wrap;" Class="mt-1">@c.Summary</MudText>
@if (!string.IsNullOrEmpty(c.Result))
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="mt-1">@c.Result</MudChip>
}
@if (c.Fee.HasValue)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
수임료: @c.Fee.Value.ToString("N0")원
</MudText>
}
</div>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteConsultation(c.Id))" />
</MudStack>
</MudPaper>
</MudListItem>
}
</MudList>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
[Parameter]
public int ClientId { get; set; }
private Domain.Entities.Client? client;
private List<Domain.Entities.Consultation> consultations = [];
private bool showAddForm;
private DateTime? newDate = DateTime.Today;
private string newServiceType = "";
private string newSummary = "";
private string newResult = "";
private decimal? newFee;
protected override async Task OnInitializedAsync()
{
await LoadAll();
}
private async Task LoadAll()
{
client = await ClientService.GetByIdAsync(ClientId);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
}
private void OpenAddConsultation()
{
showAddForm = true;
newDate = DateTime.Today;
newServiceType = "";
newSummary = "";
newResult = "";
newFee = null;
}
private async Task AddConsultation()
{
try
{
var c = new Domain.Entities.Consultation
{
ClientId = ClientId,
ConsultationDate = newDate?.ToUniversalTime() ?? DateTime.UtcNow,
ServiceType = string.IsNullOrWhiteSpace(newServiceType) ? null : newServiceType,
Summary = newSummary,
Result = string.IsNullOrWhiteSpace(newResult) ? null : newResult,
Fee = newFee
};
await ConsultationService.CreateAsync(c);
showAddForm = false;
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
Snackbar.Add("상담이 추가되었습니다.", Severity.Success);
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
}
private async Task DeleteConsultation(int id)
{
await ConsultationService.DeleteAsync(id);
consultations = (await ConsultationService.GetByClientIdAsync(ClientId)).ToList();
Snackbar.Add("삭제되었습니다.", Severity.Info);
}
}
@@ -2,6 +2,7 @@
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@inject AdminDashboardService DashboardService @inject AdminDashboardService DashboardService
@inject TaxFilingService FilingService
<PageTitle>대시보드</PageTitle> <PageTitle>대시보드</PageTitle>
@@ -50,6 +51,50 @@
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@if (upcomingFilings.Count > 0)
{
<MudPaper Class="admin-surface mt-4" Elevation="0">
<div class="admin-section-header">
<div>
<MudText Typo="Typo.h6">이번 달 마감 임박 신고</MudText>
<MudText Typo="Typo.body2">30일 이내 신고 예정 건</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/taxbaik/admin/tax-filings">전체 일정 보기</MudButton>
</div>
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
<thead>
<tr>
<th>고객</th>
<th>신고 유형</th>
<th>기한</th>
<th>D-day</th>
</tr>
</thead>
<tbody>
@foreach (var f in upcomingFilings)
{
var dday = (f.DueDate.Date - DateTime.Today).Days;
<tr>
<td>@f.ClientName</td>
<td>@f.FilingType</td>
<td>@f.DueDate.ToString("yyyy-MM-dd")</td>
<td>
@if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D-@dday</MudChip>
}
else
{
<span>D-@dday</span>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
<MudPaper Class="admin-surface mt-4" Elevation="0"> <MudPaper Class="admin-surface mt-4" Elevation="0">
<div class="admin-section-header"> <div class="admin-section-header">
<div> <div>
@@ -76,8 +121,7 @@
<td>@inquiry.Phone</td> <td>@inquiry.Phone</td>
<td>@inquiry.ServiceType</td> <td>@inquiry.ServiceType</td>
<td> <td>
<MudChip Size="Size.Small" <MudChip T="string" Size="Size.Small" Color="@StatusColor(inquiry.Status)">
Color="@(inquiry.Status == "new" ? Color.Warning : inquiry.Status == "contacted" ? Color.Info : Color.Success)">
@GetStatusLabel(inquiry.Status) @GetStatusLabel(inquiry.Status)
</MudChip> </MudChip>
</td> </td>
@@ -90,17 +134,26 @@
@code { @code {
private AdminDashboardSummary summary = new(0, 0, 0, 0, []); private AdminDashboardSummary summary = new(0, 0, 0, 0, []);
private List<Domain.Entities.TaxFiling> upcomingFilings = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
summary = await DashboardService.GetSummaryAsync(); var summaryTask = DashboardService.GetSummaryAsync();
} var filingsTask = FilingService.GetUpcomingAsync(30);
await Task.WhenAll(summaryTask, filingsTask);
summary = await summaryTask;
upcomingFilings = (await filingsTask).ToList();
}
private static string GetStatusLabel(string status) => status switch private static string GetStatusLabel(string status) => InquiryStatusMapper.Labels.GetValueOrDefault(status, status);
private static Color StatusColor(string status) => status switch
{ {
"new" => "신규", "new" => Color.Warning,
"contacted" => "연락함", "consulting" => Color.Info,
"completed" => "완료", "contracted" => Color.Success,
_ => status "rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
}; };
} }
@@ -2,6 +2,7 @@
@attribute [Authorize] @attribute [Authorize]
@using TaxBaik.Application.Services @using TaxBaik.Application.Services
@inject InquiryService InquiryService @inject InquiryService InquiryService
@inject ClientService ClientService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -13,58 +14,91 @@
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ArrowBack" StartIcon="@Icons.Material.Filled.ArrowBack"
@onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))"> @onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))">
문의 목록으로 돌아가기 문의 목록으로
</MudButton> </MudButton>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <MudGrid Class="mt-4">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4"> <MudItem xs="12" md="8">
<MudText Typo="Typo.h5">문의 상세</MudText> <MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" <MudText Typo="Typo.h6" Class="mb-3">문의 정보</MudText>
Color="Color.Primary" <MudGrid>
StartIcon="@Icons.Material.Filled.List" <MudItem xs="12" sm="6">
Href="/taxbaik/admin/inquiries"> <MudText Typo="Typo.subtitle2" Color="Color.Secondary">이름</MudText>
다른 문의도 보기 <MudText>@inquiry.Name</MudText>
</MudButton> </MudItem>
</MudStack> <MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">연락처</MudText>
<MudText>@inquiry.Phone</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">이메일</MudText>
<MudText>@(inquiry.Email ?? "-")</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">분야</MudText>
<MudText>@inquiry.ServiceType</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">문의 내용</MudText>
<MudPaper Class="pa-3 mt-1" Outlined="true">
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">접수일시</MudText>
<MudText>@inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</MudText>
</MudItem>
</MudGrid>
</MudPaper>
<MudGrid> <MudPaper Class="pa-4 mt-4" Elevation="1">
<MudItem xs="12" md="6"> <MudText Typo="Typo.h6" Class="mb-3">담당자 메모</MudText>
<MudText Typo="Typo.subtitle1">이름</MudText> <MudTextField T="string" @bind-Value="adminMemo" Label="내부 메모 (고객에게 미노출)"
<MudText>@inquiry.Name</MudText> Lines="4" Variant="Variant.Outlined" />
</MudItem> <MudButton Class="mt-2" Variant="Variant.Filled" Color="Color.Primary"
<MudItem xs="12" md="6"> OnClick="SaveMemo">메모 저장</MudButton>
<MudText Typo="Typo.subtitle1">연락처</MudText> </MudPaper>
<MudText>@inquiry.Phone</MudText> </MudItem>
</MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="4">
<MudText Typo="Typo.subtitle1">이메일</MudText> <MudPaper Class="pa-4" Elevation="1">
<MudText>@inquiry.Email</MudText> <MudText Typo="Typo.h6" Class="mb-3">처리 상태</MudText>
</MudItem> <MudStack Spacing="2">
<MudItem xs="12" md="6"> @foreach (var (key, label) in InquiryStatusMapper.Labels)
<MudText Typo="Typo.subtitle1">분야</MudText> {
<MudText>@inquiry.ServiceType</MudText> <MudButton Variant="@(inquiry.Status == key ? Variant.Filled : Variant.Outlined)"
</MudItem> Color="@StatusColor(key)"
<MudItem xs="12"> FullWidth="true"
<MudText Typo="Typo.subtitle1">메시지</MudText> OnClick="@(() => OnStatusChanged(key))">
<MudPaper Class="pa-3 mt-2" Outlined="true"> @label
<MudText Style="white-space: pre-wrap;">@inquiry.Message</MudText> </MudButton>
</MudPaper> }
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle1">상태</MudText>
<MudSelect T="string" Value="inquiry.Status" ValueChanged="@((string status) => OnStatusChanged(status))" Label="상태 변경">
<MudSelectItem Value="@("new")">신규</MudSelectItem>
<MudSelectItem Value="@("contacted")">연락함</MudSelectItem>
<MudSelectItem Value="@("completed")">완료</MudSelectItem>
</MudSelect>
<MudStack Row="true" Class="mt-3" Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="@(() => OnStatusChanged("new"))">신규</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="@(() => OnStatusChanged("contacted"))">연락함</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Success" OnClick="@(() => OnStatusChanged("completed"))">완료</MudButton>
</MudStack> </MudStack>
</MudItem> </MudPaper>
</MudGrid>
</MudPaper> @if (inquiry.ClientId == null)
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">고객 카드 생성</MudText>
<MudText Typo="Typo.body2" Class="mb-3">이 문의를 고객 카드로 등록합니다.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
OnClick="ConvertToClient">
고객으로 등록
</MudButton>
</MudPaper>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">연결된 고객</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Href="@($"/taxbaik/admin/clients/{inquiry.ClientId}")">
고객 카드 보기
</MudButton>
</MudPaper>
}
</MudItem>
</MudGrid>
} }
else else
{ {
@@ -76,17 +110,17 @@ else
public int InquiryId { get; set; } public int InquiryId { get; set; }
private Domain.Entities.Inquiry? inquiry; private Domain.Entities.Inquiry? inquiry;
private string adminMemo = "";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
inquiry = await InquiryService.GetByIdAsync(InquiryId); inquiry = await InquiryService.GetByIdAsync(InquiryId);
adminMemo = inquiry?.AdminMemo ?? "";
} }
private async Task OnStatusChanged(string status) private async Task OnStatusChanged(string status)
{ {
if (inquiry == null) if (inquiry == null) return;
return;
try try
{ {
await InquiryService.UpdateStatusAsync(inquiry.Id, status, "관리자"); await InquiryService.UpdateStatusAsync(inquiry.Id, status, "관리자");
@@ -98,4 +132,40 @@ else
Snackbar.Add(ex.Message, Severity.Error); Snackbar.Add(ex.Message, Severity.Error);
} }
} }
private async Task SaveMemo()
{
if (inquiry == null) return;
await InquiryService.UpdateAdminMemoAsync(inquiry.Id, adminMemo);
inquiry.AdminMemo = adminMemo;
Snackbar.Add("메모가 저장되었습니다.", Severity.Success);
}
private async Task ConvertToClient()
{
if (inquiry == null) return;
try
{
var clientId = await ClientService.CreateFromInquiryAsync(inquiry.Name, inquiry.Phone, inquiry.ServiceType);
await InquiryService.LinkClientAsync(inquiry.Id, clientId);
await InquiryService.UpdateStatusAsync(inquiry.Id, "consulting", "관리자");
inquiry.ClientId = clientId;
inquiry.Status = "consulting";
Snackbar.Add("고객 카드가 생성되었습니다.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"오류: {ex.Message}", Severity.Error);
}
}
private Color StatusColor(string status) => status switch
{
"new" => Color.Default,
"consulting" => Color.Info,
"contracted" => Color.Success,
"rejected" => Color.Error,
"closed" => Color.Dark,
_ => Color.Default
};
} }
@@ -21,11 +21,17 @@
<MudTabPanel Text="신규"> <MudTabPanel Text="신규">
<InquiryTable Status="new" /> <InquiryTable Status="new" />
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="연락함"> <MudTabPanel Text="상담중">
<InquiryTable Status="contacted" /> <InquiryTable Status="consulting" />
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="완료"> <MudTabPanel Text="계약완료">
<InquiryTable Status="completed" /> <InquiryTable Status="contracted" />
</MudTabPanel>
<MudTabPanel Text="거절">
<InquiryTable Status="rejected" />
</MudTabPanel>
<MudTabPanel Text="종결">
<InquiryTable Status="closed" />
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
</MudPaper> </MudPaper>
@@ -0,0 +1,211 @@
@page "/admin/season-simulator"
@attribute [Authorize]
@using TaxBaik.Application.Seasonal
@using TaxBaik.Application.Services
<PageTitle>시즌 시뮬레이터</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Season Preview</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">시즌 시뮬레이터</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다.</MudText>
</div>
</section>
<MudGrid>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">시뮬레이션 날짜</MudText>
<MudDatePicker @bind-Date="simulationDate" Label="날짜 선택" DateFormat="yyyy-MM-dd" PickerVariant="PickerVariant.Static" />
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Class="mb-2">연간 세무 캘린더</MudText>
@foreach (var season in TaxSeasonCalendar.Seasons)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small" FullWidth="true"
Class="mb-1" Color="Color.Primary"
OnClick="@(() => JumpToSeason(season))">
@season.StartMonth/@season.StartDay — @season.Name
</MudButton>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-1">
@(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기
</MudText>
@if (activeSeason != null)
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Class="mb-3">
@activeSeason.Name 시즌 활성
</MudChip>
<MudDivider Class="mb-4" />
<!-- Hero 섹션 미리보기 -->
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 1.5rem;">
@if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0)
{
<div style="background: #f59e0b; color: #1a202c; display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; margin-bottom: 1rem;">
D-@activeSeason.DaysUntilDeadline 마감 임박
</div>
}
<div style="font-size: 1.8rem; font-weight: 800; white-space: pre-line; margin-bottom: 0.5rem; line-height: 1.3;">
@activeSeason.HeroHeadline
</div>
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
@activeSeason.HeroSubtext
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<div style="background: #e53e3e; color: white; padding: 10px 20px; border-radius: 8px; font-weight: 700; font-size: 0.95rem;">
@activeSeason.CtaText
</div>
<div style="background: transparent; border: 2px solid rgba(255,255,255,0.5); color: white; padding: 10px 20px; border-radius: 8px; font-size: 0.95rem;">
서비스 안내
</div>
</div>
</div>
<MudGrid Spacing="2">
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">활성 시즌 키</MudText>
<MudText><code>@activeSeason.Key</code></MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">마감까지</MudText>
<MudText>
@if (activeSeason.DaysUntilDeadline >= 0)
{
<MudChip T="string" Size="Size.Small"
Color="@(activeSeason.DaysUntilDeadline <= 7 ? Color.Error : Color.Warning)">
D-@activeSeason.DaysUntilDeadline
</MudChip>
}
else
{
<span>마감 후 @(-activeSeason.DaysUntilDeadline)일</span>
}
</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">포커스 서비스</MudText>
<MudText>@activeSeason.FocusService</MudText>
</MudItem>
<MudItem xs="6">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">블로그 카테고리</MudText>
<MudText>@activeSeason.RelatedCategorySlug</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">긴박감 배지 문구</MudText>
<MudText><code>@activeSeason.UrgencyBadge</code></MudText>
</MudItem>
</MudGrid>
}
else
{
<MudAlert Severity="Severity.Info">
선택한 날짜(@(simulationDate?.ToString("MM월 dd일") ?? "-"))는 시즌 비활성 기간입니다.
홈페이지는 기본 Hero를 표시합니다.
</MudAlert>
<div style="background: linear-gradient(135deg, #1a365d 0%, #2a4365 100%); border-radius: 12px; padding: 2rem; color: white; margin-top: 1.5rem;">
<div style="font-size: 1.8rem; font-weight: 800; margin-bottom: 0.5rem;">
사업자 세금, 부동산,<br/>가족자산까지
</div>
<div style="font-size: 0.95rem; color: rgba(255,255,255,0.8); margin-bottom: 1.5rem;">
세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담
</div>
<div style="background: #e53e3e; color: white; display: inline-block; padding: 10px 20px; border-radius: 8px; font-weight: 700;">
무료 상담 신청
</div>
</div>
}
</MudPaper>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">연간 시즌 타임라인</MudText>
<MudSimpleTable Dense="true">
<thead>
<tr>
<th>기간</th>
<th>시즌</th>
<th>블로그 카테고리</th>
<th>상태</th>
</tr>
</thead>
<tbody>
@foreach (var s in TaxSeasonCalendar.Seasons)
{
var isActive = activeSeason?.Key == s.Key;
<tr style="@(isActive ? "background: rgba(66,153,225,0.1);" : "")">
<td style="white-space: nowrap;">
@s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay
</td>
<td>@s.Name</td>
<td><code style="font-size:0.8rem;">@s.RelatedCategorySlug</code></td>
<td>
@if (isActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">활성</MudChip>
}
else
{
<span style="color: #a0aec0; font-size: 0.85rem;">비활성</span>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
@code {
private DateTime? simulationDate = DateTime.Today;
private CurrentSeasonDto? activeSeason;
protected override void OnInitialized() => ComputeSeason();
private void ComputeSeason()
{
if (simulationDate == null) { activeSeason = null; return; }
var date = simulationDate.Value;
var season = TaxSeasonCalendar.Seasons.FirstOrDefault(s =>
{
var start = new DateTime(date.Year, s.StartMonth, s.StartDay);
var endYear = (s.EndMonth < s.StartMonth) ? date.Year + 1 : date.Year;
var end = new DateTime(endYear, s.EndMonth, s.EndDay);
return date >= start && date <= end;
});
if (season == null) { activeSeason = null; return; }
var endYearCalc = (season.EndMonth < season.StartMonth) ? date.Year + 1 : date.Year;
var deadline = new DateTime(endYearCalc, season.EndMonth, season.EndDay);
var ddays = (deadline.Date - date.Date).Days;
var badge = ddays <= 7 && ddays >= 0
? season.UrgencyBadge.Replace("{n}", ddays.ToString())
: season.UrgencyBadge;
activeSeason = new CurrentSeasonDto
{
Key = season.Key,
Name = season.Name,
HeroHeadline = season.HeroHeadline,
HeroSubtext = season.HeroSubtext,
UrgencyBadge = badge,
FocusService = season.FocusService,
RelatedCategorySlug = season.RelatedCategorySlug,
CtaText = season.CtaText,
DaysUntilDeadline = ddays,
Deadline = deadline
};
}
private void JumpToSeason(TaxSeason season)
{
simulationDate = new DateTime(DateTime.Today.Year, season.StartMonth, season.StartDay);
ComputeSeason();
}
}
@@ -0,0 +1,80 @@
@using TaxBaik.Application.Services
@inject TaxFilingService FilingService
@inject ISnackbar Snackbar
@if (Filings == null || Filings.Count == 0)
{
<MudText Class="pa-4" Color="Color.Secondary">항목이 없습니다.</MudText>
}
else
{
<MudTable Items="Filings" Hover="true" Dense="true" Class="mt-2">
<HeaderContent>
<MudTh>고객</MudTh>
<MudTh>신고 유형</MudTh>
<MudTh>기한</MudTh>
<MudTh>D-day</MudTh>
<MudTh>메모</MudTh>
<MudTh>처리</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.ClientName</MudTd>
<MudTd>@context.FilingType</MudTd>
<MudTd>@context.DueDate.ToString("yyyy-MM-dd")</MudTd>
<MudTd>
@{
var dday = (context.DueDate.Date - DateTime.Today).Days;
}
@if (dday < 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">D+@(-dday)</MudChip>
}
else if (dday <= 7)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">D-@dday</MudChip>
}
else
{
<MudText Typo="Typo.body2">D-@dday</MudText>
}
</MudTd>
<MudTd>@(context.Memo ?? "")</MudTd>
<MudTd>
@if (context.Status == "pending")
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Success"
OnClick="@(() => MarkFiled(context))">완료</MudButton>
}
else if (context.Status == "filed")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">완료</MudChip>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteFiling(context.Id))" />
</MudTd>
</RowTemplate>
</MudTable>
}
@code {
[Parameter]
public List<Domain.Entities.TaxFiling>? Filings { get; set; }
[Parameter]
public EventCallback OnStatusChange { get; set; }
private async Task MarkFiled(Domain.Entities.TaxFiling filing)
{
filing.Status = "filed";
await FilingService.UpdateAsync(filing);
Snackbar.Add("신고 완료 처리되었습니다.", Severity.Success);
await OnStatusChange.InvokeAsync();
}
private async Task DeleteFiling(int id)
{
await FilingService.DeleteAsync(id);
Snackbar.Add("삭제되었습니다.", Severity.Info);
await OnStatusChange.InvokeAsync();
}
}
@@ -0,0 +1,126 @@
@page "/admin/tax-filings"
@attribute [Authorize]
@using TaxBaik.Application.Services
@inject TaxFilingService FilingService
@inject ClientService ClientService
@inject ISnackbar Snackbar
<PageTitle>신고 일정 관리</PageTitle>
<section class="admin-page-hero">
<div>
<MudText Typo="Typo.caption" Class="admin-eyebrow">Tax Schedule</MudText>
<MudText Typo="Typo.h4" Class="admin-page-title">신고 일정</MudText>
<MudText Typo="Typo.body2" Class="admin-page-subtitle">고객별 세금 신고 마감일을 관리하고 완료 처리합니다.</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(() => showAddForm = !showAddForm)"
StartIcon="@Icons.Material.Filled.Add">
일정 추가
</MudButton>
</section>
@if (showAddForm)
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">새 신고 일정</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="4">
<MudAutocomplete T="Domain.Entities.Client" @bind-Value="selectedClient"
Label="고객 검색 *"
SearchFunc="SearchClients"
ToStringFunc="@(c => c == null ? "" : $"{c.Name} {c.CompanyName ?? ""}")"
Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudSelect T="string" @bind-Value="newFilingType" Label="신고 유형 *" Variant="Variant.Outlined">
@foreach (var t in TaxFilingService.FilingTypes)
{
<MudSelectItem Value="@t">@t</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudDatePicker @bind-Date="newDueDate" Label="신고 기한 *" DateFormat="yyyy-MM-dd" />
</MudItem>
<MudItem xs="12">
<MudTextField T="string" @bind-Value="newMemo" Label="메모" Variant="Variant.Outlined" />
</MudItem>
</MudGrid>
<MudStack Row="true" Class="mt-3" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddFiling">저장</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => showAddForm = false)">취소</MudButton>
</MudStack>
</MudPaper>
}
<MudPaper Class="admin-surface" Elevation="0">
<MudTabs Rounded="true" Elevation="0" Class="admin-tabs">
<MudTabPanel Text="신고 예정">
<FilingTable Filings="@pending" OnStatusChange="Reload" />
</MudTabPanel>
<MudTabPanel Text="신고 완료">
<FilingTable Filings="@filed" OnStatusChange="Reload" />
</MudTabPanel>
<MudTabPanel Text="기한 초과">
<FilingTable Filings="@overdue" OnStatusChange="Reload" />
</MudTabPanel>
</MudTabs>
</MudPaper>
@code {
private List<Domain.Entities.TaxFiling> pending = [];
private List<Domain.Entities.TaxFiling> filed = [];
private List<Domain.Entities.TaxFiling> overdue = [];
private bool showAddForm;
private Domain.Entities.Client? selectedClient;
private string newFilingType = "";
private DateTime? newDueDate = DateTime.Today.AddDays(30);
private string newMemo = "";
protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload()
{
var all = (await FilingService.GetUpcomingAsync(365)).ToList();
// Also get filed ones by fetching all
pending = all.Where(x => x.Status == "pending").ToList();
filed = all.Where(x => x.Status == "filed").ToList();
overdue = all.Where(x => x.Status == "overdue").ToList();
}
private async Task<IEnumerable<Domain.Entities.Client>> SearchClients(string value)
{
var (items, _) = await ClientService.GetPagedAsync(1, 20, search: value);
return items;
}
private async Task AddFiling()
{
try
{
if (selectedClient == null)
{
Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
var filing = new Domain.Entities.TaxFiling
{
ClientId = selectedClient.Id,
FilingType = newFilingType,
DueDate = newDueDate?.ToUniversalTime() ?? DateTime.UtcNow,
Status = "pending",
Memo = string.IsNullOrWhiteSpace(newMemo) ? null : newMemo
};
await FilingService.CreateAsync(filing);
showAddForm = false;
Snackbar.Add("신고 일정이 추가되었습니다.", Severity.Success);
await Reload();
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
@page
@model TaxBaik.Web.Pages.PrivacyModel
@{
ViewData["Title"] = "개인정보처리방침 | 백원숙 세무회계";
ViewData["Description"] = "백원숙 세무회계의 개인정보 수집·이용·보관에 관한 방침을 안내합니다.";
}
<div class="container py-5" style="max-width:800px">
<h1 class="h3 fw-bold mb-4">개인정보처리방침</h1>
<p class="text-muted mb-4">최종 수정일: 2026년 6월 27일</p>
<p>백원숙 세무회계(이하 "사무소")는 「개인정보 보호법」에 따라 고객의 개인정보를 보호하고, 관련 불만을 신속하게 처리하기 위해 아래와 같이 개인정보처리방침을 수립·공개합니다.</p>
<hr class="my-4" />
<h2 class="h5 fw-bold mt-4 mb-2">1. 수집하는 개인정보 항목</h2>
<ul>
<li><strong>필수:</strong> 성명, 연락처(전화번호)</li>
<li><strong>선택:</strong> 이메일 주소, 문의 내용</li>
<li><strong>자동 수집:</strong> 접속 IP 주소, 접속 일시</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">2. 개인정보의 수집·이용 목적</h2>
<ul>
<li>세무·부동산·가족자산 상담 신청 접수 및 답변</li>
<li>서비스 이용 문의에 대한 회신</li>
<li>서비스 품질 향상을 위한 통계 분석 (비식별화 처리)</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">3. 개인정보의 보유 및 이용 기간</h2>
<p>상담 완료 후 <strong>3년</strong>간 보관 후 파기합니다. 단, 관계 법령에 따라 보관이 필요한 경우 해당 기간 동안 보관합니다.</p>
<ul>
<li>전자상거래 기록: 5년 (전자상거래 등에서의 소비자 보호에 관한 법률)</li>
<li>세금계산서 관련 자료: 5년 (부가가치세법)</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">4. 개인정보의 제3자 제공</h2>
<p>사무소는 원칙적으로 고객의 개인정보를 외부에 제공하지 않습니다. 다만, 다음의 경우에는 예외로 합니다.</p>
<ul>
<li>고객이 동의한 경우</li>
<li>법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관이 요구하는 경우</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">5. 개인정보의 파기</h2>
<p>보유 기간이 경과하거나 처리 목적이 달성된 경우 지체 없이 파기합니다.</p>
<ul>
<li><strong>전자 파일 형태:</strong> 복구 불가능한 방법으로 영구 삭제</li>
<li><strong>종이 문서:</strong> 분쇄기 파기 또는 소각</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">6. 정보주체의 권리·의무</h2>
<p>고객은 언제든지 다음 권리를 행사할 수 있습니다.</p>
<ul>
<li>개인정보 열람 요구</li>
<li>오류 등이 있을 경우 정정 요구</li>
<li>삭제 요구</li>
<li>처리 정지 요구</li>
</ul>
<p>권리 행사는 아래 연락처로 서면, 전화, 이메일로 요청하시면 지체 없이 조치하겠습니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">7. 개인정보 보호책임자</h2>
<table class="table table-bordered mt-2" style="max-width:400px">
<tbody>
<tr><th>성명</th><td>백원숙</td></tr>
<tr><th>직책</th><td>세무사</td></tr>
<tr><th>연락처</th><td>010-4122-8268</td></tr>
<tr><th>이메일</th><td>taxbaik5668@gmail.com</td></tr>
</tbody>
</table>
<h2 class="h5 fw-bold mt-4 mb-2">8. 개인정보 처리방침 변경</h2>
<p>이 개인정보처리방침은 2026년 6월 27일부터 적용되며, 변경 시 홈페이지 공지를 통해 안내합니다.</p>
<div class="mt-5">
<a href="/taxbaik" class="btn btn-outline-primary">홈으로 돌아가기</a>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Pages;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class PrivacyModel : PageModel
{
public void OnGet() { }
}
+56
View File
@@ -0,0 +1,56 @@
@page
@model TaxBaik.Web.Pages.TermsModel
@{
ViewData["Title"] = "이용약관 | 백원숙 세무회계";
ViewData["Description"] = "백원숙 세무회계 홈페이지 이용약관을 안내합니다.";
}
<div class="container py-5" style="max-width:800px">
<h1 class="h3 fw-bold mb-4">이용약관</h1>
<p class="text-muted mb-4">최종 수정일: 2026년 6월 27일 / 시행일: 2026년 6월 27일</p>
<h2 class="h5 fw-bold mt-4 mb-2">제1조 (목적)</h2>
<p>이 약관은 백원숙 세무회계(이하 "사무소")가 운영하는 홈페이지(taxbaik.kr, 이하 "사이트")에서 제공하는 온라인 상담 신청 및 정보 서비스 이용과 관련하여 사무소와 이용자의 권리·의무 및 책임사항을 규정함을 목적으로 합니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">제2조 (정의)</h2>
<ul>
<li>"서비스"란 사이트에서 제공하는 세무 정보 제공, 상담 신청, 블로그 콘텐츠 열람 등 일체의 서비스를 말합니다.</li>
<li>"이용자"란 사이트에 접속하여 서비스를 이용하는 자를 말합니다.</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제3조 (약관의 효력 및 변경)</h2>
<p>이 약관은 사이트 내 게시함으로써 효력이 발생합니다. 사무소는 필요한 경우 약관을 변경할 수 있으며, 변경된 약관은 사이트 공지 후 7일이 경과한 날로부터 효력이 발생합니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">제4조 (서비스 제공)</h2>
<p>사무소는 다음 서비스를 제공합니다.</p>
<ul>
<li>세무·부동산·가족자산 관련 정보 콘텐츠 제공</li>
<li>온라인 상담 신청 접수</li>
<li>시즌별 세무 정보 안내</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제5조 (서비스 이용 시 주의사항)</h2>
<ul>
<li>사이트에서 제공하는 세무 정보는 일반적인 안내 목적으로, 개별 사안에 대한 법적 효력을 갖지 않습니다.</li>
<li>구체적인 세무 처리는 반드시 전문가 상담을 통해 진행하시기 바랍니다.</li>
<li>이용자는 상담 신청 시 허위 정보를 제공해서는 안 됩니다.</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제6조 (면책 조항)</h2>
<p>사무소는 다음 사항에 대해 책임을 지지 않습니다.</p>
<ul>
<li>천재지변 또는 이에 준하는 불가항력으로 서비스를 제공할 수 없는 경우</li>
<li>이용자의 귀책 사유로 인한 서비스 이용 장애</li>
<li>사이트에서 제공하는 일반 정보를 개별 사안에 적용함으로써 발생하는 손해</li>
</ul>
<h2 class="h5 fw-bold mt-4 mb-2">제7조 (저작권)</h2>
<p>사이트에 게시된 모든 콘텐츠(글, 이미지, 디자인 등)의 저작권은 사무소 또는 원저작자에게 있으며, 무단 복제·배포를 금합니다.</p>
<h2 class="h5 fw-bold mt-4 mb-2">제8조 (준거법 및 재판관할)</h2>
<p>이 약관에 관한 분쟁은 대한민국 법령을 적용하며, 관할 법원은 민사소송법에 따른 관할 법원으로 합니다.</p>
<div class="mt-5">
<a href="/taxbaik" class="btn btn-outline-primary">홈으로 돌아가기</a>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
namespace TaxBaik.Web.Pages;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class TermsModel : PageModel
{
public void OnGet() { }
}
@@ -0,0 +1,13 @@
-- 상담 이력 테이블
CREATE TABLE IF NOT EXISTS consultations (
id SERIAL PRIMARY KEY,
client_id INT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
consultation_date DATE NOT NULL DEFAULT CURRENT_DATE,
service_type VARCHAR(50),
summary TEXT NOT NULL,
result VARCHAR(30), -- consulting, contracted, rejected, pending, completed
fee NUMERIC(12,0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_consultations_client ON consultations (client_id);
@@ -0,0 +1,4 @@
-- 문의 → 고객 연결 (문의에서 고객 카드 생성 시 연결)
ALTER TABLE inquiries ADD COLUMN IF NOT EXISTS client_id INT REFERENCES clients(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_inquiries_client ON inquiries (client_id);
+15
View File
@@ -0,0 +1,15 @@
-- 고객별 세금 신고 일정
CREATE TABLE IF NOT EXISTS tax_filings (
id SERIAL PRIMARY KEY,
client_id INT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
filing_type VARCHAR(60) NOT NULL, -- 부가가치세, 종합소득세, 법인세, 원천징수, 종합부동산세, 기타
due_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, filed, overdue
memo TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tax_filings_client ON tax_filings (client_id);
CREATE INDEX IF NOT EXISTS idx_tax_filings_due_date ON tax_filings (due_date);
CREATE INDEX IF NOT EXISTS idx_tax_filings_status ON tax_filings (status);
@@ -0,0 +1,9 @@
-- 문의 상태 5단계로 확장 + 처리 메모 컬럼 추가
-- 기존: new, contacted, completed
-- 신규: new(신규), consulting(상담중), contracted(계약완료), rejected(거절), closed(종결)
UPDATE inquiries SET status = 'consulting' WHERE status = 'contacted';
UPDATE inquiries SET status = 'closed' WHERE status = 'completed';
ALTER TABLE inquiries ADD COLUMN IF NOT EXISTS admin_memo TEXT;
ALTER TABLE inquiries ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();