a0bdd2b5b4
Replace all corruption variants with HTML entities across 226 view files: - 3-char UTF-8-as-Win1252 sequences (ae-corruption) - Standalone smart/curly quotes that break C# Razor expressions - Partially re-corrupted variants where the 3rd byte was normalised to ASCII tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the script itself never contains a literal non-ASCII character; supports -DryRun .githooks/pre-commit: blocks commits containing the ae-corruption byte signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the hook is repo-committed and active for all future work on this machine. Build clean; 225 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
216 lines
10 KiB
Plaintext
216 lines
10 KiB
Plaintext
@{
|
||
ViewData["Title"] = "Notification History";
|
||
ViewData["PageIcon"] = "bi-bell";
|
||
var items = Model as IEnumerable<dynamic> ?? Enumerable.Empty<dynamic>();
|
||
var pageNumber = (int)(ViewBag.PageNumber ?? 1);
|
||
var pageSize = (int)(ViewBag.PageSize ?? 25);
|
||
var totalCount = (int)(ViewBag.TotalCount ?? 0);
|
||
var totalPages = (int)(ViewBag.TotalPages ?? 1);
|
||
}
|
||
|
||
@section Styles {
|
||
<style>
|
||
tr.notif-unread { background: rgba(99, 102, 241, 0.08) !important; }
|
||
tr.notif-unread:hover { background: rgba(99, 102, 241, 0.14) !important; }
|
||
</style>
|
||
}
|
||
|
||
<div class="mb-4"></div>
|
||
|
||
@if (!items.Any())
|
||
{
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-body text-center py-5 text-muted">
|
||
<i class="bi bi-bell-slash fs-1 d-block mb-3 opacity-25"></i>
|
||
<p class="mb-0">No notifications yet.</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="mobile-card-view">
|
||
<div class="mobile-card-list">
|
||
@foreach (var n in items)
|
||
{
|
||
bool mIsRead = (bool)n.IsRead;
|
||
string mTitle = (string)n.Title;
|
||
string mMessage = (string)n.Message;
|
||
string? mLink = (string?)n.Link;
|
||
string mType = (string)n.NotificationType;
|
||
DateTime mCreatedAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
|
||
<div class="mobile-data-card notif-history-row @(!mIsRead ? "notif-unread" : "")"
|
||
data-id="@n.Id"
|
||
data-title="@mTitle"
|
||
data-message="@mMessage"
|
||
data-link="@(mLink ?? "")"
|
||
data-type="@mType"
|
||
data-is-read="@(mIsRead ? "1" : "0")"
|
||
data-created-at="@mCreatedAt.ToString("MMM d, yyyy h:mm tt")">
|
||
<div class="mobile-card-header" style="@(!mIsRead ? "background:rgba(99,102,241,0.08);" : "")">
|
||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||
<i class="bi bi-bell"></i>
|
||
</div>
|
||
<div class="mobile-card-title">
|
||
<h6 class="@(!mIsRead ? "fw-semibold" : "text-muted")">
|
||
@if (!mIsRead)
|
||
{
|
||
<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;"></span>
|
||
}
|
||
@mTitle
|
||
</h6>
|
||
<small>@mCreatedAt.ToString("MMM d, yyyy h:mm tt")</small>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-card-body">
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Type</span>
|
||
<span class="mobile-card-value"><span class="badge bg-secondary bg-opacity-25 text-body small">@mType</span></span>
|
||
</div>
|
||
<div class="mobile-card-row" style="align-items:flex-start;">
|
||
<span class="mobile-card-label">Message</span>
|
||
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@mMessage</span>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mLink))
|
||
{
|
||
<div class="mobile-card-footer">
|
||
<a href="@mLink" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||
<i class="bi bi-arrow-right me-1"></i>Open
|
||
</a>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="table-responsive">
|
||
<table class="table table-hover align-middle mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th style="width:20px;"></th>
|
||
<th>Title</th>
|
||
<th>Message</th>
|
||
<th>Type</th>
|
||
<th>Received</th>
|
||
<th>Read</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var n in items)
|
||
{
|
||
bool isRead = (bool)n.IsRead;
|
||
string title = (string)n.Title;
|
||
string message = (string)n.Message;
|
||
string? link = (string?)n.Link;
|
||
string notifType = (string)n.NotificationType;
|
||
DateTime createdAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
|
||
DateTime? readAt = n.ReadAt == null ? (DateTime?)null : ((DateTime)n.ReadAt).Tz(ViewBag.CompanyTimeZone as string);
|
||
<tr class="@(!isRead ? "notif-unread" : "") notif-history-row" style="cursor:pointer;"
|
||
data-id="@n.Id"
|
||
data-title="@title"
|
||
data-message="@message"
|
||
data-link="@(link ?? "")"
|
||
data-type="@notifType"
|
||
data-is-read="@(isRead ? "1" : "0")"
|
||
data-created-at="@createdAt.ToString("MMM d, yyyy h:mm tt")">
|
||
<td>
|
||
@if (!isRead)
|
||
{
|
||
<span title="Unread" style="display:inline-block;width:10px;height:10px;background:#6366f1;border-radius:50%;"></span>
|
||
}
|
||
</td>
|
||
<td class="@(!isRead ? "fw-semibold" : "text-muted")">
|
||
@if (!string.IsNullOrEmpty(link))
|
||
{
|
||
<a href="@link" class="text-decoration-none">@title</a>
|
||
}
|
||
else
|
||
{
|
||
@title
|
||
}
|
||
</td>
|
||
<td class="text-muted small" style="max-width:320px;">@message</td>
|
||
<td><span class="badge bg-secondary bg-opacity-25 text-body small">@notifType</span></td>
|
||
<td class="text-nowrap small text-muted">@createdAt.ToString("MMM d, yyyy h:mm tt")</td>
|
||
<td class="text-nowrap small text-muted">
|
||
@if (readAt.HasValue)
|
||
{
|
||
@readAt.Value.ToString("MMM d, h:mm tt")
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-primary bg-opacity-10 text-primary">Unread</span>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
@if (totalPages > 1)
|
||
{
|
||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||
<small class="text-muted">
|
||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount
|
||
</small>
|
||
<nav>
|
||
<ul class="pagination pagination-sm mb-0">
|
||
<li class="page-item @(pageNumber <= 1 ? "disabled" : "")">
|
||
<a class="page-link" href="?pageNumber=@(pageNumber - 1)&pageSize=@pageSize">‹</a>
|
||
</li>
|
||
@for (var p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
|
||
{
|
||
<li class="page-item @(p == pageNumber ? "active" : "")">
|
||
<a class="page-link" href="?pageNumber=@p&pageSize=@pageSize">@p</a>
|
||
</li>
|
||
}
|
||
<li class="page-item @(pageNumber >= totalPages ? "disabled" : "")">
|
||
<a class="page-link" href="?pageNumber=@(pageNumber + 1)&pageSize=@pageSize">›</a>
|
||
</li>
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@section Scripts {
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.querySelectorAll('.notif-history-row').forEach(row => {
|
||
row.addEventListener('click', () => {
|
||
const n = {
|
||
id: parseInt(row.dataset.id),
|
||
title: row.dataset.title,
|
||
message: row.dataset.message,
|
||
link: row.dataset.link || null,
|
||
notificationType: row.dataset.type,
|
||
isRead: row.dataset.isRead === '1',
|
||
createdAt: row.dataset.createdAt
|
||
};
|
||
|
||
// Open the shared detail modal
|
||
notifBell.openDetail(n, null);
|
||
|
||
// Update this row's visual state if it was unread
|
||
if (!n.isRead) {
|
||
row.classList.remove('notif-unread');
|
||
row.dataset.isRead = '1';
|
||
const dot = row.querySelector('[title="Unread"]');
|
||
if (dot) dot.remove();
|
||
const badge = row.querySelector('.badge.text-primary');
|
||
if (badge) badge.remove();
|
||
const titleCell = row.querySelector('td:nth-child(2)');
|
||
if (titleCell) {
|
||
titleCell.classList.remove('fw-semibold');
|
||
titleCell.classList.add('text-muted');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
</script>
|
||
}
|