feat: notifications
parent
76a68b1645
commit
e05b42a39f
|
|
@ -0,0 +1,105 @@
|
||||||
|
using BankSampahApp.Models;
|
||||||
|
using BankSampahApp.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BankSampahApp.Controllers;
|
||||||
|
|
||||||
|
public class NotificationsController : Controller
|
||||||
|
{
|
||||||
|
private readonly INotificationService _notificationService;
|
||||||
|
|
||||||
|
public NotificationsController(INotificationService notificationService)
|
||||||
|
{
|
||||||
|
_notificationService = notificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Index(string? tab)
|
||||||
|
{
|
||||||
|
NotificationCategory? categoryFilter = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(tab) && !tab.Equals("Semua", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& Enum.TryParse(tab, ignoreCase: true, out NotificationCategory parsedCategory))
|
||||||
|
{
|
||||||
|
categoryFilter = parsedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewModel = new NotificationListViewModel
|
||||||
|
{
|
||||||
|
ActiveCategory = categoryFilter
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Show(int id)
|
||||||
|
{
|
||||||
|
var notification = _notificationService.GetById(id);
|
||||||
|
if (notification is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notification.IsRead)
|
||||||
|
{
|
||||||
|
await _notificationService.UpdateReadStateAsync(id, true);
|
||||||
|
notification.IsRead = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Table(string? category)
|
||||||
|
{
|
||||||
|
NotificationCategory? categoryFilter = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(category) &&
|
||||||
|
Enum.TryParse(category, ignoreCase: true, out NotificationCategory parsedCategory))
|
||||||
|
{
|
||||||
|
categoryFilter = parsedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifications = await _notificationService.GetNotificationsAsync(categoryFilter);
|
||||||
|
|
||||||
|
var rows = notifications.Select(n => new
|
||||||
|
{
|
||||||
|
id = n.Id,
|
||||||
|
title = n.Title,
|
||||||
|
category = n.Category.ToString(),
|
||||||
|
severity = n.Severity.ToString(),
|
||||||
|
isRead = n.IsRead,
|
||||||
|
summary = n.Summary,
|
||||||
|
createdAt = n.CreatedAt.ToString("O"),
|
||||||
|
detailUrl = Url.Action("Show", "Notifications", new { id = n.Id })
|
||||||
|
});
|
||||||
|
|
||||||
|
return Json(new { data = rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> UpdateReadStatus(int id, bool isRead)
|
||||||
|
{
|
||||||
|
var success = await _notificationService.UpdateReadStateAsync(id, isRead);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
var success = await _notificationService.DeleteAsync(id);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
namespace BankSampahApp.Models;
|
||||||
|
|
||||||
|
public enum NotificationCategory
|
||||||
|
{
|
||||||
|
StatusAkun,
|
||||||
|
Transaksi,
|
||||||
|
Pengajuan
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NotificationSeverity
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
public bool IsRead { get; set; }
|
||||||
|
public NotificationCategory Category { get; set; }
|
||||||
|
public NotificationSeverity Severity { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public string Link { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace BankSampahApp.Models;
|
||||||
|
|
||||||
|
public class NotificationListViewModel
|
||||||
|
{
|
||||||
|
public NotificationCategory? ActiveCategory { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ builder.Services.AddControllersWithViews(options =>
|
||||||
// Register application services
|
// Register application services
|
||||||
builder.Services.AddScoped<IHomeService, HomeService>();
|
builder.Services.AddScoped<IHomeService, HomeService>();
|
||||||
builder.Services.AddScoped<IStatisticsService, StatisticsService>();
|
builder.Services.AddScoped<IStatisticsService, StatisticsService>();
|
||||||
|
builder.Services.AddSingleton<INotificationService, NotificationService>();
|
||||||
|
|
||||||
// Register new optimized services
|
// Register new optimized services
|
||||||
builder.Services.AddSingleton<ICacheService, CacheService>();
|
builder.Services.AddSingleton<ICacheService, CacheService>();
|
||||||
|
|
@ -158,4 +159,4 @@ app.Lifetime.ApplicationStopping.Register(() =>
|
||||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using BankSampahApp.Models;
|
||||||
|
|
||||||
|
namespace BankSampahApp.Services;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<NotificationItem>> GetNotificationsAsync(NotificationCategory? category = null);
|
||||||
|
NotificationItem? GetById(int id);
|
||||||
|
Task<bool> UpdateReadStateAsync(int id, bool isRead);
|
||||||
|
Task<bool> DeleteAsync(int id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
using BankSampahApp.Models;
|
||||||
|
|
||||||
|
namespace BankSampahApp.Services;
|
||||||
|
|
||||||
|
public class NotificationService : INotificationService
|
||||||
|
{
|
||||||
|
private readonly List<NotificationItem> _notifications =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Title = "User BSU - 1",
|
||||||
|
Category = NotificationCategory.StatusAkun,
|
||||||
|
Severity = NotificationSeverity.Warning,
|
||||||
|
Summary = "Status akun Anda saat ini belum disetujui oleh [Nama BSU]. Mohon menunggu proses verifikasi data oleh pihak BSU. Anda akan mendapatkan notifikasi kembali setelah data Anda diverifikasi.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-1),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.StatusAkun,
|
||||||
|
Severity = NotificationSeverity.Success,
|
||||||
|
Summary = "Selamat! Akun Anda telah disetujui dan aktif oleh [Nama Bank Sampah Unit (BSU)]. Anda kini dapat melakukan transaksi penyetoran dan penarikan sampah melalui aplikasi Bank Sampah.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-2),
|
||||||
|
IsRead = true
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.StatusAkun,
|
||||||
|
Severity = NotificationSeverity.Error,
|
||||||
|
Summary = "Akun Anda ditolak oleh [Nama Bank Sampah Unit (BSU)]. Alasan: i. Data yang Anda berikan tidak valid atau tidak sesuai. ii. Pelanggaran terhadap kebijakan atau aturan Bank Sampah. Silakan hubungi [Nama BSU] untuk informasi lebih lanjut atau proses pengaktifan kembali akun Anda.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-3),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 4,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.Transaksi,
|
||||||
|
Severity = NotificationSeverity.Success,
|
||||||
|
Summary = "Terima kasih telah melakukan pengumpulan sampah di [Nama BSU]! Anda telah menyetorkan sampah seberat {berat_sampah} kg dengan total nilai sebesar Rp {jumlah_uang}. Terus berkontribusi menjaga lingkungan!",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-4),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 5,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.Transaksi,
|
||||||
|
Severity = NotificationSeverity.Success,
|
||||||
|
Summary = "Penarikan saldo Anda telah berhasil diproses di [Nama BSU]. Jumlah yang ditarik: Rp {jumlah_uang}. Sisa saldo tabungan Anda saat ini: Rp {sisa_saldo}. Terima kasih telah berpartisipasi!",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-5),
|
||||||
|
IsRead = true
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 6,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.StatusAkun,
|
||||||
|
Severity = NotificationSeverity.Success,
|
||||||
|
Summary = "Selamat! Akun Bank Sampah Unit (BSU) Anda telah disetujui dan diaktifkan oleh pihak Dinas. Anda kini dapat melakukan pengelolaan data nasabah, transaksi, serta pelaporan melalui sistem Bank Sampah.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-6),
|
||||||
|
IsRead = true
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.StatusAkun,
|
||||||
|
Severity = NotificationSeverity.Warning,
|
||||||
|
Summary = "Status verifikasi Bank Sampah Unit (BSU) Anda saat ini belum disetujui oleh pihak Dinas. Mohon menunggu proses pemeriksaan data dan kelengkapan dokumen.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-7),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 8,
|
||||||
|
Title = "User BSU - 2",
|
||||||
|
Category = NotificationCategory.StatusAkun,
|
||||||
|
Severity = NotificationSeverity.Error,
|
||||||
|
Summary = "Mohon maaf, akun Bank Sampah Unit (BSU) Anda tidak dapat diaktifkan oleh pihak Dinas. Silakan lengkapi data atau hubungi pihak Dinas untuk proses klarifikasi dan pengajuan ulang.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-8),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 9,
|
||||||
|
Title = "Pengajuan BSU - 1",
|
||||||
|
Category = NotificationCategory.Pengajuan,
|
||||||
|
Severity = NotificationSeverity.Info,
|
||||||
|
Summary = "Pengajuan verifikasi Bank Sampah Unit (BSU) Anda telah berhasil dikirim ke Bank Sampah Induk (BSI).\nMohon menunggu proses verifikasi dan persetujuan dari pihak BSI.\nAnda akan mendapatkan notifikasi kembali setelah pengajuan diverifikasi. ♻️",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-9),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
Title = "Pengajuan BSU - 1",
|
||||||
|
Category = NotificationCategory.Pengajuan,
|
||||||
|
Severity = NotificationSeverity.Success,
|
||||||
|
Summary = "Selamat! 🎉\nPengajuan verifikasi Bank Sampah Unit (BSU) Anda telah disetujui dan diaktifkan oleh Bank Sampah Induk (BSI).\nAnda kini dapat mulai melakukan pengelolaan data transaksi.",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-10),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 11,
|
||||||
|
Title = "Pengajuan BSU - 2",
|
||||||
|
Category = NotificationCategory.Pengajuan,
|
||||||
|
Severity = NotificationSeverity.Warning,
|
||||||
|
Summary = "Anda menerima pengajuan keanggotaan baru dari Nasabah yang ingin bergabung dengan Bank Sampah Unit (BSU) Anda.\nMohon lakukan verifikasi data dan kelengkapan identitas sebelum memberikan keputusan.\n🔹 Nama Nasabah: {nama_nasabah}\n🔹 Tanggal Pengajuan: {tanggal_pengajuan}\n🔹 Nomor HP / ID Nasabah: {id_nasabah}\nLakukan verifikasi agar nasabah dapat segera menjadi anggota aktif BSU Anda. ♻️",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-11),
|
||||||
|
IsRead = true
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 12,
|
||||||
|
Title = "Pengajuan BSU - 2",
|
||||||
|
Category = NotificationCategory.Pengajuan,
|
||||||
|
Severity = NotificationSeverity.Info,
|
||||||
|
Summary = "Anda menerima pengajuan keanggotaan baru dari Bank Sampah Unit (BSU).\nMohon tinjau dan verifikasi data sebelum menyetujui atau menolak pengajuan.\n🔹 Nama BSU: {nama_bsu}\n🔹 Tanggal Pengajuan: {tanggal_pengajuan}\n🔹 Diajukan oleh: {nama_pengaju / penanggung_jawab}\nSegera lakukan verifikasi agar BSU dapat menjadi bagian dari jaringan Bank Sampah Induk Anda. ♻️",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-12),
|
||||||
|
IsRead = false
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 13,
|
||||||
|
Title = "Pengajuan BSU - 3",
|
||||||
|
Category = NotificationCategory.Pengajuan,
|
||||||
|
Severity = NotificationSeverity.Warning,
|
||||||
|
Summary = "Terdapat pengajuan verifikasi baru dari Bank Sampah Unit (BSU) yang perlu ditinjau.\nSilakan lakukan pemeriksaan data dan dokumen BSU sebelum menentukan status persetujuan.\n🔹 Nama BSU: {nama_bsu}\n🔹 Tanggal Pengajuan: {tanggal_pengajuan}\n🔹 Diajukan oleh: {nama_pengaju / penanggung_jawab}\nSegera lakukan verifikasi agar BSU dapat melanjutkan proses operasionalnya. ♻️",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-13),
|
||||||
|
IsRead = true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<NotificationItem>> GetNotificationsAsync(NotificationCategory? category = null)
|
||||||
|
{
|
||||||
|
IEnumerable<NotificationItem> query = _notifications;
|
||||||
|
|
||||||
|
if (category.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.Category == category.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<NotificationItem>>(query
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Select(n => new NotificationItem
|
||||||
|
{
|
||||||
|
Id = n.Id,
|
||||||
|
Title = n.Title,
|
||||||
|
Summary = n.Summary,
|
||||||
|
Category = n.Category,
|
||||||
|
Severity = n.Severity,
|
||||||
|
CreatedAt = n.CreatedAt,
|
||||||
|
Link = $"/Notifications/Show/{n.Id}",
|
||||||
|
IsRead = n.IsRead
|
||||||
|
})
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotificationItem? GetById(int id)
|
||||||
|
{
|
||||||
|
var notification = _notifications.FirstOrDefault(n => n.Id == id);
|
||||||
|
return notification is null
|
||||||
|
? null
|
||||||
|
: new NotificationItem
|
||||||
|
{
|
||||||
|
Id = notification.Id,
|
||||||
|
Title = notification.Title,
|
||||||
|
Summary = notification.Summary,
|
||||||
|
Category = notification.Category,
|
||||||
|
Severity = notification.Severity,
|
||||||
|
CreatedAt = notification.CreatedAt,
|
||||||
|
Link = $"/Notifications/Show/{notification.Id}",
|
||||||
|
IsRead = notification.IsRead
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> UpdateReadStateAsync(int id, bool isRead)
|
||||||
|
{
|
||||||
|
var notification = _notifications.FirstOrDefault(n => n.Id == id);
|
||||||
|
if (notification is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.IsRead = isRead;
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var notification = _notifications.FirstOrDefault(n => n.Id == id);
|
||||||
|
if (notification is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifications.Remove(notification);
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
@model NotificationListViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Notifikasi";
|
||||||
|
var activeTabKey = Model.ActiveCategory?.ToString() ?? "Semua";
|
||||||
|
var statusKey = NotificationCategory.StatusAkun.ToString();
|
||||||
|
var transaksiKey = NotificationCategory.Transaksi.ToString();
|
||||||
|
var pengajuanKey = NotificationCategory.Pengajuan.ToString();
|
||||||
|
var isSemuaActive = activeTabKey.Equals("Semua", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isStatusActive = activeTabKey.Equals(statusKey, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isTransaksiActive = activeTabKey.Equals(transaksiKey, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isPengajuanActive = activeTabKey.Equals(pengajuanKey, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
<form id="notificationsActionsForm" method="post" class="hidden">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:gap-0">
|
||||||
|
<div class="prose">
|
||||||
|
<span class="text-xl font-semibold text-gray-900 font-['Plus_Jakarta_Sans']">
|
||||||
|
Notifikasi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-6"></div>
|
||||||
|
|
||||||
|
<div class="card bg-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-4">
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">Status Notifikasi</legend>
|
||||||
|
<select id="filterStatusNotifikasi" class="select">
|
||||||
|
<option value="">Semua Status</option>
|
||||||
|
<option value="Sukses">Sukses</option>
|
||||||
|
<option value="Peringatan">Peringatan</option>
|
||||||
|
<option value="Gagal">Gagal</option>
|
||||||
|
<option value="Info">Info</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">Status Baca</legend>
|
||||||
|
<select id="filterStatusBaca" class="select">
|
||||||
|
<option value="">Semua</option>
|
||||||
|
<option value="Dibaca">Sudah Dibaca</option>
|
||||||
|
<option value="Belum Dibaca">Belum Dibaca</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-6"></div>
|
||||||
|
|
||||||
|
<div class="card bg-white">
|
||||||
|
<div class="tabs tabs-border" id="notificationTabs">
|
||||||
|
<input type="radio" name="notifications_tabs" class="tab font-semibold" aria-label="Semua" data-tab-value="Semua" @(isSemuaActive ? "checked=\"checked\"" : string.Empty) />
|
||||||
|
<div class="tab-content border-base-300 bg-base-100 p-0 rounded-none rounded-b-lg" data-tab-panel="Semua">
|
||||||
|
<div id="notificationsTableContainer">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table id="notificationsTable" class="display stripe hover w-full text-sm text-left">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Status Notifikasi</th>
|
||||||
|
<th>Status Baca</th>
|
||||||
|
<th>Ringkasan</th>
|
||||||
|
<th>Diterima Pada</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="radio" name="notifications_tabs" class="tab font-semibold" aria-label="Status Akun" data-tab-value="@statusKey" @(isStatusActive ? "checked=\"checked\"" : string.Empty) />
|
||||||
|
<div class="tab-content border-base-300 bg-base-100 p-0 rounded-none rounded-b-lg" data-tab-panel="@statusKey">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="radio" name="notifications_tabs" class="tab font-semibold" aria-label="Transaksi" data-tab-value="@transaksiKey" @(isTransaksiActive ? "checked=\"checked\"" : string.Empty) />
|
||||||
|
<div class="tab-content border-base-300 bg-base-100 p-0 rounded-none rounded-b-lg" data-tab-panel="@transaksiKey">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="radio" name="notifications_tabs" class="tab font-semibold" aria-label="Pengajuan" data-tab-value="@pengajuanKey" @(isPengajuanActive ? "checked=\"checked\"" : string.Empty) />
|
||||||
|
<div class="tab-content border-base-300 bg-base-100 p-0 rounded-none rounded-b-lg" data-tab-panel="@pengajuanKey">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const tableElement = document.querySelector('#notificationsTable');
|
||||||
|
if (!tableElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTabKey = '@activeTabKey';
|
||||||
|
const resolvedInitialTab = initialTabKey && initialTabKey !== '' ? initialTabKey : 'Semua';
|
||||||
|
let currentCategory = resolvedInitialTab;
|
||||||
|
|
||||||
|
const categoryLabelMap = {
|
||||||
|
StatusAkun: 'Status Akun',
|
||||||
|
Transaksi: 'Transaksi',
|
||||||
|
Pengajuan: 'Pengajuan'
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityLabelMap = {
|
||||||
|
Success: 'Sukses',
|
||||||
|
Warning: 'Peringatan',
|
||||||
|
Error: 'Gagal',
|
||||||
|
Info: 'Info'
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityClassMap = {
|
||||||
|
Success: 'badge-success',
|
||||||
|
Warning: 'badge-warning',
|
||||||
|
Error: 'badge-error',
|
||||||
|
Info: 'badge-neutral'
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value
|
||||||
|
.toString()
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeRegex = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return date.toLocaleString('id-ID', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveTableToPanel = (tabKey) => {
|
||||||
|
const panel = document.querySelector(`[data-tab-panel="${tabKey}"]`) || document.querySelector('[data-tab-panel="Semua"]');
|
||||||
|
if (!panel) return;
|
||||||
|
const container = document.getElementById('notificationsTableContainer');
|
||||||
|
if (container && panel !== container.parentElement) {
|
||||||
|
panel.appendChild(container);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterSeveritySelect = document.getElementById('filterStatusNotifikasi');
|
||||||
|
const filterReadSelect = document.getElementById('filterStatusBaca');
|
||||||
|
|
||||||
|
const table = new DataTable(tableElement, {
|
||||||
|
ajax: {
|
||||||
|
url: '@Url.Action("Table", "Notifications")',
|
||||||
|
dataSrc: 'data',
|
||||||
|
data: function (params) {
|
||||||
|
if (currentCategory && currentCategory !== 'Semua') {
|
||||||
|
params.category = currentCategory;
|
||||||
|
} else {
|
||||||
|
delete params.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: [[5, 'desc']],
|
||||||
|
autoWidth: false,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'category',
|
||||||
|
render: (data) => `<span class="badge badge-sm rounded badge-outline">${categoryLabelMap[data] ?? data}</span>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'severity',
|
||||||
|
render: (data) => `<span class="badge badge-sm badge-soft rounded-full ${severityClassMap[data] ?? 'badge-neutral'}">${severityLabelMap[data] ?? escapeHtml(data)}</span>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'isRead',
|
||||||
|
render: (data) => `<span class="badge badge-sm badge-soft rounded-full ${data ? 'badge-ghost text-gray-500' : 'badge-primary'}" data-role="read-status">${data ? 'Dibaca' : 'Belum Dibaca'}</span>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'summary',
|
||||||
|
render: (data) => `<p class="text-gray-600 line-clamp-2 max-w-xl">${escapeHtml(data)}</p>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'createdAt',
|
||||||
|
render: (data) => formatDate(data)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: null,
|
||||||
|
orderable: false,
|
||||||
|
searchable: false,
|
||||||
|
render: (_, __, row) => `
|
||||||
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
|
<a class="btn btn-circle btn-ghost btn-sm tooltip tooltip-left" href="${row.detailUrl}" data-tip="Detail">
|
||||||
|
<i class="ph ph-eye text-lg"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-circle btn-soft btn-sm tooltip tooltip-left tooltip-info btn-info btn-toggle-read" data-id="${row.id}" data-is-read="${row.isRead}" data-tip="${row.isRead ? 'Tandai sebagai belum dibaca' : 'Tandai sebagai dibaca'}">
|
||||||
|
<i class="ph ${row.isRead ? 'ph-envelope-open' : 'ph-envelope'} text-lg"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-circle btn-soft btn-sm tooltip tooltip-left tooltip-error btn-error btn-delete" data-id="${row.id}" data-tip="Hapus">
|
||||||
|
<i class="ph ph-trash text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsForm = document.getElementById('notificationsActionsForm');
|
||||||
|
const antiForgeryToken = actionsForm?.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||||
|
const updateReadUrl = '@Url.Action("UpdateReadStatus", "Notifications")';
|
||||||
|
const deleteUrl = '@Url.Action("Delete", "Notifications")';
|
||||||
|
|
||||||
|
const postForm = async (url, payload) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'X-CSRF-TOKEN': antiForgeryToken
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Permintaan gagal diproses');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
tableElement.addEventListener('click', async (event) => {
|
||||||
|
const toggleReadButton = event.target.closest('.btn-toggle-read');
|
||||||
|
if (toggleReadButton) {
|
||||||
|
const id = toggleReadButton.dataset.id;
|
||||||
|
const isRead = toggleReadButton.dataset.isRead === 'true';
|
||||||
|
const nextState = !isRead;
|
||||||
|
try {
|
||||||
|
await postForm(updateReadUrl, { id, isRead: nextState });
|
||||||
|
table.ajax.reload(null, false);
|
||||||
|
Swal.fire('Berhasil', nextState ? 'Notifikasi ditandai sebagai dibaca' : 'Notifikasi ditandai belum dibaca', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Swal.fire('Gagal', error.message, 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteButton = event.target.closest('.btn-delete');
|
||||||
|
if (deleteButton) {
|
||||||
|
const id = deleteButton.dataset.id;
|
||||||
|
const confirmation = await Swal.fire({
|
||||||
|
title: 'Hapus notifikasi?',
|
||||||
|
text: 'Notifikasi akan dihapus dari daftar.',
|
||||||
|
icon: 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Hapus',
|
||||||
|
cancelButtonText: 'Batal'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmation.isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postForm(deleteUrl, { id });
|
||||||
|
table.ajax.reload(null, false);
|
||||||
|
Swal.fire('Terhapus', 'Notifikasi berhasil dihapus.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Swal.fire('Gagal', error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabRadios = document.querySelectorAll('#notificationTabs .tab');
|
||||||
|
tabRadios.forEach((radio) => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
if (!radio.checked) return;
|
||||||
|
const tabKey = radio.dataset.tabValue || 'Semua';
|
||||||
|
currentCategory = tabKey;
|
||||||
|
table.ajax.reload();
|
||||||
|
moveTableToPanel(tabKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const applySeverityFilter = () => {
|
||||||
|
const value = filterSeveritySelect?.value ?? '';
|
||||||
|
table.column(1).search(value, false, false).draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyReadFilter = () => {
|
||||||
|
const value = filterReadSelect?.value ?? '';
|
||||||
|
if (!value) {
|
||||||
|
table.column(2).search('', false, false).draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escapedValue = escapeRegex(value);
|
||||||
|
table.column(2).search(`^${escapedValue}$`, true, false).draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
filterSeveritySelect?.addEventListener('change', applySeverityFilter);
|
||||||
|
filterReadSelect?.addEventListener('change', applyReadFilter);
|
||||||
|
|
||||||
|
applySeverityFilter();
|
||||||
|
applyReadFilter();
|
||||||
|
moveTableToPanel(resolvedInitialTab);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
@model NotificationItem
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Detail Notifikasi";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:gap-0">
|
||||||
|
<div class="prose">
|
||||||
|
<span class="text-xl font-semibold text-gray-900 font-['Plus_Jakarta_Sans']">
|
||||||
|
Notifikasi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<a href="@Url.Action("Index", "Notifications")" class="btn btn-sm w-full sm:w-auto rounded-full bg-white border border-gray-300 hover:bg-gray-50" onclick="modal_download.showModal()">
|
||||||
|
<i class="ph ph-arrow-left"></i>
|
||||||
|
Kembali
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-6"></div>
|
||||||
|
|
||||||
|
<div class="card bg-white shadow-sm border border-gray-100">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="badge badge-outline rounded">@GetCategoryLabel(Model.Category)</span>
|
||||||
|
<span class="badge badge-soft rounded-full @GetSeverityClass(Model.Severity)">@GetSeverityLabel(Model.Severity)</span>
|
||||||
|
<span class="badge @(Model.IsRead ? "badge-ghost text-gray-600" : "badge-primary")">
|
||||||
|
@(Model.IsRead ? "Sudah Dibaca" : "Belum Dibaca")
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
@Model.CreatedAt.ToString("dd MMMM yyyy HH:mm")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="prose max-w-none text-gray-800 whitespace-pre-line leading-relaxed">
|
||||||
|
@Model.Summary
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
private static string GetCategoryLabel(NotificationCategory category) => category switch
|
||||||
|
{
|
||||||
|
NotificationCategory.StatusAkun => "Status Akun",
|
||||||
|
NotificationCategory.Transaksi => "Transaksi",
|
||||||
|
NotificationCategory.Pengajuan => "Pengajuan",
|
||||||
|
_ => category.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetSeverityClass(NotificationSeverity severity) => severity switch
|
||||||
|
{
|
||||||
|
NotificationSeverity.Success => "badge-success",
|
||||||
|
NotificationSeverity.Warning => "badge-warning",
|
||||||
|
NotificationSeverity.Error => "badge-error",
|
||||||
|
_ => "badge-neutral"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetSeverityLabel(NotificationSeverity severity) => severity switch
|
||||||
|
{
|
||||||
|
NotificationSeverity.Success => "Sukses",
|
||||||
|
NotificationSeverity.Warning => "Peringatan",
|
||||||
|
NotificationSeverity.Error => "Gagal",
|
||||||
|
NotificationSeverity.Info => "Info",
|
||||||
|
_ => severity.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
<div class="navbar bg-base-100 sticky top-0 z-20 w-full justify-between lg:justify-end">
|
@using System.Linq
|
||||||
|
@using BankSampahApp.Models
|
||||||
|
@inject BankSampahApp.Services.INotificationService NotificationService
|
||||||
|
@{
|
||||||
|
var notifications = (await NotificationService.GetNotificationsAsync())
|
||||||
|
.OrderBy(n => n.IsRead)
|
||||||
|
.ThenByDescending(n => n.CreatedAt)
|
||||||
|
.Take(5)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="navbar bg-white shadow-sm border-b border-gray-100 sticky top-0 z-20 w-full px-4 justify-between lg:justify-end">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<label for="my-drawer-2" class="btn bg-bpsrw-500 drawer-button btn-square lg:hidden">
|
<label for="my-drawer-2" class="btn bg-bpsrw-500 drawer-button btn-square lg:hidden">
|
||||||
<i class="ph ph-list text-lg"></i>
|
<i class="ph ph-list text-lg"></i>
|
||||||
|
|
@ -8,9 +19,53 @@
|
||||||
<button class="btn btn-ghost btn-square rounded-lg border-gray-200">
|
<button class="btn btn-ghost btn-square rounded-lg border-gray-200">
|
||||||
<span class="icon icon-outline">help</span>
|
<span class="icon icon-outline">help</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-square avatar avatar-online rounded-lg border-gray-200">
|
<div class="dropdown dropdown-end">
|
||||||
<span class="icon icon-outline">notifications</span>
|
<div tabindex="0" role="button" class="btn btn-ghost btn-square avatar avatar-online rounded-lg border-gray-200">
|
||||||
</button>
|
<span class="icon icon-outline">notifications</span>
|
||||||
|
</div>
|
||||||
|
<div tabindex="0" class="dropdown-content card card-sm bg-base-100 z-1 w-lg shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">
|
||||||
|
Notifikasi
|
||||||
|
</h2>
|
||||||
|
<ul class="divide-y border rounded border-gray-200">
|
||||||
|
@foreach (var notification in notifications)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<a class="flex gap-3 p-4 transition rounded hover:bg-gray-100 @(notification.IsRead ? string.Empty : "bg-gray-50")"
|
||||||
|
href="@Url.Action("Show", "Notifications", new { id = notification.Id })">
|
||||||
|
<div class="@GetSeverityWrapClasses(notification.Severity)">
|
||||||
|
<span class="icon icon-outline">@GetSeverityIcon(notification.Severity)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="font-semibold text-gray-800">@notification.Title</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs uppercase font-semibold text-gray-500">
|
||||||
|
@GetCategoryLabel(notification.Category)
|
||||||
|
</span>
|
||||||
|
@if (!notification.IsRead)
|
||||||
|
{
|
||||||
|
<span class="inline-flex size-2 rounded-full bg-bpsrw-500"></span>
|
||||||
|
<span class="text-[11px] font-semibold text-bpsrw-500 uppercase">baru</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 line-clamp-2">
|
||||||
|
@notification.Summary
|
||||||
|
</p>
|
||||||
|
<span class="text-[11px] text-gray-400">@notification.CreatedAt.ToString("dd MMM yyyy HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a href="@Url.Action("Index", "Notifications")" class="btn btn-primary btn-soft btn-sm">Lihat Semua</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="divider divider-horizontal m-0"></div>
|
<div class="divider divider-horizontal m-0"></div>
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar border-bpsrw-500 border-2">
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar border-bpsrw-500 border-2">
|
||||||
|
|
@ -26,4 +81,30 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
private static string GetSeverityWrapClasses(NotificationSeverity severity) => severity switch
|
||||||
|
{
|
||||||
|
NotificationSeverity.Success => "bg-green-100 text-green-600 rounded size-10 flex items-center justify-center",
|
||||||
|
NotificationSeverity.Warning => "bg-yellow-100 text-yellow-600 rounded size-10 flex items-center justify-center",
|
||||||
|
NotificationSeverity.Error => "bg-red-100 text-red-600 rounded size-10 flex items-center justify-center",
|
||||||
|
_ => "bg-gray-100 text-gray-600 rounded size-10 flex items-center justify-center"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetSeverityIcon(NotificationSeverity severity) => severity switch
|
||||||
|
{
|
||||||
|
NotificationSeverity.Success => "check",
|
||||||
|
NotificationSeverity.Warning => "warning",
|
||||||
|
NotificationSeverity.Error => "error",
|
||||||
|
_ => "info"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetCategoryLabel(NotificationCategory category) => category switch
|
||||||
|
{
|
||||||
|
NotificationCategory.StatusAkun => "Status Akun",
|
||||||
|
NotificationCategory.Transaksi => "Transaksi",
|
||||||
|
NotificationCategory.Pengajuan => "Pengajuan",
|
||||||
|
_ => category.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue