From cc72a6735538765297206445489d23634c5ae3b2 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 27 Jun 2026 22:45:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EC=A6=8C=EB=B3=84=20=EB=A7=88?= =?UTF-8?q?=EC=BC=80=ED=8C=85=20+=20=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 연간 세무 캘린더(7개 시즌) 기반 자동 Hero 섹션 전환 - 시즌 감지 시 D-Day 카운트다운, 긴박감 배지, 시즌 CTA 표시 - 서비스 카드 순서 시즌 관련 항목 우선 정렬 - 어드민 공지사항 CRUD (등록·수정·삭제, 기간·유형 설정) - 홈페이지 상단 공지 배너 자동 노출 (일반/배너/긴급) - CLAUDE.md에 세무 캘린더 및 마케팅 방향 하네스 추가 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 49 +++ TaxBaik.Application/DTOs/AnnouncementDto.cs | 13 + TaxBaik.Application/DependencyInjection.cs | 2 + .../Seasonal/CurrentSeasonDto.cs | 14 + TaxBaik.Application/Seasonal/TaxSeason.cs | 18 ++ .../Seasonal/TaxSeasonCalendar.cs | 96 ++++++ .../Services/AnnouncementService.cs | 44 +++ .../Services/SeasonalMarketingService.cs | 38 +++ TaxBaik.Domain/Entities/Announcement.cs | 15 + .../Interfaces/IAnnouncementRepository.cs | 13 + TaxBaik.Infrastructure/DependencyInjection.cs | 1 + .../Repositories/AnnouncementRepository.cs | 74 +++++ TaxBaik.Web/Components/Admin/App.razor | 5 + .../Components/Admin/InquiryTable.razor | 2 +- .../Components/Admin/Layout/MainLayout.razor | 67 +++- .../Announcements/AnnouncementEdit.razor | 161 ++++++++++ .../Announcements/AnnouncementList.razor | 148 +++++++++ .../Admin/Pages/Blog/BlogList.razor | 24 +- .../Components/Admin/Pages/Dashboard.razor | 65 ++-- .../Admin/Pages/Inquiries/InquiryList.razor | 12 +- .../Components/Admin/Pages/Login.razor | 7 + .../Admin/Pages/Settings/SiteSettings.razor | 35 ++- TaxBaik.Web/Pages/Index.cshtml | 293 ++++++++++++------ TaxBaik.Web/Pages/Index.cshtml.cs | 23 +- TaxBaik.Web/wwwroot/css/site.css | 87 ++++++ TaxBaik.Web/wwwroot/js/admin-session.js | 8 + db/migrations/V005__CreateAnnouncements.sql | 14 + 27 files changed, 1184 insertions(+), 144 deletions(-) create mode 100644 TaxBaik.Application/DTOs/AnnouncementDto.cs create mode 100644 TaxBaik.Application/Seasonal/CurrentSeasonDto.cs create mode 100644 TaxBaik.Application/Seasonal/TaxSeason.cs create mode 100644 TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs create mode 100644 TaxBaik.Application/Services/AnnouncementService.cs create mode 100644 TaxBaik.Application/Services/SeasonalMarketingService.cs create mode 100644 TaxBaik.Domain/Entities/Announcement.cs create mode 100644 TaxBaik.Domain/Interfaces/IAnnouncementRepository.cs create mode 100644 TaxBaik.Infrastructure/Repositories/AnnouncementRepository.cs create mode 100644 TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor create mode 100644 TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor create mode 100644 db/migrations/V005__CreateAnnouncements.sql diff --git a/CLAUDE.md b/CLAUDE.md index 890174c..e49060d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -744,6 +744,55 @@ SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1; --- +--- + +## 13. 시즌별 마케팅 (Seasonal Marketing) + +### 13.1 핵심 방향 + +세무사 사무실은 **1년 중 특정 시기에 특정 고객이 집중**된다. 홈페이지는 이 시기마다 자동으로 전환되어야 한다. + +**목표**: 방문자가 접속한 날짜에 맞는 세무 이벤트를 즉시 인지하고 상담 신청으로 전환 + +**전환 방식**: +- Hero 섹션 헤드라인과 CTA가 시즌에 맞게 변경됨 +- 마감 D-7일 이내에는 긴박감 메시지 추가 표시 +- 시즌 관련 서비스 카드가 맨 앞으로 이동 +- 최종 CTA도 시즌 문구로 전환 +- 관리자가 별도 공지사항을 등록하면 모든 페이지 최상단에 배너로 노출 + +### 13.2 연간 세무 캘린더 + +| 기간 | 이벤트 | Key | 타깃 서비스 | +|------|--------|-----|-------------| +| 1/1 ~ 1/25 | 부가가치세 2기 확정신고 | `vat-2nd` | business-tax | +| 1/15 ~ 2/28 | 연말정산 | `year-end-settlement` | business-tax | +| 3/1 ~ 3/31 | 법인세 신고 | `corporate-tax` | business-tax | +| 5/1 ~ 5/31 | **종합소득세 신고** (연중 최대 피크) | `income-tax` | business-tax | +| 7/1 ~ 7/25 | 부가가치세 1기 확정신고 | `vat-1st` | business-tax | +| 11/15 ~ 11/30 | 종합부동산세 납부 | `comprehensive-real-estate-tax` | real-estate-tax | +| 12/1 ~ 12/31 | 연말 증여·절세 플래닝 | `year-end-gift` | family-asset | + +캘린더 정의 위치: `TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs` + +시즌 추가/수정은 이 파일만 변경하면 된다. DB·마이그레이션 변경 없음. + +### 13.3 공지사항 (Announcement) + +어드민 `/taxbaik/admin/announcements`에서 관리. + +- **유형**: 일반(info, 파란색) / 배너(banner, 주황색) / 긴급(urgent, 빨간색) +- **게시 기간**: 시작일~종료일 설정 가능. 비우면 즉시~무기한 +- **노출 위치**: 홈페이지 최상단 (공지 배너 스트립) +- **우선순위**: sort_order 내림차순 + +공지사항은 시즌 Hero와 독립적으로 동작한다. 동시 표시 가능. + +### 13.4 시즌 우선순위 / 광고 규칙 준수 + +- 허용: "지금 신고 준비하세요", "마감 전 사전 검토", "D-N일 남았습니다" +- 금지: "100% 절세 보장", "최저가 신고", "무료" + **마지막 체크리스트:** - [ ] 솔루션 빌드 성공 (`dotnet build`) - [ ] 모든 프로젝트 참조 정확 diff --git a/TaxBaik.Application/DTOs/AnnouncementDto.cs b/TaxBaik.Application/DTOs/AnnouncementDto.cs new file mode 100644 index 0000000..d3da0ba --- /dev/null +++ b/TaxBaik.Application/DTOs/AnnouncementDto.cs @@ -0,0 +1,13 @@ +namespace TaxBaik.Application.DTOs; + +public class AnnouncementDto +{ + public int Id { get; set; } + public string Title { get; set; } = ""; + public string? Content { get; set; } + public string DisplayType { get; set; } = "info"; + public bool IsActive { get; set; } = true; + public DateTime? StartsAt { get; set; } + public DateTime? EndsAt { get; set; } + public int SortOrder { get; set; } +} diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index 8fa550f..d69a663 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -13,6 +13,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); return services; } } diff --git a/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs b/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs new file mode 100644 index 0000000..495311b --- /dev/null +++ b/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs @@ -0,0 +1,14 @@ +namespace TaxBaik.Application.Seasonal; + +public record CurrentSeasonDto +{ + public string Key { get; init; } = ""; + public string Name { get; init; } = ""; + public string HeroHeadline { get; init; } = ""; + public string HeroSubtext { get; init; } = ""; + public string UrgencyBadge { get; init; } = ""; + public string FocusService { get; init; } = ""; + public string CtaText { get; init; } = "상담 신청하기"; + public int DaysUntilDeadline { get; init; } + public DateTime Deadline { get; init; } +} diff --git a/TaxBaik.Application/Seasonal/TaxSeason.cs b/TaxBaik.Application/Seasonal/TaxSeason.cs new file mode 100644 index 0000000..19b055b --- /dev/null +++ b/TaxBaik.Application/Seasonal/TaxSeason.cs @@ -0,0 +1,18 @@ +namespace TaxBaik.Application.Seasonal; + +public record TaxSeason +{ + public string Key { get; init; } = ""; + public string Name { get; init; } = ""; + + public int StartMonth { get; init; } + public int StartDay { get; init; } + public int EndMonth { get; init; } + public int EndDay { get; init; } + + public string HeroHeadline { get; init; } = ""; + public string HeroSubtext { get; init; } = ""; + public string UrgencyBadge { get; init; } = ""; + public string FocusService { get; init; } = ""; + public string CtaText { get; init; } = "상담 신청하기"; +} diff --git a/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs b/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs new file mode 100644 index 0000000..88927eb --- /dev/null +++ b/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs @@ -0,0 +1,96 @@ +namespace TaxBaik.Application.Seasonal; + +/// +/// 한국 세무사 사무실 연간 시즌 캘린더. +/// 각 시즌이 활성화되면 홈페이지 Hero가 해당 세무 이벤트에 맞게 전환된다. +/// +public static class TaxSeasonCalendar +{ + public static readonly IReadOnlyList Seasons = + [ + new TaxSeason + { + Key = "vat-2nd", + Name = "부가가치세 2기 확정신고", + StartMonth = 1, StartDay = 1, + EndMonth = 1, EndDay = 25, + HeroHeadline = "부가가치세 2기\n1월 25일 마감", + HeroSubtext = "일반과세 사업자 확정신고 · 기한 내 신고로 가산세 방지", + UrgencyBadge = "D-{n}일 | 부가세 마감", + FocusService = "business-tax", + CtaText = "부가세 신고 상담" + }, + new TaxSeason + { + Key = "year-end-settlement", + Name = "연말정산", + StartMonth = 1, StartDay = 15, + EndMonth = 2, EndDay = 28, + HeroHeadline = "연말정산\n지금 준비하세요", + HeroSubtext = "직원이 있는 사업자 원천징수 신고 · 환급 최대화", + UrgencyBadge = "연말정산 진행 중", + FocusService = "business-tax", + CtaText = "연말정산 상담" + }, + new TaxSeason + { + Key = "corporate-tax", + Name = "법인세 신고", + StartMonth = 3, StartDay = 1, + EndMonth = 3, EndDay = 31, + HeroHeadline = "법인세\n3월 31일 마감", + HeroSubtext = "법인사업자 결산 · 세무조정 · 절세 전략 수립", + UrgencyBadge = "D-{n}일 | 법인세 마감", + FocusService = "business-tax", + CtaText = "법인세 신고 상담" + }, + new TaxSeason + { + Key = "income-tax", + Name = "종합소득세 신고", + StartMonth = 5, StartDay = 1, + EndMonth = 5, EndDay = 31, + HeroHeadline = "종합소득세\n5월 31일 마감", + HeroSubtext = "개인사업자 · 임대소득 · 프리랜서 · 기타소득 모두 해당", + UrgencyBadge = "D-{n}일 | 종합소득세 마감", + FocusService = "business-tax", + CtaText = "종합소득세 상담" + }, + new TaxSeason + { + Key = "vat-1st", + Name = "부가가치세 1기 확정신고", + StartMonth = 7, StartDay = 1, + EndMonth = 7, EndDay = 25, + HeroHeadline = "부가가치세 1기\n7월 25일 마감", + HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검", + UrgencyBadge = "D-{n}일 | 부가세 마감", + FocusService = "business-tax", + CtaText = "부가세 신고 상담" + }, + new TaxSeason + { + Key = "comprehensive-real-estate-tax", + Name = "종합부동산세", + StartMonth = 11, StartDay = 15, + EndMonth = 11, EndDay = 30, + HeroHeadline = "종합부동산세\n납부 시즌", + HeroSubtext = "다주택자 · 임대사업자 세부담 분석 · 분납·합산배제 검토", + UrgencyBadge = "D-{n}일 | 종부세 납부", + FocusService = "real-estate-tax", + CtaText = "종부세 절세 상담" + }, + new TaxSeason + { + Key = "year-end-gift", + Name = "연말 증여·절세 플래닝", + StartMonth = 12, StartDay = 1, + EndMonth = 12, EndDay = 31, + HeroHeadline = "연말 절세 플래닝\n마지막 기회", + HeroSubtext = "증여 공제 한도 · 자산 이전 · 법인전환 연간 마감", + UrgencyBadge = "D-{n}일 | 연간 증여 한도 마감", + FocusService = "family-asset", + CtaText = "연말 절세 상담" + } + ]; +} diff --git a/TaxBaik.Application/Services/AnnouncementService.cs b/TaxBaik.Application/Services/AnnouncementService.cs new file mode 100644 index 0000000..7c10b0a --- /dev/null +++ b/TaxBaik.Application/Services/AnnouncementService.cs @@ -0,0 +1,44 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Application.DTOs; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class AnnouncementService(IAnnouncementRepository repository) +{ + public Task> GetActiveAsync(CancellationToken ct = default) + => repository.GetActiveAsync(ct); + + public Task> GetAllAsync(CancellationToken ct = default) + => repository.GetAllAsync(ct); + + public Task GetByIdAsync(int id, CancellationToken ct = default) + => repository.GetByIdAsync(id, ct); + + public Task CreateAsync(AnnouncementDto dto, CancellationToken ct = default) + { + var entity = MapToEntity(dto); + return repository.CreateAsync(entity, ct); + } + + public Task UpdateAsync(AnnouncementDto dto, CancellationToken ct = default) + { + var entity = MapToEntity(dto); + return repository.UpdateAsync(entity, ct); + } + + public Task DeleteAsync(int id, CancellationToken ct = default) + => repository.DeleteAsync(id, ct); + + private static Announcement MapToEntity(AnnouncementDto dto) => new() + { + Id = dto.Id, + Title = dto.Title.Trim(), + Content = string.IsNullOrWhiteSpace(dto.Content) ? null : dto.Content.Trim(), + DisplayType = dto.DisplayType, + IsActive = dto.IsActive, + StartsAt = dto.StartsAt, + EndsAt = dto.EndsAt, + SortOrder = dto.SortOrder + }; +} diff --git a/TaxBaik.Application/Services/SeasonalMarketingService.cs b/TaxBaik.Application/Services/SeasonalMarketingService.cs new file mode 100644 index 0000000..8076f68 --- /dev/null +++ b/TaxBaik.Application/Services/SeasonalMarketingService.cs @@ -0,0 +1,38 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Application.Seasonal; + +public class SeasonalMarketingService +{ + public CurrentSeasonDto? GetCurrentSeason() + { + var today = DateTime.Today; + + foreach (var season in TaxSeasonCalendar.Seasons) + { + var start = new DateTime(today.Year, season.StartMonth, season.StartDay); + var end = new DateTime(today.Year, season.EndMonth, season.EndDay); + + if (today >= start && today <= end) + { + var days = (end - today).Days; + return new CurrentSeasonDto + { + Key = season.Key, + Name = season.Name, + HeroHeadline = season.HeroHeadline, + HeroSubtext = season.HeroSubtext, + UrgencyBadge = season.UrgencyBadge.Replace("{n}", days.ToString()), + FocusService = season.FocusService, + CtaText = season.CtaText, + DaysUntilDeadline = days, + Deadline = end + }; + } + } + + return null; + } + + public IReadOnlyList GetFullCalendar() => TaxSeasonCalendar.Seasons; +} diff --git a/TaxBaik.Domain/Entities/Announcement.cs b/TaxBaik.Domain/Entities/Announcement.cs new file mode 100644 index 0000000..faa5e66 --- /dev/null +++ b/TaxBaik.Domain/Entities/Announcement.cs @@ -0,0 +1,15 @@ +namespace TaxBaik.Domain.Entities; + +public class Announcement +{ + public int Id { get; set; } + public string Title { get; set; } = null!; + public string? Content { get; set; } + public string DisplayType { get; set; } = "info"; + public bool IsActive { get; set; } + public DateTime? StartsAt { get; set; } + public DateTime? EndsAt { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/TaxBaik.Domain/Interfaces/IAnnouncementRepository.cs b/TaxBaik.Domain/Interfaces/IAnnouncementRepository.cs new file mode 100644 index 0000000..c6c9759 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IAnnouncementRepository.cs @@ -0,0 +1,13 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IAnnouncementRepository +{ + Task> GetActiveAsync(CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + Task CreateAsync(Announcement announcement, CancellationToken cancellationToken = default); + Task UpdateAsync(Announcement announcement, CancellationToken cancellationToken = default); + Task DeleteAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index 6f57186..72b266c 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -15,6 +15,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/TaxBaik.Infrastructure/Repositories/AnnouncementRepository.cs b/TaxBaik.Infrastructure/Repositories/AnnouncementRepository.cs new file mode 100644 index 0000000..90d10d1 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/AnnouncementRepository.cs @@ -0,0 +1,74 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class AnnouncementRepository(IDbConnectionFactory connectionFactory) + : BaseRepository(connectionFactory), IAnnouncementRepository +{ + private const string SelectColumns = + "id, title, content, display_type, is_active, starts_at, ends_at, sort_order, created_at, updated_at"; + + public async Task> GetActiveAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + $@"SELECT {SelectColumns} + FROM announcements + WHERE is_active = TRUE + AND (starts_at IS NULL OR starts_at <= NOW()) + AND (ends_at IS NULL OR ends_at >= NOW()) + ORDER BY sort_order DESC, created_at DESC"); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + $"SELECT {SelectColumns} FROM announcements ORDER BY sort_order DESC, created_at DESC"); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + $"SELECT {SelectColumns} FROM announcements WHERE id = @Id", + new { Id = id }); + } + + public async Task CreateAsync(Announcement announcement, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO announcements + (title, content, display_type, is_active, starts_at, ends_at, sort_order, created_at, updated_at) + VALUES + (@Title, @Content, @DisplayType, @IsActive, @StartsAt, @EndsAt, @SortOrder, NOW(), NOW()) + RETURNING id", + announcement); + } + + public async Task UpdateAsync(Announcement announcement, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE announcements + SET title = @Title, + content = @Content, + display_type = @DisplayType, + is_active = @IsActive, + starts_at = @StartsAt, + ends_at = @EndsAt, + sort_order = @SortOrder, + updated_at = NOW() + WHERE id = @Id", + announcement); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync("DELETE FROM announcements WHERE id = @Id", new { Id = id }); + } +} diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor index 6338cfc..059bcf3 100644 --- a/TaxBaik.Web/Components/Admin/App.razor +++ b/TaxBaik.Web/Components/Admin/App.razor @@ -9,6 +9,11 @@ + diff --git a/TaxBaik.Web/Components/Admin/InquiryTable.razor b/TaxBaik.Web/Components/Admin/InquiryTable.razor index 7ef52bb..a98f9d0 100644 --- a/TaxBaik.Web/Components/Admin/InquiryTable.razor +++ b/TaxBaik.Web/Components/Admin/InquiryTable.razor @@ -1,7 +1,7 @@ @using TaxBaik.Application.Services @inject InquiryService InquiryService - + 이름 diff --git a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor index dc91196..7320a4b 100644 --- a/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor +++ b/TaxBaik.Web/Components/Admin/Layout/MainLayout.razor @@ -1,24 +1,60 @@ @inherits LayoutComponentBase - - - 백원숙 세무회계 관리자 + + + +
+ TaxBaik Backoffice + 백원숙 세무회계 관리자 +
- 공개 사이트 - 로그아웃 + + 공개 사이트 + + + 로그아웃 +
- - - 📊 대시보드 - 📝 블로그 관리 - 💬 문의 관리 - ⚙️ 설정 + +
+
T
+
+ TaxBaik + 세무 운영 콘솔 +
+
+ + 대시보드 + 공지사항 + 블로그 관리 + 문의 관리 + 설정 +
- - + + @Body @@ -26,4 +62,9 @@ @code { private bool drawerOpen = true; + + private void ToggleDrawer() + { + drawerOpen = !drawerOpen; + } } diff --git a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor new file mode 100644 index 0000000..a71c1c7 --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementEdit.razor @@ -0,0 +1,161 @@ +@page "/admin/announcements/create" +@page "/admin/announcements/{Id:int}/edit" +@attribute [Authorize] +@using TaxBaik.Application.DTOs +@using TaxBaik.Application.Services +@inject AnnouncementService AnnouncementService +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +@(Id.HasValue ? "공지 수정" : "공지 등록") + +
+
+ Homepage + @(Id.HasValue ? "공지 수정" : "공지 등록") +
+
+ + + + + + + + + + + + + + + 일반 (파란색) + 배너 (주황색) — 중요 이벤트 + 긴급 (빨간색) — 마감 임박 + + + + + + + + + + + + + + + + + + + + +
+ + @(isSaving ? "저장 중..." : "저장") + + + 취소 + +
+
+
+ +@code { + [Parameter] public int? Id { get; set; } + + private MudForm? form; + private bool isSaving; + private DateTime? startsAtDate; + private DateTime? endsAtDate; + + private AnnouncementDto model = new(); + + protected override async Task OnInitializedAsync() + { + if (Id.HasValue) + { + var entity = await AnnouncementService.GetByIdAsync(Id.Value); + if (entity is null) + { + Navigation.NavigateTo("/taxbaik/admin/announcements"); + return; + } + model = new AnnouncementDto + { + Id = entity.Id, + Title = entity.Title, + Content = entity.Content, + DisplayType = entity.DisplayType, + IsActive = entity.IsActive, + SortOrder = entity.SortOrder + }; + startsAtDate = entity.StartsAt?.ToLocalTime(); + endsAtDate = entity.EndsAt?.ToLocalTime(); + } + } + + private async Task SaveAsync() + { + if (form is null) return; + await form.Validate(); + if (!form.IsValid) return; + + isSaving = true; + try + { + model.StartsAt = startsAtDate.HasValue + ? DateTime.SpecifyKind(startsAtDate.Value.Date, DateTimeKind.Local).ToUniversalTime() + : null; + model.EndsAt = endsAtDate.HasValue + ? DateTime.SpecifyKind(endsAtDate.Value.Date.AddDays(1).AddSeconds(-1), DateTimeKind.Local).ToUniversalTime() + : null; + + if (Id.HasValue) + await AnnouncementService.UpdateAsync(model); + else + await AnnouncementService.CreateAsync(model); + + Snackbar.Add("공지사항이 저장되었습니다.", Severity.Success); + Navigation.NavigateTo("/taxbaik/admin/announcements"); + } + catch (Exception ex) + { + Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error); + } + finally + { + isSaving = false; + } + } +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor new file mode 100644 index 0000000..b77c8aa --- /dev/null +++ b/TaxBaik.Web/Components/Admin/Pages/Announcements/AnnouncementList.razor @@ -0,0 +1,148 @@ +@page "/admin/announcements" +@attribute [Authorize] +@using TaxBaik.Application.Services +@using TaxBaik.Domain.Entities +@inject AnnouncementService AnnouncementService +@inject NavigationManager Navigation +@inject IDialogService DialogService +@inject ISnackbar Snackbar + +공지사항 관리 + +
+
+ Homepage + 공지사항 관리 + 홈페이지 상단에 노출되는 공지사항을 등록하고 관리합니다. +
+ + 공지 등록 + +
+ + + @if (announcements is null) + { + + } + else if (!announcements.Any()) + { + 등록된 공지사항이 없습니다. + } + else + { + + + + 제목 + 유형 + 상태 + 게시 기간 + 순서 + + + + + @foreach (var item in announcements) + { + + @item.Title + + + @GetTypeLabel(item.DisplayType) + + + + @if (IsCurrentlyActive(item)) + { + 노출 중 + } + else if (!item.IsActive) + { + 비활성 + } + else + { + 기간 외 + } + + + @FormatPeriod(item) + + @item.SortOrder + + + + 수정 + + + 삭제 + + + + + } + + + } + + +@code { + private List? announcements; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + announcements = (await AnnouncementService.GetAllAsync()).ToList(); + } + + private async Task DeleteAsync(Announcement item) + { + var confirmed = await DialogService.ShowMessageBox( + "공지 삭제", + $"'{item.Title}' 공지를 삭제하시겠습니까?", + yesText: "삭제", cancelText: "취소"); + + if (confirmed != true) return; + + await AnnouncementService.DeleteAsync(item.Id); + Snackbar.Add("공지사항이 삭제되었습니다.", Severity.Success); + await LoadAsync(); + } + + private static bool IsCurrentlyActive(Announcement a) + { + if (!a.IsActive) return false; + var now = DateTime.UtcNow; + if (a.StartsAt.HasValue && a.StartsAt > now) return false; + if (a.EndsAt.HasValue && a.EndsAt < now) return false; + return true; + } + + private static string FormatPeriod(Announcement a) + { + var start = a.StartsAt?.ToLocalTime().ToString("MM/dd") ?? "즉시"; + var end = a.EndsAt?.ToLocalTime().ToString("MM/dd") ?? "무기한"; + return $"{start} ~ {end}"; + } + + private static Color GetTypeColor(string type) => type switch + { + "urgent" => Color.Error, + "banner" => Color.Warning, + _ => Color.Info + }; + + private static string GetTypeLabel(string type) => type switch + { + "urgent" => "긴급", + "banner" => "배너", + _ => "일반" + }; +} diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor index 1e21a82..27d19e7 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor @@ -5,20 +5,24 @@ 블로그 관리 -
- 📝 블로그 관리 - 새 포스트 -
+
+
+ Content + 블로그 관리 + 검색 유입 콘텐츠의 발행 상태와 성과를 관리합니다. +
+ 새 포스트 작성 +
- + @($"전체 포스트 {totalPosts}개") 페이지 @currentPage / @totalPages - + @@ -31,9 +35,9 @@ - 수정 - 수정하기 + 삭제 diff --git a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor index ca5287e..9e13ae6 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Dashboard.razor @@ -5,41 +5,60 @@ 대시보드 -

대시보드

+
+
+ Overview + 대시보드 + 문의 흐름과 콘텐츠 상태를 한 화면에서 확인합니다. +
+ + 새 포스트 작성 + +
- + - - 이번달 문의 - @summary.ThisMonthInquiries + + 이번달 문의 + @summary.ThisMonthInquiries + 월간 상담 유입 - - 신규 문의 - @summary.NewInquiries + + 신규 문의 + @summary.NewInquiries + 처리 대기 - - 전체 포스트 - @summary.TotalPosts + + 전체 포스트 + @summary.TotalPosts + 콘텐츠 자산 - - 발행된 포스트 - @summary.PublishedPosts + + 발행된 포스트 + @summary.PublishedPosts + 검색 노출 대상 - - 최근 문의 - + +
+
+ 최근 문의 + 최근 유입된 상담 요청을 빠르게 확인합니다. +
+ 문의 전체 보기 +
+ 이름 @@ -59,7 +78,7 @@ - @inquiry.Status + @GetStatusLabel(inquiry.Status) @inquiry.CreatedAt.ToString("yyyy-MM-dd") @@ -75,5 +94,13 @@ protected override async Task OnInitializedAsync() { summary = await DashboardService.GetSummaryAsync(); - } +} + + private static string GetStatusLabel(string status) => status switch + { + "new" => "신규", + "contacted" => "연락함", + "completed" => "완료", + _ => status + }; } diff --git a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor index f54d239..78e0537 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Inquiries/InquiryList.razor @@ -5,9 +5,16 @@ 문의 관리 -💬 문의 관리 +
+
+ Customer Requests + 문의 관리 + 상담 요청을 상태별로 확인하고 후속 조치를 기록합니다. +
+
- + + @@ -21,3 +28,4 @@ + diff --git a/TaxBaik.Web/Components/Admin/Pages/Login.razor b/TaxBaik.Web/Components/Admin/Pages/Login.razor index 08fe249..6fe265e 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Login.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Login.razor @@ -5,6 +5,7 @@ @inject IApiClient ApiClient @inject NavigationManager NavigationManager @inject CustomAuthenticationStateProvider AuthStateProvider +@inject IJSRuntime Js 로그인 @@ -58,6 +59,12 @@ private LoginModel model = new(); + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass"); + } + private async Task HandleLogin() { if (isLoading) diff --git a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor index 3097e5b..b218831 100644 --- a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor +++ b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor @@ -9,9 +9,23 @@ 설정 -⚙️ 사이트 설정 +
+
+ System + 설정 + 공개 사이트 연락처와 관리자 계정 보안을 관리합니다. +
+
- + + + +
+
+ 사이트 정보 + 홈페이지와 문의 알림에 노출되는 기본 정보입니다. +
+
@@ -26,12 +40,20 @@ Variant="Variant.Outlined" Class="mb-4" /> 저장 + StartIcon="@Icons.Material.Filled.Save" + @onclick="SaveSettings">사이트 정보 저장
+
- - 계정 관리 + + + +
@code { private string phone = "010-4122-8268"; diff --git a/TaxBaik.Web/Pages/Index.cshtml b/TaxBaik.Web/Pages/Index.cshtml index 17f83d7..514c4d2 100644 --- a/TaxBaik.Web/Pages/Index.cshtml +++ b/TaxBaik.Web/Pages/Index.cshtml @@ -1,37 +1,107 @@ @page @model TaxBaik.Web.Pages.IndexModel @{ - ViewData["Title"] = "백원숙 세무회계 | 사업자·부동산·증여 세무 상담"; + var season = Model.CurrentSeason; + ViewData["Title"] = season != null + ? $"백원숙 세무회계 | {season.Name} — 지금 상담하세요" + : "백원숙 세무회계 | 사업자·부동산·증여 세무 상담"; ViewData["Description"] = "사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 온라인 맞춤 상담 제공."; } - -
-
-
-
- 경험 있는 세무사의 맞춤 전략 -

- 세금과 자산
- 한 번에 해결하는 -

-

- 사업자 세무, 부동산 거래, 가족자산 관리를 위한
- 통합 솔루션을 제공합니다. -

- -
-
-
📋
+@* ─── 공지사항 배너 (관리자 등록 공지) ─── *@ +@if (Model.ActiveAnnouncements.Count > 0) +{ + foreach (var notice in Model.ActiveAnnouncements) + { +
+
+ + @if (notice.DisplayType == "urgent") { 🚨 } + else if (notice.DisplayType == "banner") { 📢 } + else { ℹ️ } + + @notice.Title + @if (!string.IsNullOrEmpty(notice.Content)) + { + — @notice.Content + }
-
-
+ } +} + +@* ─── Hero Section ─── *@ +@if (season != null) +{ +
+
+
+
+ + @season.UrgencyBadge + +

@season.HeroHeadline

+

+ @season.HeroSubtext +

+ + @if (season.DaysUntilDeadline <= 7) + { +

+ 마감까지 @(season.DaysUntilDeadline)일 남았습니다. + 지금 바로 상담 신청하세요. +

+ } +
+
+
+
마감
+
@season.Deadline.ToString("M월 d일")
+
D-@season.DaysUntilDeadline
+
+
+
+
+
+} +else +{ +
+
+
+
+ 경험 있는 세무사의 맞춤 전략 +

+ 세금과 자산
+ 한 번에 해결하는 +

+

+ 사업자 세무, 부동산 거래, 가족자산 관리를 위한
+ 통합 솔루션을 제공합니다. +

+ +
+
+
📋
+
+
+
+
+}
@@ -62,7 +132,7 @@
- +
@@ -72,66 +142,88 @@

+ @{ + var focusService = season?.FocusService ?? ""; + // 시즌에 따라 서비스 카드 순서 결정: 시즌 관련 카드가 맨 앞 + var cardOrder = focusService switch + { + "real-estate-tax" => new[] { "real-estate-tax", "business-tax", "family-asset" }, + "family-asset" => new[] { "family-asset", "business-tax", "real-estate-tax" }, + _ => new[] { "business-tax", "real-estate-tax", "family-asset" } + }; + } +
- -
-
-
🏪
-
-

사업자 세무

-
    -
  • ✓ 정확한 기장 및 결산
  • -
  • ✓ 세금계산서 관리
  • -
  • ✓ 경비처리 최적화
  • -
  • ✓ 절세 전략 수립
  • -
-

- 초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다. -

- 자세히 보기 + @foreach (var cardKey in cardOrder) + { + var isFeatured = cardKey == focusService; + if (cardKey == "business-tax") + { +
+
+ @if (isFeatured) {
현재 시즌
} +
🏪
+
+

사업자 세무

+
    +
  • ✓ 정확한 기장 및 결산
  • +
  • ✓ 세금계산서 관리
  • +
  • ✓ 경비처리 최적화
  • +
  • ✓ 절세 전략 수립
  • +
+

+ 초기부터 세무 전략을 수립하면 연간 최대 수백만 원의 절세가 가능합니다. +

+ 자세히 보기 +
+
-
-
- - -
-
-
🏠
-
-

부동산 세금

-
    -
  • ✓ 양도세 최소화
  • -
  • ✓ 취득세 절감
  • -
  • ✓ 임대소득 관리
  • -
  • ✓ 다주택자 세무
  • -
-

- 부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다. -

- 자세히 보기 + } + else if (cardKey == "real-estate-tax") + { +
+
+ @if (isFeatured) {
현재 시즌
} +
🏠
+
+

부동산 세금

+
    +
  • ✓ 양도세 최소화
  • +
  • ✓ 취득세 절감
  • +
  • ✓ 임대소득 관리
  • +
  • ✓ 다주택자 세무
  • +
+

+ 부동산 거래 시 미리 상담하면 세금 부담을 크게 줄일 수 있습니다. +

+ 자세히 보기 +
+
-
-
- - -
-
-
👨‍👩‍👧‍👦
-
-

가족자산 관리

-
    -
  • ✓ 증여세 전략
  • -
  • ✓ 상속세 대비
  • -
  • ✓ 자산 이전 계획
  • -
  • ✓ 가족법인 설립
  • -
-

- 세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다. -

- 자세히 보기 + } + else + { +
+
+ @if (isFeatured) {
현재 시즌
} +
👨‍👩‍👧‍👦
+
+

가족자산 관리

+
    +
  • ✓ 증여세 전략
  • +
  • ✓ 상속세 대비
  • +
  • ✓ 자산 이전 계획
  • +
  • ✓ 가족법인 설립
  • +
+

+ 세대 이전 전에 사전 계획하면 세금 부담을 현저히 줄일 수 있습니다. +

+ 자세히 보기 +
+
-
-
+ } + }
@@ -214,17 +306,32 @@ - +
-

세금 고민은 이제 끝!

-

- 무료 상담으로 현재 상황을 진단하고
- 맞춤형 절세 전략을 받아보세요. -

- + @if (season != null) + { +

@season.Name 마감이 다가옵니다!

+

+ 마감 D-@(season.DaysUntilDeadline)일 — 지금 바로 상담을 신청하세요.
+ 빠른 검토로 불이익 없이 신고를 완료합니다. +

+ + } + else + { +

세금 고민은 이제 끝!

+

+ 무료 상담으로 현재 상황을 진단하고
+ 맞춤형 절세 전략을 받아보세요. +

+ + }
diff --git a/TaxBaik.Web/Pages/Index.cshtml.cs b/TaxBaik.Web/Pages/Index.cshtml.cs index 1e0ac5f..da5c72a 100644 --- a/TaxBaik.Web/Pages/Index.cshtml.cs +++ b/TaxBaik.Web/Pages/Index.cshtml.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc.RazorPages; +using TaxBaik.Application.Seasonal; using TaxBaik.Application.Services; using TaxBaik.Domain.Entities; @@ -7,16 +8,36 @@ namespace TaxBaik.Web.Pages; public class IndexModel : PageModel { private readonly BlogService _blogService; + private readonly SeasonalMarketingService _seasonalMarketingService; + private readonly AnnouncementService _announcementService; public List RecentPosts { get; set; } = []; + public CurrentSeasonDto? CurrentSeason { get; set; } + public List ActiveAnnouncements { get; set; } = []; - public IndexModel(BlogService blogService) + public IndexModel( + BlogService blogService, + SeasonalMarketingService seasonalMarketingService, + AnnouncementService announcementService) { _blogService = blogService; + _seasonalMarketingService = seasonalMarketingService; + _announcementService = announcementService; } public async Task OnGetAsync() { + CurrentSeason = _seasonalMarketingService.GetCurrentSeason(); + + try + { + ActiveAnnouncements = (await _announcementService.GetActiveAsync()).ToList(); + } + catch + { + ActiveAnnouncements = []; + } + try { var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3); diff --git a/TaxBaik.Web/wwwroot/css/site.css b/TaxBaik.Web/wwwroot/css/site.css index 33ef8c7..59e908d 100644 --- a/TaxBaik.Web/wwwroot/css/site.css +++ b/TaxBaik.Web/wwwroot/css/site.css @@ -554,3 +554,90 @@ img { background-color: rgba(200, 157, 110, 0.15) !important; color: var(--color-primary) !important; } + +/* ===== 공지사항 배너 ===== */ +.announcement-bar { + border-bottom: 1px solid rgba(0,0,0,0.08); + font-size: 0.9rem; +} +.announcement-bar--info { + background: #E8F4FD; + color: #1565C0; +} +.announcement-bar--banner { + background: #FFF8E1; + color: #E65100; +} +.announcement-bar--urgent { + background: #FFEBEE; + color: #C62828; +} +.announcement-icon { + flex-shrink: 0; +} +.announcement-text { + flex: 1; +} + +/* ===== 시즌 Hero ===== */ +.hero-section--seasonal { + background: linear-gradient(135deg, #1F3A30 0%, #2E5C4E 60%, #3D7A68 100%); +} +.bg-danger-badge { + background-color: rgba(198, 40, 40, 0.85) !important; + color: #fff !important; + letter-spacing: 0.5px; +} + +/* D-Day 카운트다운 위젯 */ +.seasonal-deadline-badge { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 220px; + height: 220px; + border-radius: 50%; + border: 4px solid rgba(255,255,255,0.25); + background: rgba(255,255,255,0.08); + color: white; + backdrop-filter: blur(4px); +} +.deadline-label { + font-size: 0.85rem; + opacity: 0.75; + letter-spacing: 2px; + text-transform: uppercase; +} +.deadline-date { + font-size: 1.6rem; + font-weight: 800; + line-height: 1.2; + margin: 0.25rem 0; +} +.deadline-days { + font-size: 2.8rem; + font-weight: 900; + color: #FFD54F; + line-height: 1; +} + +/* ===== 서비스 카드 시즌 강조 ===== */ +.service-card--featured { + border: 2px solid var(--color-primary) !important; + position: relative; +} +.service-card-badge { + position: absolute; + top: -12px; + left: 50%; + transform: translateX(-50%); + background: var(--color-primary); + color: white; + font-size: 0.75rem; + font-weight: 700; + padding: 3px 14px; + border-radius: 20px; + white-space: nowrap; + letter-spacing: 0.5px; +} diff --git a/TaxBaik.Web/wwwroot/js/admin-session.js b/TaxBaik.Web/wwwroot/js/admin-session.js index b4a534a..3a135f9 100644 --- a/TaxBaik.Web/wwwroot/js/admin-session.js +++ b/TaxBaik.Web/wwwroot/js/admin-session.js @@ -1,4 +1,9 @@ window.taxbaikAdminSession = { + syncRouteClass: function () { + document.documentElement.classList.toggle( + 'admin-login-route', + window.location.pathname.toLowerCase().endsWith('/admin/login')); + }, clearAuthToken: function () { try { localStorage.removeItem('auth_token'); @@ -7,6 +12,9 @@ window.taxbaikAdminSession = { } }, watchReconnect: function () { + window.taxbaikAdminSession.syncRouteClass(); + window.addEventListener('popstate', window.taxbaikAdminSession.syncRouteClass); + const modal = document.getElementById('components-reconnect-modal'); if (!modal) { return; diff --git a/db/migrations/V005__CreateAnnouncements.sql b/db/migrations/V005__CreateAnnouncements.sql new file mode 100644 index 0000000..8e6ccab --- /dev/null +++ b/db/migrations/V005__CreateAnnouncements.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS announcements ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT, + display_type VARCHAR(20) NOT NULL DEFAULT 'info', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + starts_at TIMESTAMPTZ, + ends_at TIMESTAMPTZ, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON COLUMN announcements.display_type IS 'banner | info | urgent';