From 79492184d0dc4f47e220bb914c1512d4362b1df9 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 28 Jun 2026 00:01:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CRM=20Phase=201-2=20=EC=99=84=EC=84=B1?= =?UTF-8?q?=20+=20=EC=8B=9C=EC=A6=8C=20=EC=8B=9C=EB=AE=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20+=20=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=B9=A8/=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=EC=95=BD=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP_WBS.md | 104 ++++++-- .../InquiryServiceTests.cs | 16 ++ TaxBaik.Application/DependencyInjection.cs | 2 + TaxBaik.Application/Services/ClientService.cs | 13 + .../Services/ConsultationService.cs | 25 ++ .../Services/InquiryService.cs | 6 + .../Services/InquiryStatusMapper.cs | 31 ++- .../Services/TaxFilingService.cs | 50 ++++ TaxBaik.Domain/Entities/Consultation.cs | 13 + TaxBaik.Domain/Entities/Inquiry.cs | 3 + TaxBaik.Domain/Entities/TaxFiling.cs | 15 ++ TaxBaik.Domain/Enums/InquiryStatus.cs | 6 +- .../Interfaces/IConsultationRepository.cs | 10 + .../Interfaces/IInquiryRepository.cs | 2 + .../Interfaces/ITaxFilingRepository.cs | 13 + TaxBaik.Infrastructure/DependencyInjection.cs | 2 + .../Repositories/ConsultationRepository.cs | 35 +++ .../Repositories/InquiryRepository.cs | 27 +- .../Repositories/TaxFilingRepository.cs | 76 ++++++ .../Components/Admin/Layout/MainLayout.razor | 2 + .../Admin/Pages/Clients/ClientDetail.razor | 236 ++++++++++++++++++ .../Components/Admin/Pages/Dashboard.razor | 71 +++++- .../Admin/Pages/Inquiries/InquiryDetail.razor | 172 +++++++++---- .../Admin/Pages/Inquiries/InquiryList.razor | 14 +- .../Admin/Pages/SeasonSimulator.razor | 211 ++++++++++++++++ .../Admin/Pages/TaxFilings/FilingTable.razor | 80 ++++++ .../Pages/TaxFilings/TaxFilingList.razor | 126 ++++++++++ TaxBaik.Web/Pages/Privacy.cshtml | 77 ++++++ TaxBaik.Web/Pages/Privacy.cshtml.cs | 8 + TaxBaik.Web/Pages/Terms.cshtml | 56 +++++ TaxBaik.Web/Pages/Terms.cshtml.cs | 8 + db/migrations/V008__CreateConsultations.sql | 13 + .../V009__AddClientIdToInquiries.sql | 4 + db/migrations/V010__CreateTaxFilings.sql | 15 ++ db/migrations/V011__ExtendInquiryStatus.sql | 9 + 35 files changed, 1447 insertions(+), 104 deletions(-) create mode 100644 TaxBaik.Application/Services/ConsultationService.cs create mode 100644 TaxBaik.Application/Services/TaxFilingService.cs create mode 100644 TaxBaik.Domain/Entities/Consultation.cs create mode 100644 TaxBaik.Domain/Entities/TaxFiling.cs create mode 100644 TaxBaik.Domain/Interfaces/IConsultationRepository.cs create mode 100644 TaxBaik.Domain/Interfaces/ITaxFilingRepository.cs create mode 100644 TaxBaik.Infrastructure/Repositories/ConsultationRepository.cs create mode 100644 TaxBaik.Infrastructure/Repositories/TaxFilingRepository.cs create mode 100644 TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor create mode 100644 TaxBaik.Web/Components/Admin/Pages/SeasonSimulator.razor create mode 100644 TaxBaik.Web/Components/Admin/Pages/TaxFilings/FilingTable.razor create mode 100644 TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor create mode 100644 TaxBaik.Web/Pages/Privacy.cshtml create mode 100644 TaxBaik.Web/Pages/Privacy.cshtml.cs create mode 100644 TaxBaik.Web/Pages/Terms.cshtml create mode 100644 TaxBaik.Web/Pages/Terms.cshtml.cs create mode 100644 db/migrations/V008__CreateConsultations.sql create mode 100644 db/migrations/V009__AddClientIdToInquiries.sql create mode 100644 db/migrations/V010__CreateTaxFilings.sql create mode 100644 db/migrations/V011__ExtendInquiryStatus.sql diff --git a/ROADMAP_WBS.md b/ROADMAP_WBS.md index d771019..a909972 100644 --- a/ROADMAP_WBS.md +++ b/ROADMAP_WBS.md @@ -53,6 +53,23 @@ Todo: - [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) 목표: 세무사가 관리자 화면에서 FAQ 항목을 직접 등록·수정·삭제·순서 조정한다. @@ -117,6 +134,27 @@ Todo: - [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) 목표: 운영자가 홈페이지 최상단 배너를 등록·수정·삭제할 수 있다. @@ -294,16 +332,17 @@ Todo: - 이력 없는 고객은 빈 목록 표시 DB 스키마: -- `consultations` 테이블 (V007 마이그레이션) +- `consultations` 테이블 (V008 마이그레이션) - 컬럼: id, client_id(FK), consultation_date, service_type, summary, result, fee, created_at Todo: -- [ ] V007__CreateConsultations.sql 마이그레이션 -- [ ] Consultation 엔티티 (Domain) -- [ ] IConsultationRepository 인터페이스 (Domain) -- [ ] ConsultationRepository 구현 (Infrastructure) -- [ ] ConsultationService 구현 (Application) -- [ ] ClientDetail.razor (고객 상세 + 상담 이력 탭) +- [x] V008__CreateConsultations.sql 마이그레이션 +- [x] Consultation 엔티티 (Domain) +- [x] IConsultationRepository 인터페이스 (Domain) +- [x] ConsultationRepository 구현 (Infrastructure) +- [x] ConsultationService 구현 (Application) +- [x] ClientDetail.razor (고객 상세 + 상담 이력 추가/삭제) +- [x] DI 등록 (Infrastructure + Application) - [ ] 배포 후 고객 상세에서 상담 이력 추가 확인 ## WBS-CRM-03 문의 → 고객 전환 — Phase 1 @@ -312,14 +351,18 @@ Todo: 성공 기준: - 문의 상세에 "고객으로 등록" 버튼 표시 -- 버튼 클릭 시 이름·연락처 자동 채워진 고객 생성 폼으로 이동 +- 버튼 클릭 시 고객 카드 자동 생성 후 연결 - 이미 연결된 고객이 있으면 버튼 대신 고객 카드 링크 표시 -- inquiries 테이블에 client_id 컬럼 추가 +- inquiries 테이블에 client_id, admin_memo, updated_at 컬럼 추가 Todo: -- [ ] inquiries 테이블에 client_id FK 컬럼 추가 (V008 마이그레이션) -- [ ] InquiryDetail.razor에 "고객으로 등록" 버튼 추가 -- [ ] ClientEdit.razor에 inquiry_id 파라미터 지원 (자동 채우기) +- [x] V009__AddClientIdToInquiries.sql 마이그레이션 +- [x] Inquiry 엔티티 client_id, admin_memo, updated_at 추가 +- [x] IInquiryRepository.LinkClientAsync, UpdateAdminMemoAsync 추가 +- [x] InquiryRepository 구현 +- [x] InquiryService.LinkClientAsync, UpdateAdminMemoAsync 추가 +- [x] ClientService.CreateFromInquiryAsync 추가 +- [x] InquiryDetail.razor "고객으로 등록" 버튼 + 담당자 메모 추가 - [ ] 배포 후 문의 → 고객 전환 흐름 확인 --- @@ -336,14 +379,19 @@ Todo: - 이번 달 마감 목록을 대시보드 위젯으로 표시 DB 스키마: -- `tax_filings` 테이블 (V009 마이그레이션) +- `tax_filings` 테이블 (V010 마이그레이션) - 컬럼: id, client_id(FK), filing_type, due_date, status(pending/filed/overdue), memo Todo: -- [ ] V009__CreateTaxFilings.sql -- [ ] TaxFiling 엔티티, Repository, Service -- [ ] TaxFilingList.razor (관리자 신고 일정 화면) -- [ ] Dashboard.razor에 이번 달 마감 위젯 추가 +- [x] V010__CreateTaxFilings.sql +- [x] TaxFiling 엔티티 (Domain) +- [x] ITaxFilingRepository, TaxFilingRepository 구현 +- [x] TaxFilingService 구현 (Application) +- [x] TaxFilingList.razor (관리자 신고 일정 화면 + 상태별 탭) +- [x] FilingTable.razor (D-Day 강조, 완료 처리, 삭제) +- [x] Dashboard.razor에 30일 이내 마감 위젯 추가 +- [x] MainLayout.razor 신고 일정 메뉴 추가 +- [x] DI 등록 - [ ] 배포 후 신고 일정 등록 → D-Day 표시 확인 ## WBS-CRM-05 문의 접수 현황 강화 — Phase 2 @@ -352,13 +400,16 @@ Todo: 성공 기준: - 문의 상태: 신규/상담중/계약완료/거절/종결 5단계 -- 목록에서 상태 칩 필터로 빠른 분류 -- 상태 변경 시 변경 일시 자동 기록 +- 목록에서 상태 탭 필터로 빠른 분류 +- 상태 변경 시 updated_at 자동 기록 Todo: -- [ ] inquiries.status 컬럼 확장 (V010 마이그레이션) -- [ ] InquiryList.razor 상태 필터 추가 -- [ ] InquiryDetail.razor 상태 변경 버튼 추가 +- [x] V011__ExtendInquiryStatus.sql 마이그레이션 (contacted→consulting, completed→closed, admin_memo/updated_at 추가) +- [x] InquiryStatus enum 5단계 확장 +- [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) -- 배포 커밋: `77a5c44` (FAQ 섹션 추가, 푸시 대기 중) -- WBS-MKT-01/02/03 구현 완료, 배포 후 시각 검증 필요 -- WBS-CRM-01 구현 중 (Phase 1 고객 카드) -- WBS-CRM-02/03 Phase 1 구현 예정 (고객 카드 완료 후 순차 진행) +- 최종 배포 커밋: `9c96f15` (FAQ 관리 기능) +- WBS-MKT-01/02/03/04 구현 완료, 배포 후 시각 검증 필요 +- WBS-UX-03/04 구현 완료 +- WBS-CRM-01/02/03/04/05 구현 완료 (배포 후 검증 필요) +- WBS-CRM-06/07/08 (텔레그램·포털·소셜 로그인) Phase 3 미착수 diff --git a/TaxBaik.Application.Tests/InquiryServiceTests.cs b/TaxBaik.Application.Tests/InquiryServiceTests.cs index a410b9b..61aa6ab 100644 --- a/TaxBaik.Application.Tests/InquiryServiceTests.cs +++ b/TaxBaik.Application.Tests/InquiryServiceTests.cs @@ -65,6 +65,22 @@ public class InquiryServiceTests inquiry.Status = status; 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 diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index da52111..5bc5e9a 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -17,6 +17,8 @@ public static class DependencyInjection services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/TaxBaik.Application/Services/ClientService.cs b/TaxBaik.Application/Services/ClientService.cs index eed66e7..b026a4d 100644 --- a/TaxBaik.Application/Services/ClientService.cs +++ b/TaxBaik.Application/Services/ClientService.cs @@ -64,6 +64,19 @@ public class ClientService(IClientRepository repository) await repository.UpdateAsync(client, ct); } + public async Task 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) => await repository.DeleteAsync(id, ct); } diff --git a/TaxBaik.Application/Services/ConsultationService.cs b/TaxBaik.Application/Services/ConsultationService.cs new file mode 100644 index 0000000..0d5bd4c --- /dev/null +++ b/TaxBaik.Application/Services/ConsultationService.cs @@ -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> GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task 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); +} diff --git a/TaxBaik.Application/Services/InquiryService.cs b/TaxBaik.Application/Services/InquiryService.cs index 849699b..b2c2510 100644 --- a/TaxBaik.Application/Services/InquiryService.cs +++ b/TaxBaik.Application/Services/InquiryService.cs @@ -60,6 +60,12 @@ public class InquiryService( public Task CountByStatusAsync(string status, CancellationToken ct = default) => 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) { if (!InquiryStatusMapper.TryParse(status, out var parsed)) diff --git a/TaxBaik.Application/Services/InquiryStatusMapper.cs b/TaxBaik.Application/Services/InquiryStatusMapper.cs index 912d741..8073e1e 100644 --- a/TaxBaik.Application/Services/InquiryStatusMapper.cs +++ b/TaxBaik.Application/Services/InquiryStatusMapper.cs @@ -4,24 +4,37 @@ using TaxBaik.Domain.Enums; public static class InquiryStatusMapper { + public static readonly Dictionary Labels = new() + { + ["new"] = "신규", + ["consulting"] = "상담중", + ["contracted"] = "계약완료", + ["rejected"] = "거절", + ["closed"] = "종결", + }; + public static string ToStorageValue(InquiryStatus status) => status switch { - InquiryStatus.New => "new", - InquiryStatus.Contacted => "contacted", - InquiryStatus.Completed => "completed", + InquiryStatus.New => "new", + InquiryStatus.Consulting => "consulting", + InquiryStatus.Contracted => "contracted", + InquiryStatus.Rejected => "rejected", + InquiryStatus.Closed => "closed", _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) }; 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, - "contacted" => InquiryStatus.Contacted, - "completed" => InquiryStatus.Completed, + "new" => InquiryStatus.New, + "consulting" => InquiryStatus.Consulting, + "contracted" => InquiryStatus.Contracted, + "rejected" => InquiryStatus.Rejected, + "closed" => InquiryStatus.Closed, _ => default }; - - return value?.Trim().ToLowerInvariant() is "new" or "contacted" or "completed"; + return key is "new" or "consulting" or "contracted" or "rejected" or "closed"; } } diff --git a/TaxBaik.Application/Services/TaxFilingService.cs b/TaxBaik.Application/Services/TaxFilingService.cs new file mode 100644 index 0000000..cbf25a1 --- /dev/null +++ b/TaxBaik.Application/Services/TaxFilingService.cs @@ -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 StatusLabels = new() + { + ["pending"] = "신고 예정", + ["filed"] = "신고 완료", + ["overdue"] = "기한 초과", + }; + + public async Task> GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task> GetUpcomingAsync(int daysAhead = 30, CancellationToken ct = default) => + await repository.GetUpcomingAsync(daysAhead, ct); + + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await repository.GetByIdAsync(id, ct); + + public async Task 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); +} diff --git a/TaxBaik.Domain/Entities/Consultation.cs b/TaxBaik.Domain/Entities/Consultation.cs new file mode 100644 index 0000000..eeb8c9d --- /dev/null +++ b/TaxBaik.Domain/Entities/Consultation.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Entities/Inquiry.cs b/TaxBaik.Domain/Entities/Inquiry.cs index c839ef7..8e72729 100644 --- a/TaxBaik.Domain/Entities/Inquiry.cs +++ b/TaxBaik.Domain/Entities/Inquiry.cs @@ -10,5 +10,8 @@ public class Inquiry public string Message { get; set; } = null!; public string Status { get; set; } = "new"; public string? IpAddress { get; set; } + public int? ClientId { get; set; } + public string? AdminMemo { get; set; } public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } } diff --git a/TaxBaik.Domain/Entities/TaxFiling.cs b/TaxBaik.Domain/Entities/TaxFiling.cs new file mode 100644 index 0000000..ef8cb2d --- /dev/null +++ b/TaxBaik.Domain/Entities/TaxFiling.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Enums/InquiryStatus.cs b/TaxBaik.Domain/Enums/InquiryStatus.cs index afe67e0..1d99ec1 100644 --- a/TaxBaik.Domain/Enums/InquiryStatus.cs +++ b/TaxBaik.Domain/Enums/InquiryStatus.cs @@ -3,6 +3,8 @@ namespace TaxBaik.Domain.Enums; public enum InquiryStatus { New = 0, - Contacted = 1, - Completed = 2 + Consulting = 1, + Contracted = 2, + Rejected = 3, + Closed = 4 } diff --git a/TaxBaik.Domain/Interfaces/IConsultationRepository.cs b/TaxBaik.Domain/Interfaces/IConsultationRepository.cs new file mode 100644 index 0000000..72bb702 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IConsultationRepository.cs @@ -0,0 +1,10 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IConsultationRepository +{ + Task> GetByClientIdAsync(int clientId, CancellationToken ct = default); + Task CreateAsync(Consultation consultation, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); +} diff --git a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs index ed23e56..b8fc471 100644 --- a/TaxBaik.Domain/Interfaces/IInquiryRepository.cs +++ b/TaxBaik.Domain/Interfaces/IInquiryRepository.cs @@ -12,4 +12,6 @@ public interface IInquiryRepository Task CountThisMonthAsync(CancellationToken cancellationToken = default); Task CountByStatusAsync(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); } diff --git a/TaxBaik.Domain/Interfaces/ITaxFilingRepository.cs b/TaxBaik.Domain/Interfaces/ITaxFilingRepository.cs new file mode 100644 index 0000000..d887960 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/ITaxFilingRepository.cs @@ -0,0 +1,13 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface ITaxFilingRepository +{ + Task> GetByClientIdAsync(int clientId, CancellationToken ct = default); + Task> GetUpcomingAsync(int daysAhead, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task CreateAsync(TaxFiling filing, CancellationToken ct = default); + Task UpdateAsync(TaxFiling filing, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index 60c6231..0b112e8 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -18,6 +18,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/TaxBaik.Infrastructure/Repositories/ConsultationRepository.cs b/TaxBaik.Infrastructure/Repositories/ConsultationRepository.cs new file mode 100644 index 0000000..d115d89 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/ConsultationRepository.cs @@ -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> GetByClientIdAsync(int clientId, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"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 CreateAsync(Consultation consultation, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"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 }); + } +} diff --git a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs index ab8d6f5..ab8e514 100644 --- a/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/InquiryRepository.cs @@ -20,7 +20,9 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep { using var conn = Conn(); return await conn.QueryFirstOrDefaultAsync( - "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 }); } @@ -31,7 +33,8 @@ public class InquiryRepository(IDbConnectionFactory connectionFactory) : BaseRep var offset = (page - 1) * pageSize; 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 WHERE @Status::text IS NULL OR status = @Status 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) { 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 }); } } diff --git a/TaxBaik.Infrastructure/Repositories/TaxFilingRepository.cs b/TaxBaik.Infrastructure/Repositories/TaxFilingRepository.cs new file mode 100644 index 0000000..aaeadac --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/TaxFilingRepository.cs @@ -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> GetByClientIdAsync(int clientId, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + $@"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> GetUpcomingAsync(int daysAhead, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + $@"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 GetByIdAsync(int id, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + $@"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 CreateAsync(TaxFiling filing, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"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 }); + } +} diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index 2e6fd23..f573f4c 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -44,11 +44,13 @@ 대시보드 고객 카드 + 신고 일정 공지사항 FAQ 관리 블로그 관리 + 시즌 시뮬레이터 문의 관리 설정 diff --git a/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor new file mode 100644 index 0000000..edce688 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Clients/ClientDetail.razor @@ -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 + +고객 상세 + +@if (client == null) +{ + 고객을 찾을 수 없습니다. + return; +} + + + + 목록으로 + + + 수정 + + + + + + + 고객 정보 + + + 이름 + @client.Name + + + 상호 + @(client.CompanyName ?? "-") + + + 연락처 + @(client.Phone ?? "-") + + + 이메일 + @(client.Email ?? "-") + + + 서비스 + @(client.ServiceType ?? "-") + + + 사업자 유형 + @(client.TaxType ?? "-") + + + 유입 경로 + @(client.Source ?? "-") + + + 등록일 + @client.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd") + + @if (!string.IsNullOrWhiteSpace(client.Memo)) + { + + 메모 + @client.Memo + + } + + + + + + + + 상담 이력 + + + 상담 추가 + + + + @if (showAddForm) + { + + + + + + + + @foreach (var t in ClientService.ServiceTypes) + { + @t + } + + + + + + + + - + @foreach (var r in ConsultationService.Results) + { + @r + } + + + + + + + + 저장 + 취소 + + + } + + @if (consultations.Count == 0) + { + 상담 이력이 없습니다. + } + else + { + + @foreach (var c in consultations) + { + + + +
+ + @c.ConsultationDate.ToString("yyyy-MM-dd") + @if (!string.IsNullOrEmpty(c.ServiceType)) { · @c.ServiceType } + + @c.Summary + @if (!string.IsNullOrEmpty(c.Result)) + { + @c.Result + } + @if (c.Fee.HasValue) + { + + 수임료: @c.Fee.Value.ToString("N0")원 + + } +
+ +
+
+
+ } +
+ } +
+
+
+ +@code { + [Parameter] + public int ClientId { get; set; } + + private Domain.Entities.Client? client; + private List 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); + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index 9e13ae6..636b084 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @using TaxBaik.Application.Services @inject AdminDashboardService DashboardService +@inject TaxFilingService FilingService 대시보드 @@ -50,6 +51,50 @@ +@if (upcomingFilings.Count > 0) +{ + +
+
+ 이번 달 마감 임박 신고 + 30일 이내 신고 예정 건 +
+ 전체 일정 보기 +
+ + + + 고객 + 신고 유형 + 기한 + D-day + + + + @foreach (var f in upcomingFilings) + { + var dday = (f.DueDate.Date - DateTime.Today).Days; + + @f.ClientName + @f.FilingType + @f.DueDate.ToString("yyyy-MM-dd") + + @if (dday <= 7) + { + D-@dday + } + else + { + D-@dday + } + + + } + + +
+} +
@@ -76,8 +121,7 @@ @inquiry.Phone @inquiry.ServiceType - + @GetStatusLabel(inquiry.Status) @@ -90,17 +134,26 @@ @code { private AdminDashboardSummary summary = new(0, 0, 0, 0, []); + private List upcomingFilings = []; 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" => "신규", - "contacted" => "연락함", - "completed" => "완료", - _ => status + "new" => Color.Warning, + "consulting" => Color.Info, + "contracted" => Color.Success, + "rejected" => Color.Error, + "closed" => Color.Dark, + _ => Color.Default }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor index 921ca4f..f2b0080 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryDetail.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @using TaxBaik.Application.Services @inject InquiryService InquiryService +@inject ClientService ClientService @inject NavigationManager Navigation @inject ISnackbar Snackbar @@ -13,58 +14,91 @@ Color="Color.Primary" StartIcon="@Icons.Material.Filled.ArrowBack" @onclick="@(() => Navigation.NavigateTo("/taxbaik/admin/inquiries"))"> - 문의 목록으로 돌아가기 + 문의 목록으로 - - - 문의 상세 - - 다른 문의도 보기 - - + + + + 문의 정보 + + + 이름 + @inquiry.Name + + + 연락처 + @inquiry.Phone + + + 이메일 + @(inquiry.Email ?? "-") + + + 분야 + @inquiry.ServiceType + + + 문의 내용 + + @inquiry.Message + + + + 접수일시 + @inquiry.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + + + - - - 이름 - @inquiry.Name - - - 연락처 - @inquiry.Phone - - - 이메일 - @inquiry.Email - - - 분야 - @inquiry.ServiceType - - - 메시지 - - @inquiry.Message - - - - 상태 - - 신규 - 연락함 - 완료 - - - 신규 - 연락함 - 완료 + + 담당자 메모 + + 메모 저장 + + + + + + 처리 상태 + + @foreach (var (key, label) in InquiryStatusMapper.Labels) + { + + @label + + } - - - + + + @if (inquiry.ClientId == null) + { + + 고객 카드 생성 + 이 문의를 고객 카드로 등록합니다. + + 고객으로 등록 + + + } + else + { + + 연결된 고객 + + 고객 카드 보기 + + + } + + } else { @@ -76,17 +110,17 @@ else public int InquiryId { get; set; } private Domain.Entities.Inquiry? inquiry; + private string adminMemo = ""; protected override async Task OnInitializedAsync() { inquiry = await InquiryService.GetByIdAsync(InquiryId); + adminMemo = inquiry?.AdminMemo ?? ""; } private async Task OnStatusChanged(string status) { - if (inquiry == null) - return; - + if (inquiry == null) return; try { await InquiryService.UpdateStatusAsync(inquiry.Id, status, "관리자"); @@ -98,4 +132,40 @@ else 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 + }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor index 78e0537..549492e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor @@ -21,11 +21,17 @@ - - + + - - + + + + + + + + diff --git a/TaxBaik.Web/Components/Admin/Pages/SeasonSimulator.razor b/TaxBaik.Web/Components/Admin/Pages/SeasonSimulator.razor new file mode 100644 index 0000000..b85f961 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/SeasonSimulator.razor @@ -0,0 +1,211 @@ +@page "/admin/season-simulator" +@attribute [Authorize] +@using TaxBaik.Application.Seasonal +@using TaxBaik.Application.Services + +시즌 시뮬레이터 + +
+
+ Season Preview + 시즌 시뮬레이터 + 날짜를 선택해 홈페이지 시즌 화면이 어떻게 보이는지 미리 확인합니다. +
+
+ + + + + 시뮬레이션 날짜 + + + 연간 세무 캘린더 + @foreach (var season in TaxSeasonCalendar.Seasons) + { + + @season.StartMonth/@season.StartDay — @season.Name + + } + + + + + + + @(simulationDate?.ToString("yyyy년 MM월 dd일") ?? "날짜를 선택하세요") 홈페이지 미리보기 + + @if (activeSeason != null) + { + + @activeSeason.Name 시즌 활성 + + + +
+ @if (activeSeason.DaysUntilDeadline <= 7 && activeSeason.DaysUntilDeadline >= 0) + { +
+ D-@activeSeason.DaysUntilDeadline 마감 임박 +
+ } +
+ @activeSeason.HeroHeadline +
+
+ @activeSeason.HeroSubtext +
+
+
+ @activeSeason.CtaText +
+
+ 서비스 안내 +
+
+
+ + + + 활성 시즌 키 + @activeSeason.Key + + + 마감까지 + + @if (activeSeason.DaysUntilDeadline >= 0) + { + + D-@activeSeason.DaysUntilDeadline + + } + else + { + 마감 후 @(-activeSeason.DaysUntilDeadline)일 + } + + + + 포커스 서비스 + @activeSeason.FocusService + + + 블로그 카테고리 + @activeSeason.RelatedCategorySlug + + + 긴박감 배지 문구 + @activeSeason.UrgencyBadge + + + } + else + { + + 선택한 날짜(@(simulationDate?.ToString("MM월 dd일") ?? "-"))는 시즌 비활성 기간입니다. + 홈페이지는 기본 Hero를 표시합니다. + +
+
+ 사업자 세금, 부동산,
가족자산까지 +
+
+ 세무사·부동산중개사·보험설계사 자격 보유 | 온라인 맞춤 상담 +
+
+ 무료 상담 신청 +
+
+ } +
+ + + 연간 시즌 타임라인 + + + + 기간 + 시즌 + 블로그 카테고리 + 상태 + + + + @foreach (var s in TaxSeasonCalendar.Seasons) + { + var isActive = activeSeason?.Key == s.Key; + + + @s.StartMonth/@s.StartDay ~ @s.EndMonth/@s.EndDay + + @s.Name + @s.RelatedCategorySlug + + @if (isActive) + { + 활성 + } + else + { + 비활성 + } + + + } + + + +
+
+ +@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(); + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/FilingTable.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/FilingTable.razor new file mode 100644 index 0000000..c0ffcc5 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/FilingTable.razor @@ -0,0 +1,80 @@ +@using TaxBaik.Application.Services +@inject TaxFilingService FilingService +@inject ISnackbar Snackbar + +@if (Filings == null || Filings.Count == 0) +{ + 항목이 없습니다. +} +else +{ + + + 고객 + 신고 유형 + 기한 + D-day + 메모 + 처리 + + + @context.ClientName + @context.FilingType + @context.DueDate.ToString("yyyy-MM-dd") + + @{ + var dday = (context.DueDate.Date - DateTime.Today).Days; + } + @if (dday < 0) + { + D+@(-dday) + } + else if (dday <= 7) + { + D-@dday + } + else + { + D-@dday + } + + @(context.Memo ?? "") + + @if (context.Status == "pending") + { + 완료 + } + else if (context.Status == "filed") + { + 완료 + } + + + + +} + +@code { + [Parameter] + public List? 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(); + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor new file mode 100644 index 0000000..a179d3e --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor @@ -0,0 +1,126 @@ +@page "/admin/tax-filings" +@attribute [Authorize] +@using TaxBaik.Application.Services +@inject TaxFilingService FilingService +@inject ClientService ClientService +@inject ISnackbar Snackbar + +신고 일정 관리 + +
+
+ Tax Schedule + 신고 일정 + 고객별 세금 신고 마감일을 관리하고 완료 처리합니다. +
+ + 일정 추가 + +
+ +@if (showAddForm) +{ + + 새 신고 일정 + + + + + + + @foreach (var t in TaxFilingService.FilingTypes) + { + @t + } + + + + + + + + + + + 저장 + 취소 + + +} + + + + + + + + + + + + + + + +@code { + private List pending = []; + private List filed = []; + private List 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> 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); + } + } +} diff --git a/TaxBaik.Web/Pages/Privacy.cshtml b/TaxBaik.Web/Pages/Privacy.cshtml new file mode 100644 index 0000000..fc26934 --- /dev/null +++ b/TaxBaik.Web/Pages/Privacy.cshtml @@ -0,0 +1,77 @@ +@page +@model TaxBaik.Web.Pages.PrivacyModel +@{ + ViewData["Title"] = "개인정보처리방침 | 백원숙 세무회계"; + ViewData["Description"] = "백원숙 세무회계의 개인정보 수집·이용·보관에 관한 방침을 안내합니다."; +} + +
+

개인정보처리방침

+

최종 수정일: 2026년 6월 27일

+ +

백원숙 세무회계(이하 "사무소")는 「개인정보 보호법」에 따라 고객의 개인정보를 보호하고, 관련 불만을 신속하게 처리하기 위해 아래와 같이 개인정보처리방침을 수립·공개합니다.

+ +
+ +

1. 수집하는 개인정보 항목

+
    +
  • 필수: 성명, 연락처(전화번호)
  • +
  • 선택: 이메일 주소, 문의 내용
  • +
  • 자동 수집: 접속 IP 주소, 접속 일시
  • +
+ +

2. 개인정보의 수집·이용 목적

+
    +
  • 세무·부동산·가족자산 상담 신청 접수 및 답변
  • +
  • 서비스 이용 문의에 대한 회신
  • +
  • 서비스 품질 향상을 위한 통계 분석 (비식별화 처리)
  • +
+ +

3. 개인정보의 보유 및 이용 기간

+

상담 완료 후 3년간 보관 후 파기합니다. 단, 관계 법령에 따라 보관이 필요한 경우 해당 기간 동안 보관합니다.

+
    +
  • 전자상거래 기록: 5년 (전자상거래 등에서의 소비자 보호에 관한 법률)
  • +
  • 세금계산서 관련 자료: 5년 (부가가치세법)
  • +
+ +

4. 개인정보의 제3자 제공

+

사무소는 원칙적으로 고객의 개인정보를 외부에 제공하지 않습니다. 다만, 다음의 경우에는 예외로 합니다.

+
    +
  • 고객이 동의한 경우
  • +
  • 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관이 요구하는 경우
  • +
+ +

5. 개인정보의 파기

+

보유 기간이 경과하거나 처리 목적이 달성된 경우 지체 없이 파기합니다.

+
    +
  • 전자 파일 형태: 복구 불가능한 방법으로 영구 삭제
  • +
  • 종이 문서: 분쇄기 파기 또는 소각
  • +
+ +

6. 정보주체의 권리·의무

+

고객은 언제든지 다음 권리를 행사할 수 있습니다.

+
    +
  • 개인정보 열람 요구
  • +
  • 오류 등이 있을 경우 정정 요구
  • +
  • 삭제 요구
  • +
  • 처리 정지 요구
  • +
+

권리 행사는 아래 연락처로 서면, 전화, 이메일로 요청하시면 지체 없이 조치하겠습니다.

+ +

7. 개인정보 보호책임자

+ + + + + + + +
성명백원숙
직책세무사
연락처010-4122-8268
이메일taxbaik5668@gmail.com
+ +

8. 개인정보 처리방침 변경

+

이 개인정보처리방침은 2026년 6월 27일부터 적용되며, 변경 시 홈페이지 공지를 통해 안내합니다.

+ + +
diff --git a/TaxBaik.Web/Pages/Privacy.cshtml.cs b/TaxBaik.Web/Pages/Privacy.cshtml.cs new file mode 100644 index 0000000..7df60d7 --- /dev/null +++ b/TaxBaik.Web/Pages/Privacy.cshtml.cs @@ -0,0 +1,8 @@ +namespace TaxBaik.Web.Pages; + +using Microsoft.AspNetCore.Mvc.RazorPages; + +public class PrivacyModel : PageModel +{ + public void OnGet() { } +} diff --git a/TaxBaik.Web/Pages/Terms.cshtml b/TaxBaik.Web/Pages/Terms.cshtml new file mode 100644 index 0000000..cf52aeb --- /dev/null +++ b/TaxBaik.Web/Pages/Terms.cshtml @@ -0,0 +1,56 @@ +@page +@model TaxBaik.Web.Pages.TermsModel +@{ + ViewData["Title"] = "이용약관 | 백원숙 세무회계"; + ViewData["Description"] = "백원숙 세무회계 홈페이지 이용약관을 안내합니다."; +} + +
+

이용약관

+

최종 수정일: 2026년 6월 27일 / 시행일: 2026년 6월 27일

+ +

제1조 (목적)

+

이 약관은 백원숙 세무회계(이하 "사무소")가 운영하는 홈페이지(taxbaik.kr, 이하 "사이트")에서 제공하는 온라인 상담 신청 및 정보 서비스 이용과 관련하여 사무소와 이용자의 권리·의무 및 책임사항을 규정함을 목적으로 합니다.

+ +

제2조 (정의)

+
    +
  • "서비스"란 사이트에서 제공하는 세무 정보 제공, 상담 신청, 블로그 콘텐츠 열람 등 일체의 서비스를 말합니다.
  • +
  • "이용자"란 사이트에 접속하여 서비스를 이용하는 자를 말합니다.
  • +
+ +

제3조 (약관의 효력 및 변경)

+

이 약관은 사이트 내 게시함으로써 효력이 발생합니다. 사무소는 필요한 경우 약관을 변경할 수 있으며, 변경된 약관은 사이트 공지 후 7일이 경과한 날로부터 효력이 발생합니다.

+ +

제4조 (서비스 제공)

+

사무소는 다음 서비스를 제공합니다.

+
    +
  • 세무·부동산·가족자산 관련 정보 콘텐츠 제공
  • +
  • 온라인 상담 신청 접수
  • +
  • 시즌별 세무 정보 안내
  • +
+ +

제5조 (서비스 이용 시 주의사항)

+
    +
  • 사이트에서 제공하는 세무 정보는 일반적인 안내 목적으로, 개별 사안에 대한 법적 효력을 갖지 않습니다.
  • +
  • 구체적인 세무 처리는 반드시 전문가 상담을 통해 진행하시기 바랍니다.
  • +
  • 이용자는 상담 신청 시 허위 정보를 제공해서는 안 됩니다.
  • +
+ +

제6조 (면책 조항)

+

사무소는 다음 사항에 대해 책임을 지지 않습니다.

+
    +
  • 천재지변 또는 이에 준하는 불가항력으로 서비스를 제공할 수 없는 경우
  • +
  • 이용자의 귀책 사유로 인한 서비스 이용 장애
  • +
  • 사이트에서 제공하는 일반 정보를 개별 사안에 적용함으로써 발생하는 손해
  • +
+ +

제7조 (저작권)

+

사이트에 게시된 모든 콘텐츠(글, 이미지, 디자인 등)의 저작권은 사무소 또는 원저작자에게 있으며, 무단 복제·배포를 금합니다.

+ +

제8조 (준거법 및 재판관할)

+

이 약관에 관한 분쟁은 대한민국 법령을 적용하며, 관할 법원은 민사소송법에 따른 관할 법원으로 합니다.

+ + +
diff --git a/TaxBaik.Web/Pages/Terms.cshtml.cs b/TaxBaik.Web/Pages/Terms.cshtml.cs new file mode 100644 index 0000000..db5c043 --- /dev/null +++ b/TaxBaik.Web/Pages/Terms.cshtml.cs @@ -0,0 +1,8 @@ +namespace TaxBaik.Web.Pages; + +using Microsoft.AspNetCore.Mvc.RazorPages; + +public class TermsModel : PageModel +{ + public void OnGet() { } +} diff --git a/db/migrations/V008__CreateConsultations.sql b/db/migrations/V008__CreateConsultations.sql new file mode 100644 index 0000000..766d5b9 --- /dev/null +++ b/db/migrations/V008__CreateConsultations.sql @@ -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); diff --git a/db/migrations/V009__AddClientIdToInquiries.sql b/db/migrations/V009__AddClientIdToInquiries.sql new file mode 100644 index 0000000..a0f331a --- /dev/null +++ b/db/migrations/V009__AddClientIdToInquiries.sql @@ -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); diff --git a/db/migrations/V010__CreateTaxFilings.sql b/db/migrations/V010__CreateTaxFilings.sql new file mode 100644 index 0000000..3882520 --- /dev/null +++ b/db/migrations/V010__CreateTaxFilings.sql @@ -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); diff --git a/db/migrations/V011__ExtendInquiryStatus.sql b/db/migrations/V011__ExtendInquiryStatus.sql new file mode 100644 index 0000000..7c293e9 --- /dev/null +++ b/db/migrations/V011__ExtendInquiryStatus.sql @@ -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();