Przeglądaj źródła

cms xử lý add prize

student 3 tygodni temu
rodzic
commit
4f3dc97a36

+ 322 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Controllers/MonthlyWinnerController.cs

@@ -0,0 +1,322 @@
+using Common;
+using Database.Database;
+using Kitty_Fall.Cms.Helpers;
+using Kitty_Fall.Cms.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Kitty_Fall.Cms.Controllers;
+
+public class MonthlyWinnerController : CmsAuthorizedController
+{
+    private readonly ModelContext _db;
+
+    public MonthlyWinnerController(ModelContext db)
+    {
+        _db = db;
+    }
+
+    [HttpGet("/MonthlyWinner")]
+    [HttpGet("/MonthlyWinner/Index")]
+    public async Task<IActionResult> Index(int? monthKey = null)
+    {
+        if (!CheckLogin()) return RedirectToLogin();
+        if (!HasMenuPermission(CmsSecurityHelper.MonthlyWinnersMenuKey)) return RedirectNoPermission();
+
+        var normalizedMonthKey = NormalizeMonthKey(monthKey);
+        var draw = await _db.GameMonthlyDraws
+            .AsNoTracking()
+            .FirstOrDefaultAsync(x => x.MonthKey == normalizedMonthKey);
+
+        var winners = await (
+            from winner in _db.GameMonthlyWinners.AsNoTracking()
+            join ticket in _db.GameMonthlyDrawTickets.AsNoTracking()
+                on winner.TicketId equals ticket.TicketId into ticketJoin
+            from ticket in ticketJoin.DefaultIfEmpty()
+            where winner.MonthKey == normalizedMonthKey
+            orderby winner.CreatedTime descending
+            select new MonthlyWinnerItemViewModel
+            {
+                WinnerId = winner.WinnerId,
+                DrawId = winner.DrawId,
+                MonthKey = winner.MonthKey,
+                Msisdn = winner.Msisdn,
+                TicketId = winner.TicketId,
+                TicketNo = ticket == null ? null : ticket.TicketNo,
+                TotalScore = ticket == null ? null : ticket.TotalScore,
+                PrizeCode = winner.PrizeCode,
+                PrizeName = winner.PrizeName,
+                PrizeValue = winner.PrizeValue,
+                PrizeUnit = winner.PrizeUnit,
+                Status = winner.Status,
+                StatusText = ToStatusText(winner.Status),
+                AnnounceTime = winner.AnnounceTime,
+                PaidTime = winner.PaidTime,
+                CreatedTime = winner.CreatedTime
+            })
+            .ToListAsync();
+
+        var prizeConfigs = await _db.GameMonthlyPrizeConfigs
+            .AsNoTracking()
+            .Where(x => x.Status != false)
+            .OrderBy(x => x.DisplayOrder)
+            .ThenBy(x => x.PrizeConfigId)
+            .Select(x => new MonthlyWinnerPrizeViewModel
+            {
+                PrizeCode = x.PrizeCode,
+                PrizeName = x.PrizeName,
+                PrizeValue = x.PrizeValue,
+                PrizeUnit = x.PrizeUnit
+            })
+            .ToListAsync();
+
+        var selectedMsisdns = winners.Select(x => x.Msisdn).ToHashSet(StringComparer.OrdinalIgnoreCase);
+        var tickets = await _db.GameMonthlyDrawTickets
+            .AsNoTracking()
+            .Where(x => x.MonthKey == normalizedMonthKey && !selectedMsisdns.Contains(x.Msisdn))
+            .OrderByDescending(x => x.TotalScore)
+            .ThenBy(x => x.TicketNo)
+            .Take(300)
+            .Select(x => new MonthlyWinnerTicketViewModel
+            {
+                TicketId = x.TicketId,
+                Msisdn = x.Msisdn,
+                TicketNo = x.TicketNo,
+                TotalScore = x.TotalScore
+            })
+            .ToListAsync();
+
+        var model = new MonthlyWinnerViewModel
+        {
+            MonthKey = normalizedMonthKey,
+            MonthText = ToMonthText(normalizedMonthKey),
+            TotalWinners = winners.Count,
+            TotalEligible = draw?.TotalEligible ?? 0,
+            TotalTicket = draw?.TotalTicket ?? 0,
+            Winners = winners,
+            PrizeConfigs = prizeConfigs,
+            Tickets = tickets
+        };
+
+        return View(model);
+    }
+
+    [HttpPost]
+    public async Task<JsonResult> SaveWinner(MonthlyWinnerSaveRequest request)
+    {
+        if (!CheckLogin()) return Json(new { errorCode = 401, message = "Unauthorized" });
+        if (!HasMenuPermission(CmsSecurityHelper.MonthlyWinnersMenuKey)) return NoPermissionJson();
+
+        try
+        {
+            var monthKey = NormalizeMonthKey(request.MonthKey);
+            var prizeCode = (request.PrizeCode ?? string.Empty).Trim();
+            if (string.IsNullOrWhiteSpace(prizeCode))
+            {
+                return Json(new { errorCode = 400, message = "Prize is required." });
+            }
+
+            var prize = await _db.GameMonthlyPrizeConfigs
+                .AsNoTracking()
+                .FirstOrDefaultAsync(x => x.PrizeCode == prizeCode && x.Status != false);
+            if (prize == null)
+            {
+                return Json(new { errorCode = 404, message = "Prize config not found." });
+            }
+
+            var draw = await EnsureDrawAsync(monthKey);
+            var ticket = await LoadTicketAsync(monthKey, request.TicketId);
+            var msisdn = NormalizeMsisdn(ticket?.Msisdn ?? request.Msisdn);
+            if (string.IsNullOrWhiteSpace(msisdn))
+            {
+                return Json(new { errorCode = 400, message = "MSISDN or ticket is required." });
+            }
+
+            var duplicate = await _db.GameMonthlyWinners
+                .AsNoTracking()
+                .AnyAsync(x => x.DrawId == draw.DrawId
+                    && x.PrizeCode == prize.PrizeCode
+                    && x.Msisdn == msisdn
+                    && x.WinnerId != request.WinnerId);
+            if (duplicate)
+            {
+                return Json(new { errorCode = 409, message = "This winner already exists for the selected prize." });
+            }
+
+            var now = DateTime.Now;
+            GameMonthlyWinner? winner = null;
+            if (request.WinnerId > 0)
+            {
+                winner = await _db.GameMonthlyWinners.FirstOrDefaultAsync(x => x.WinnerId == request.WinnerId);
+                if (winner == null)
+                {
+                    return Json(new { errorCode = 404, message = "Winner not found." });
+                }
+            }
+
+            if (winner == null)
+            {
+                winner = new GameMonthlyWinner
+                {
+                    WinnerId = await NextDecimalIdAsync("SEQ_GAME_MONTHLY_WINNER"),
+                    DrawId = draw.DrawId,
+                    MonthKey = monthKey,
+                    CreatedTime = now
+                };
+                _db.GameMonthlyWinners.Add(winner);
+            }
+
+            winner.DrawId = draw.DrawId;
+            winner.MonthKey = monthKey;
+            winner.Msisdn = msisdn;
+            winner.TicketId = ticket?.TicketId;
+            winner.PrizeCode = prize.PrizeCode;
+            winner.PrizeName = prize.PrizeName;
+            winner.PrizeValue = prize.PrizeValue;
+            winner.PrizeUnit = prize.PrizeUnit;
+            winner.Status = NormalizeStatus(request.Status);
+            winner.UpdatedTime = now;
+
+            ApplyStatusTime(winner, now);
+            await _db.SaveChangesAsync();
+
+            return Json(new { errorCode = 200, message = "Success" });
+        }
+        catch (Exception ex)
+        {
+            return Json(new { errorCode = 500, message = ex.Message });
+        }
+    }
+
+    [HttpPost]
+    public async Task<JsonResult> UpdateStatus(decimal winnerId, byte status)
+    {
+        if (!CheckLogin()) return Json(new { errorCode = 401, message = "Unauthorized" });
+        if (!HasMenuPermission(CmsSecurityHelper.MonthlyWinnersMenuKey)) return NoPermissionJson();
+
+        var winner = await _db.GameMonthlyWinners.FirstOrDefaultAsync(x => x.WinnerId == winnerId);
+        if (winner == null)
+        {
+            return Json(new { errorCode = 404, message = "Winner not found." });
+        }
+
+        var now = DateTime.Now;
+        winner.Status = NormalizeStatus(status);
+        winner.UpdatedTime = now;
+        ApplyStatusTime(winner, now);
+        await _db.SaveChangesAsync();
+
+        return Json(new { errorCode = 200, message = "Success" });
+    }
+
+    private async Task<GameMonthlyDraw> EnsureDrawAsync(int monthKey)
+    {
+        var draw = await _db.GameMonthlyDraws.FirstOrDefaultAsync(x => x.MonthKey == monthKey);
+        if (draw != null) return draw;
+
+        var now = DateTime.Now;
+        draw = new GameMonthlyDraw
+        {
+            DrawId = await NextDecimalIdAsync("SEQ_GAME_MONTHLY_DRAW"),
+            MonthKey = monthKey,
+            Status = 0,
+            TotalEligible = 0,
+            TotalTicket = 0,
+            CreatedTime = now
+        };
+        _db.GameMonthlyDraws.Add(draw);
+        await _db.SaveChangesAsync();
+        return draw;
+    }
+
+    private async Task<GameMonthlyDrawTicket?> LoadTicketAsync(int monthKey, decimal? ticketId)
+    {
+        if (ticketId == null || ticketId <= 0) return null;
+        return await _db.GameMonthlyDrawTickets
+            .AsNoTracking()
+            .FirstOrDefaultAsync(x => x.TicketId == ticketId.Value && x.MonthKey == monthKey);
+    }
+
+    private static void ApplyStatusTime(GameMonthlyWinner winner, DateTime now)
+    {
+        if (winner.Status == 1 && winner.AnnounceTime == null)
+        {
+            winner.AnnounceTime = now;
+        }
+
+        if (winner.Status == 2)
+        {
+            winner.AnnounceTime ??= now;
+            winner.PaidTime ??= now;
+        }
+
+        winner.ResponseCode = winner.Status switch
+        {
+            0 => "DRAFT",
+            1 => "ANNOUNCED",
+            2 => "PAID_MANUAL",
+            3 => "CANCELLED",
+            _ => winner.ResponseCode
+        };
+        winner.ResponseMessage = winner.Status switch
+        {
+            0 => "waiting cms confirm",
+            1 => "announced by cms",
+            2 => "cash prize paid manually",
+            3 => "cancelled by cms",
+            _ => winner.ResponseMessage
+        };
+    }
+
+    private async Task<decimal> NextDecimalIdAsync(string sequence)
+    {
+        return await Database.DbLogic.GenIdAsync(_db, sequence) ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+    }
+
+    private static int NormalizeMonthKey(int? monthKey)
+    {
+        if (monthKey is >= 200001 and <= 299912)
+        {
+            var month = monthKey.Value % 100;
+            if (month is >= 1 and <= 12) return monthKey.Value;
+        }
+
+        var previousMonth = DateTime.Now.Date.AddMonths(-1);
+        return previousMonth.Year * 100 + previousMonth.Month;
+    }
+
+    private static string NormalizeMsisdn(string? msisdn)
+    {
+        var value = (msisdn ?? string.Empty).Trim();
+        if (value.StartsWith("0", StringComparison.Ordinal) && value.Length > 1)
+        {
+            value = "95" + value[1..];
+        }
+
+        return value;
+    }
+
+    private static byte NormalizeStatus(byte status)
+    {
+        return status is <= 3 ? status : (byte)0;
+    }
+
+    private static string ToMonthText(int monthKey)
+    {
+        var year = monthKey / 100;
+        var month = monthKey % 100;
+        return new DateTime(year, month, 1).ToString("MMMM yyyy", System.Globalization.CultureInfo.InvariantCulture);
+    }
+
+    private static string ToStatusText(byte status)
+    {
+        return status switch
+        {
+            0 => "Draft",
+            1 => "Announced",
+            2 => "Paid",
+            3 => "Cancelled",
+            _ => "Unknown"
+        };
+    }
+}

+ 15 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Helpers/CmsSecurityHelper.cs

@@ -16,6 +16,7 @@ public static class CmsSecurityHelper
     public const string JobsMenuKey = "jobs";
     public const string RevenueReportsMenuKey = "revenue-reports";
     public const string SubscriptionChargeReportsMenuKey = "subscription-charge-reports";
+    public const string MonthlyWinnersMenuKey = "monthly-winners";
     public const string EmployersMenuKey = "employers";
     public const string AccountsMenuKey = "accounts";
     public const string RolesMenuKey = "roles";
@@ -181,6 +182,20 @@ public static class CmsSecurityHelper
                 "/admin/subscriptionchargereport"
             }
         },
+        new()
+        {
+            Key = MonthlyWinnersMenuKey,
+            Title = "Monthly Winners",
+            Url = "/MonthlyWinner",
+            IconClass = "fas fa-trophy",
+            PermissionPaths = new List<string>
+            {
+                "/monthlywinner",
+                "/monthlywinner/index",
+                "/monthlywinner/savewinner",
+                "/monthlywinner/updatestatus"
+            }
+        },
         //new()
         //{
         //    Key = EmployersMenuKey,

+ 92 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Models/MonthlyWinnerViewModel.cs

@@ -0,0 +1,92 @@
+namespace Kitty_Fall.Cms.Models;
+
+public class MonthlyWinnerViewModel
+{
+    public int MonthKey { get; set; }
+
+    public string MonthText { get; set; } = string.Empty;
+
+    public int TotalWinners { get; set; }
+
+    public long TotalEligible { get; set; }
+
+    public long TotalTicket { get; set; }
+
+    public List<MonthlyWinnerItemViewModel> Winners { get; set; } = new();
+
+    public List<MonthlyWinnerPrizeViewModel> PrizeConfigs { get; set; } = new();
+
+    public List<MonthlyWinnerTicketViewModel> Tickets { get; set; } = new();
+}
+
+public class MonthlyWinnerItemViewModel
+{
+    public decimal WinnerId { get; set; }
+
+    public decimal DrawId { get; set; }
+
+    public int MonthKey { get; set; }
+
+    public string Msisdn { get; set; } = string.Empty;
+
+    public decimal? TicketId { get; set; }
+
+    public long? TicketNo { get; set; }
+
+    public long? TotalScore { get; set; }
+
+    public string PrizeCode { get; set; } = string.Empty;
+
+    public string PrizeName { get; set; } = string.Empty;
+
+    public decimal PrizeValue { get; set; }
+
+    public string PrizeUnit { get; set; } = string.Empty;
+
+    public byte Status { get; set; }
+
+    public string StatusText { get; set; } = string.Empty;
+
+    public DateTime? AnnounceTime { get; set; }
+
+    public DateTime? PaidTime { get; set; }
+
+    public DateTime CreatedTime { get; set; }
+}
+
+public class MonthlyWinnerPrizeViewModel
+{
+    public string PrizeCode { get; set; } = string.Empty;
+
+    public string PrizeName { get; set; } = string.Empty;
+
+    public decimal PrizeValue { get; set; }
+
+    public string PrizeUnit { get; set; } = string.Empty;
+}
+
+public class MonthlyWinnerTicketViewModel
+{
+    public decimal TicketId { get; set; }
+
+    public string Msisdn { get; set; } = string.Empty;
+
+    public long TicketNo { get; set; }
+
+    public long TotalScore { get; set; }
+}
+
+public class MonthlyWinnerSaveRequest
+{
+    public decimal WinnerId { get; set; }
+
+    public int MonthKey { get; set; }
+
+    public string? Msisdn { get; set; }
+
+    public decimal? TicketId { get; set; }
+
+    public string? PrizeCode { get; set; }
+
+    public byte Status { get; set; }
+}

+ 13 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Program.cs

@@ -1,7 +1,13 @@
+using Database.Database;
+using Microsoft.EntityFrameworkCore;
+
 var builder = WebApplication.CreateBuilder(args);
 
 // Add services to the container.
 builder.Services.AddControllersWithViews();
+builder.Services.AddDbContext<ModelContext>(options =>
+    options.UseOracle(builder.Configuration["Connection"]));
+builder.Services.AddSession();
 
 var app = builder.Build();
 
@@ -16,8 +22,15 @@ if (!app.Environment.IsDevelopment())
 app.UseHttpsRedirection();
 app.UseStaticFiles();
 
+var pathBase = app.Configuration["PathBase"];
+if (!string.IsNullOrWhiteSpace(pathBase))
+{
+    app.UsePathBase(pathBase);
+}
+
 app.UseRouting();
 
+app.UseSession();
 app.UseAuthorization();
 
 app.MapControllerRoute(

+ 1 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Views/CmsAccount/Permissions.cshtml

@@ -58,7 +58,7 @@
         </div>
     </div>
 
-    @await Html.PartialAsync("_Pagination", new MyJob.Cms.Models.PaginationModel { Page = ViewBag.Page ?? 1, TotalPages = ViewBag.TotalPages ?? 1, BaseUrl = "/CmsAccount/Permissions" })
+    @await Html.PartialAsync("_Pagination", new Kitty_Fall.Cms.Models.PaginationModel { Page = ViewBag.Page ?? 1, TotalPages = ViewBag.TotalPages ?? 1, BaseUrl = "/CmsAccount/Permissions" })
 </div>
 
 <!-- Permission Modal -->

+ 1 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Views/CmsAccount/Roles.cshtml

@@ -51,7 +51,7 @@
         }
     </div>
 
-    @await Html.PartialAsync("_Pagination", new MyJob.Cms.Models.PaginationModel { Page = ViewBag.Page ?? 1, TotalPages = ViewBag.TotalPages ?? 1, BaseUrl = "/CmsAccount/Roles" })
+    @await Html.PartialAsync("_Pagination", new Kitty_Fall.Cms.Models.PaginationModel { Page = ViewBag.Page ?? 1, TotalPages = ViewBag.TotalPages ?? 1, BaseUrl = "/CmsAccount/Roles" })
 </div>
 
 <!-- Role Modal -->

+ 1 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Views/CmsAccount/Users.cshtml

@@ -75,7 +75,7 @@
         </div>
     </div>
 
-    @await Html.PartialAsync("_Pagination", new MyJob.Cms.Models.PaginationModel { Page = ViewBag.Page ?? 1, TotalPages = ViewBag.TotalPages ?? 1, BaseUrl = "/CmsAccount/Users" })
+    @await Html.PartialAsync("_Pagination", new Kitty_Fall.Cms.Models.PaginationModel { Page = ViewBag.Page ?? 1, TotalPages = ViewBag.TotalPages ?? 1, BaseUrl = "/CmsAccount/Users" })
 </div>
 
 <!-- User Modal -->

+ 314 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Views/MonthlyWinner/Index.cshtml

@@ -0,0 +1,314 @@
+@model Kitty_Fall.Cms.Models.MonthlyWinnerViewModel
+@{
+    ViewData["Title"] = "Monthly Winners";
+
+    string FormatMoney(decimal value) => value.ToString("#,##0.##");
+    string FormatTime(DateTime? value) => value?.ToString("dd/MM/yyyy HH:mm") ?? "-";
+    string StatusClass(byte status) => status switch
+    {
+        0 => "bg-gray-100 text-gray-700",
+        1 => "bg-blue-50 text-blue-700",
+        2 => "bg-emerald-50 text-emerald-700",
+        3 => "bg-red-50 text-red-700",
+        _ => "bg-gray-100 text-gray-700"
+    };
+}
+
+<div class="py-6 sm:py-8">
+    <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
+        <div>
+            <h1 class="text-2xl font-bold text-gray-900">Monthly Winners</h1>
+            <p class="mt-1 text-sm text-gray-500">Select cash prize winners manually and publish them to the website.</p>
+        </div>
+        <button onclick="openWinnerModal()" class="inline-flex items-center justify-center rounded-2xl bg-admin-active px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-purple-700">
+            <i class="fas fa-plus mr-2"></i>
+            Add Winner
+        </button>
+    </div>
+
+    <div class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100">
+        <div class="border-b border-gray-100 px-5 py-5 sm:px-6">
+            <form method="get" action="@Url.Content("~/MonthlyWinner")" class="grid gap-3 md:grid-cols-[minmax(0,260px)_auto]">
+                <label class="block">
+                    <span class="mb-2 block text-sm font-semibold text-gray-700">Month</span>
+                    <input type="month"
+                           name="month"
+                           id="monthPicker"
+                           value="@($"{Model.MonthKey / 100:D4}-{Model.MonthKey % 100:D2}")"
+                           class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm text-gray-700 outline-none transition focus:border-admin-active focus:ring-2 focus:ring-purple-100" />
+                    <input type="hidden" name="monthKey" id="monthKeyInput" value="@Model.MonthKey" />
+                </label>
+                <div class="flex items-end">
+                    <button type="submit" class="inline-flex w-full items-center justify-center rounded-2xl bg-admin-active px-4 py-3 text-sm font-semibold text-white transition hover:bg-purple-700 md:w-auto">
+                        Apply filter
+                    </button>
+                </div>
+            </form>
+
+            <div class="mt-5 grid gap-3 sm:grid-cols-3">
+                <div class="rounded-2xl bg-gray-50 px-4 py-3">
+                    <div class="text-xs font-bold uppercase tracking-[0.16em] text-gray-400">Month</div>
+                    <div class="mt-1 text-lg font-bold text-gray-900">@Model.MonthText</div>
+                </div>
+                <div class="rounded-2xl bg-gray-50 px-4 py-3">
+                    <div class="text-xs font-bold uppercase tracking-[0.16em] text-gray-400">Eligible / Ticket</div>
+                    <div class="mt-1 text-lg font-bold text-gray-900">@Model.TotalEligible.ToString("#,##0") / @Model.TotalTicket.ToString("#,##0")</div>
+                </div>
+                <div class="rounded-2xl bg-gray-50 px-4 py-3">
+                    <div class="text-xs font-bold uppercase tracking-[0.16em] text-gray-400">Winners</div>
+                    <div class="mt-1 text-lg font-bold text-gray-900">@Model.TotalWinners.ToString("#,##0")</div>
+                </div>
+            </div>
+        </div>
+
+        @if (Model.Winners.Count == 0)
+        {
+            <div class="px-5 pb-6 sm:px-6">
+                <div class="rounded-3xl border border-dashed border-gray-200 bg-gray-50 px-6 py-10 text-center">
+                    <div class="text-base font-semibold text-gray-800">No monthly winner found.</div>
+                    <div class="mt-2 text-sm text-gray-500">Add a winner manually or let the worker generate draft winners.</div>
+                </div>
+            </div>
+        }
+        else
+        {
+            <div class="hidden px-5 pb-6 sm:block sm:px-6">
+                <div class="overflow-hidden rounded-3xl border border-gray-100">
+                    <div class="overflow-x-auto">
+                        <table class="min-w-full divide-y divide-gray-100">
+                            <thead class="bg-gray-50">
+                                <tr class="text-left text-xs font-bold uppercase tracking-[0.14em] text-gray-500">
+                                    <th class="px-4 py-3">MSISDN</th>
+                                    <th class="px-4 py-3">Prize</th>
+                                    <th class="px-4 py-3">Ticket</th>
+                                    <th class="px-4 py-3">Status</th>
+                                    <th class="px-4 py-3">Announced</th>
+                                    <th class="px-4 py-3">Paid</th>
+                                    <th class="px-4 py-3 text-right">Actions</th>
+                                </tr>
+                            </thead>
+                            <tbody class="divide-y divide-gray-100 bg-white text-sm text-gray-700">
+                                @foreach (var winner in Model.Winners)
+                                {
+                                    <tr class="align-top">
+                                        <td class="whitespace-nowrap px-4 py-4 font-semibold text-gray-900">@winner.Msisdn</td>
+                                        <td class="whitespace-nowrap px-4 py-4">
+                                            <div class="font-semibold text-gray-900">@winner.PrizeName</div>
+                                            <div class="text-xs text-gray-500">@FormatMoney(winner.PrizeValue) @winner.PrizeUnit</div>
+                                        </td>
+                                        <td class="whitespace-nowrap px-4 py-4">
+                                            <div class="font-semibold text-gray-900">@(winner.TicketNo?.ToString() ?? "-")</div>
+                                            <div class="text-xs text-gray-500">Score: @(winner.TotalScore?.ToString("#,##0") ?? "-")</div>
+                                        </td>
+                                        <td class="whitespace-nowrap px-4 py-4">
+                                            <span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-bold @StatusClass(winner.Status)">
+                                                @winner.StatusText
+                                            </span>
+                                        </td>
+                                        <td class="whitespace-nowrap px-4 py-4">@FormatTime(winner.AnnounceTime)</td>
+                                        <td class="whitespace-nowrap px-4 py-4">@FormatTime(winner.PaidTime)</td>
+                                        <td class="whitespace-nowrap px-4 py-4 text-right">
+                                            <button class="btn-edit-winner mr-3 font-semibold text-admin-active hover:text-purple-800"
+                                                    data-winner='@Json.Serialize(winner)'>Edit</button>
+                                            @if (winner.Status == 0)
+                                            {
+                                                <button onclick="updateWinnerStatus(@winner.WinnerId, 1)" class="mr-3 font-semibold text-blue-600 hover:text-blue-800">Announce</button>
+                                            }
+                                            @if (winner.Status != 2)
+                                            {
+                                                <button onclick="updateWinnerStatus(@winner.WinnerId, 2)" class="mr-3 font-semibold text-emerald-600 hover:text-emerald-800">Paid</button>
+                                            }
+                                            @if (winner.Status != 3)
+                                            {
+                                                <button onclick="updateWinnerStatus(@winner.WinnerId, 3)" class="font-semibold text-red-600 hover:text-red-800">Cancel</button>
+                                            }
+                                        </td>
+                                    </tr>
+                                }
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+            </div>
+
+            <div class="px-5 pb-6 sm:hidden">
+                <div class="space-y-4">
+                    @foreach (var winner in Model.Winners)
+                    {
+                        <div class="rounded-3xl border border-gray-100 bg-gray-50 p-4 shadow-sm">
+                            <div class="flex items-start justify-between gap-3">
+                                <div>
+                                    <div class="text-xs font-bold uppercase tracking-[0.18em] text-admin-active">@winner.PrizeCode</div>
+                                    <div class="mt-1 text-lg font-bold text-gray-900">@winner.Msisdn</div>
+                                    <div class="mt-1 text-sm text-gray-500">@winner.PrizeName - @FormatMoney(winner.PrizeValue) @winner.PrizeUnit</div>
+                                </div>
+                                <span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-bold @StatusClass(winner.Status)">
+                                    @winner.StatusText
+                                </span>
+                            </div>
+                            <div class="mt-4 grid grid-cols-2 gap-3 text-sm">
+                                <div class="rounded-2xl bg-white px-3 py-2">
+                                    <div class="text-gray-500">Ticket</div>
+                                    <div class="mt-1 font-semibold text-gray-900">@(winner.TicketNo?.ToString() ?? "-")</div>
+                                </div>
+                                <div class="rounded-2xl bg-white px-3 py-2">
+                                    <div class="text-gray-500">Score</div>
+                                    <div class="mt-1 font-semibold text-gray-900">@(winner.TotalScore?.ToString("#,##0") ?? "-")</div>
+                                </div>
+                            </div>
+                            <div class="mt-4 flex flex-wrap gap-3 text-sm">
+                                <button class="btn-edit-winner font-semibold text-admin-active"
+                                        data-winner='@Json.Serialize(winner)'>Edit</button>
+                                <button onclick="updateWinnerStatus(@winner.WinnerId, 1)" class="font-semibold text-blue-600">Announce</button>
+                                <button onclick="updateWinnerStatus(@winner.WinnerId, 2)" class="font-semibold text-emerald-600">Paid</button>
+                                <button onclick="updateWinnerStatus(@winner.WinnerId, 3)" class="font-semibold text-red-600">Cancel</button>
+                            </div>
+                        </div>
+                    }
+                </div>
+            </div>
+        }
+    </div>
+</div>
+
+<div id="winnerModal" class="fixed inset-0 z-50 hidden overflow-y-auto bg-black/50 p-4 sm:p-6">
+    <div class="mx-auto mt-10 w-full max-w-2xl overflow-hidden rounded-3xl bg-white shadow-xl">
+        <div class="flex items-center justify-between border-b border-gray-100 px-6 py-4">
+            <h3 id="winnerModalTitle" class="text-lg font-bold text-gray-900">Add Monthly Winner</h3>
+            <button onclick="closeWinnerModal()" class="text-gray-400 hover:text-gray-600">
+                <i class="fas fa-times text-xl"></i>
+            </button>
+        </div>
+        <form id="winnerForm" class="p-6 space-y-4">
+            <input type="hidden" id="winnerId" name="WinnerId" value="0" />
+            <input type="hidden" name="MonthKey" value="@Model.MonthKey" />
+
+            <div class="grid gap-4 md:grid-cols-2">
+                <label class="block">
+                    <span class="mb-2 block text-sm font-semibold text-gray-700">Prize</span>
+                    <select id="prizeCode" name="PrizeCode" required class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm outline-none focus:border-admin-active focus:ring-2 focus:ring-purple-100">
+                        <option value="">Select prize</option>
+                        @foreach (var prize in Model.PrizeConfigs)
+                        {
+                            <option value="@prize.PrizeCode">@prize.PrizeName - @FormatMoney(prize.PrizeValue) @prize.PrizeUnit</option>
+                        }
+                    </select>
+                </label>
+                <label class="block">
+                    <span class="mb-2 block text-sm font-semibold text-gray-700">Status</span>
+                    <select id="winnerStatus" name="Status" class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm outline-none focus:border-admin-active focus:ring-2 focus:ring-purple-100">
+                        <option value="0">Draft</option>
+                        <option value="1">Announced</option>
+                        <option value="2">Paid manually</option>
+                        <option value="3">Cancelled</option>
+                    </select>
+                </label>
+            </div>
+
+            <label class="block">
+                <span class="mb-2 block text-sm font-semibold text-gray-700">Eligible ticket</span>
+                <select id="ticketId" name="TicketId" class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm outline-none focus:border-admin-active focus:ring-2 focus:ring-purple-100">
+                    <option value="">Manual MSISDN</option>
+                    @foreach (var ticket in Model.Tickets)
+                    {
+                        <option value="@ticket.TicketId" data-msisdn="@ticket.Msisdn">@ticket.Msisdn - ticket @ticket.TicketNo - @ticket.TotalScore.ToString("#,##0") points</option>
+                    }
+                </select>
+            </label>
+
+            <label class="block">
+                <span class="mb-2 block text-sm font-semibold text-gray-700">MSISDN</span>
+                <input type="text" id="msisdn" name="Msisdn" placeholder="95123456789"
+                       class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm outline-none focus:border-admin-active focus:ring-2 focus:ring-purple-100" />
+            </label>
+
+            <div class="rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800">
+                Monthly cash prizes are paid manually. Saving here only updates <span class="font-semibold">GAME_MONTHLY_WINNER</span>; it does not call WS or reward API.
+            </div>
+
+            <div class="flex items-center justify-end gap-3 pt-4">
+                <button type="button" onclick="closeWinnerModal()" class="rounded-2xl bg-gray-100 px-6 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Cancel</button>
+                <button type="submit" class="rounded-2xl bg-admin-active px-6 py-2.5 text-sm font-semibold text-white hover:bg-purple-700">Save Winner</button>
+            </div>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $('#monthPicker').on('change', function () {
+            const value = $(this).val();
+            $('#monthKeyInput').val(value ? value.replace('-', '') : '@Model.MonthKey');
+        });
+
+        function openWinnerModal() {
+            $('#winnerModalTitle').text('Add Monthly Winner');
+            $('#winnerForm')[0].reset();
+            $('#winnerId').val('0');
+            $('#winnerStatus').val('0');
+            $('#winnerModal').removeClass('hidden');
+        }
+
+        function closeWinnerModal() {
+            $('#winnerModal').addClass('hidden');
+        }
+
+        $('#ticketId').on('change', function () {
+            const msisdn = $(this).find(':selected').data('msisdn') || '';
+            if (msisdn) {
+                $('#msisdn').val(msisdn);
+            }
+        });
+
+        $('.btn-edit-winner').on('click', function () {
+            const winner = $(this).data('winner');
+            $('#winnerModalTitle').text('Edit Monthly Winner');
+            $('#winnerId').val(winner.winnerId || winner.WinnerId || 0);
+            $('#prizeCode').val(winner.prizeCode || winner.PrizeCode || '');
+            $('#winnerStatus').val(winner.status ?? winner.Status ?? 0);
+            $('#ticketId').val(winner.ticketId || winner.TicketId || '');
+            $('#msisdn').val(winner.msisdn || winner.Msisdn || '');
+            $('#winnerModal').removeClass('hidden');
+        });
+
+        $('#winnerForm').on('submit', function (e) {
+            e.preventDefault();
+            $.ajax({
+                url: '@Url.Content("~/MonthlyWinner/SaveWinner")',
+                type: 'POST',
+                data: $(this).serialize(),
+                success: function (res) {
+                    if (res.errorCode === 200) {
+                        showToast('Monthly winner saved successfully');
+                        setTimeout(() => location.reload(), 800);
+                    } else {
+                        showToast(res.message, 'error');
+                    }
+                }
+            });
+        });
+
+        function updateWinnerStatus(winnerId, status) {
+            const label = status === 1 ? 'announce this winner' : status === 2 ? 'mark this winner as paid' : 'cancel this winner';
+            showConfirm(
+                'Update Winner Status',
+                'Are you sure you want to ' + label + '?',
+                function () {
+                    $.ajax({
+                        url: '@Url.Content("~/MonthlyWinner/UpdateStatus")',
+                        type: 'POST',
+                        data: { winnerId: winnerId, status: status },
+                        success: function (res) {
+                            if (res.errorCode === 200) {
+                                showToast('Winner status updated successfully');
+                                setTimeout(() => location.reload(), 800);
+                            } else {
+                                showToast(res.message, 'error');
+                            }
+                        }
+                    });
+                }
+            );
+        }
+    </script>
+}

+ 21 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Cms/Views/Shared/_Layout.cshtml

@@ -14,6 +14,7 @@
     var canViewJobs = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.JobsMenuKey);
     var canViewRevenueReports = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.RevenueReportsMenuKey);
     var canViewSubscriptionChargeReports = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.SubscriptionChargeReportsMenuKey);
+    var canViewMonthlyWinners = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.MonthlyWinnersMenuKey);
     var canViewEmployers = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.EmployersMenuKey);
     var canViewAccounts = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.AccountsMenuKey);
     var canViewRoles = CmsSecurityHelper.HasMenuAccess(cmsSession, CmsSecurityHelper.RolesMenuKey);
@@ -201,6 +202,16 @@
                         </a>
                     </li>
                 }
+                @if (canViewMonthlyWinners)
+                {
+                    <li class="relative px-6 py-3">
+                        <span class="@NavMarkerClass("/monthlywinner") absolute inset-y-0 left-0 w-1 bg-admin-active rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
+                        <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 @NavLinkClass("/monthlywinner")" href="~/MonthlyWinner" onclick="closeMobileSidebar()">
+                            <i class="fas fa-trophy w-5 h-5"></i>
+                            <span class="ml-4">Monthly Winners</span>
+                        </a>
+                    </li>
+                }
                 @if (canViewEmployers)
                 {
                     <li class="relative px-6 py-3">
@@ -379,6 +390,16 @@
                             </a>
                         </li>
                     }
+                    @if (canViewMonthlyWinners)
+                    {
+                        <li class="relative px-6 py-3">
+                            <span class="@NavMarkerClass("/monthlywinner") absolute inset-y-0 left-0 w-1 bg-admin-active rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
+                            <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 @NavLinkClass("/monthlywinner")" href="~/MonthlyWinner">
+                                <i class="fas fa-trophy w-5 h-5"></i>
+                                <span class="ml-4">Monthly Winners</span>
+                            </a>
+                        </li>
+                    }
                     @if (canViewEmployers)
                     {
                         <li class="relative px-6 py-3">