Restore all zeroed views + add bulk gift certificate creation

The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 20:09:22 -04:00
parent 3eda91f170
commit 4ec55e7290
240 changed files with 73116 additions and 0 deletions
@@ -0,0 +1,569 @@
@model PowderCoating.Application.DTOs.User.UserProfileDto
@{
ViewData["Title"] = "My Profile";
ViewData["PageIcon"] = "bi-person-circle";
ViewData["PageHelpTitle"] = "My Profile";
ViewData["PageHelpContent"] = "Profile: edit your name and phone number. Security: change your login email or password. Photo: upload a profile picture used as your avatar throughout the app. Appearance: choose a theme, sidebar color, date format, and timezone. Changes on each tab save independently.";
}
<div class="container-fluid mt-4">
<div class="mb-4">
@Html.AntiForgeryToken()
<!-- Toast notification container -->
<div id="toastContainer" class="position-fixed top-0 end-0 p-3" style="z-index: 9999;"></div>
<div class="row">
<div class="col-lg-3 mb-4">
<!-- Profile Summary Card -->
<div class="card text-center">
<div class="card-body py-4">
<div id="avatarContainer" class="mb-3">
@if (Model.HasProfilePicture)
{
<img id="profilePhotoImg" src="@Url.Action("Photo", "Profile")" alt="Profile Photo"
class="rounded-circle" style="width:100px;height:100px;object-fit:cover;" />
}
else
{
<div id="profilePhotoPlaceholder" class="rounded-circle bg-primary d-flex align-items-center justify-content-center mx-auto"
style="width:100px;height:100px;font-size:2.5rem;color:white;font-weight:600;">
@Model.FirstName.Substring(0, 1).ToUpper()
</div>
}
</div>
<h5 class="mb-0">@Model.FullName</h5>
<p class="text-muted small">@Model.Email</p>
@if (!string.IsNullOrEmpty(Model.Position))
{
<span class="badge bg-secondary">@Model.Position</span>
}
@if (!string.IsNullOrEmpty(Model.CompanyRole))
{
<span class="badge bg-primary ms-1">@Model.CompanyRole</span>
}
</div>
</div>
</div>
<div class="col-lg-9">
<!-- Tab navigation -->
<ul class="nav nav-tabs mb-4" id="profileTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="profile-tab" data-bs-toggle="tab" data-bs-target="#profile-pane"
type="button" role="tab">
<i class="bi bi-person me-1"></i>Profile
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="security-tab" data-bs-toggle="tab" data-bs-target="#security-pane"
type="button" role="tab">
<i class="bi bi-shield-lock me-1"></i>Security
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="photo-tab" data-bs-toggle="tab" data-bs-target="#photo-pane"
type="button" role="tab">
<i class="bi bi-camera me-1"></i>Photo
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="appearance-tab" data-bs-toggle="tab" data-bs-target="#appearance-pane"
type="button" role="tab">
<i class="bi bi-palette me-1"></i>Appearance
</button>
</li>
</ul>
<div class="tab-content" id="profileTabsContent">
<!-- Profile Tab -->
<div class="tab-pane fade show active" id="profile-pane" role="tabpanel">
<div class="card">
<div class="card-header d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-person me-2"></i>Personal Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Personal Information"
data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference â€" change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<form id="profileForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">First Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="firstName" name="FirstName"
value="@Model.FirstName" required />
</div>
<div class="col-md-6">
<label class="form-label">Last Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="lastName" name="LastName"
value="@Model.LastName" required />
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" id="phone" name="Phone"
value="@Model.Phone" />
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" value="@Model.Email" readonly disabled />
<div class="form-text">To change your email, use the <a href="#" onclick="document.getElementById('security-tab').click();return false;">Security tab</a>.</div>
</div>
</div>
<hr />
<h6 class="text-muted">Employment Information (read-only)</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Department</label>
<input type="text" class="form-control" value="@Model.Department" readonly disabled />
</div>
<div class="col-md-4">
<label class="form-label">Position</label>
<input type="text" class="form-control" value="@Model.Position" readonly disabled />
</div>
<div class="col-md-4">
<label class="form-label">Employee #</label>
<input type="text" class="form-control" value="@Model.EmployeeNumber" readonly disabled />
</div>
<div class="col-md-4">
<label class="form-label">Role</label>
<input type="text" class="form-control" value="@Model.CompanyRole" readonly disabled />
</div>
<div class="col-md-4">
<label class="form-label">Hire Date</label>
<input type="text" class="form-control" value="@Model.HireDate.ToString("MM/dd/yyyy")" readonly disabled />
</div>
<div class="col-md-4">
<label class="form-label">Last Login</label>
<input type="text" class="form-control" value="@(Model.LastLoginDate?.ToString("MM/dd/yyyy HH:mm") ?? "-")" readonly disabled />
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Profile
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Security Tab -->
<div class="tab-pane fade" id="security-pane" role="tabpanel">
<div class="card mb-4">
<div class="card-header d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-envelope me-2"></i>Change Email</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Change Email"
data-bs-content="Your email address is also your login username. Enter a new address and confirm your current password to update it. You will need to use the new email address the next time you log in.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<p class="text-muted small">Your email address is also your login. Confirm your current password to update it.</p>
<form id="emailForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">New Email Address <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="newEmail" name="NewEmail" required />
</div>
<div class="col-md-6">
<label class="form-label">Current Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" id="emailCurrentPassword" name="CurrentPassword" required />
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-envelope me-1"></i>Update Email
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Change Password</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Change Password"
data-bs-content="Enter your current password to verify your identity, then choose a new password. Requirements: at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit. Your new password must be confirmed before saving.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<form id="passwordForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Current Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" id="currentPassword" name="CurrentPassword" required />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">New Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" id="newPassword" name="NewPassword" required />
</div>
<div class="col-md-6">
<label class="form-label">Confirm New Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" id="confirmPassword" name="ConfirmPassword" required />
</div>
</div>
<div class="alert alert-info alert-permanent mt-3 mb-0">
<i class="bi bi-info-circle me-2"></i>
<strong>Password requirements:</strong> At least 8 characters with uppercase, lowercase, and a digit.
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-key me-1"></i>Change Password
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Photo Tab -->
<div class="tab-pane fade" id="photo-pane" role="tabpanel">
<div class="card">
<div class="card-header d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-camera me-2"></i>Profile Photo</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Profile Photo"
data-bs-content="Your profile photo appears as your avatar in the sidebar and on your profile summary card. Accepted formats: JPG, PNG, GIF, or WebP, up to 10 MB. If no photo is uploaded, your initials are shown instead. Use Remove Photo to revert to the initials avatar.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3 text-center mb-3">
<div id="photoPreviewContainer">
@if (Model.HasProfilePicture)
{
<img id="photoPreview" src="@Url.Action("Photo", "Profile")" alt="Profile Photo"
class="rounded-circle" style="width:120px;height:120px;object-fit:cover;" />
}
else
{
<div id="photoPreviewPlaceholder" class="rounded-circle bg-secondary d-flex align-items-center justify-content-center mx-auto"
style="width:120px;height:120px;font-size:3rem;color:white;">
<i class="bi bi-person-circle"></i>
</div>
}
</div>
</div>
<div class="col-md-9">
<form id="photoForm" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<div class="mb-3">
<label class="form-label">Upload New Photo</label>
<input type="file" class="form-control" id="photoInput" name="photo"
accept="image/jpeg,image/png,image/gif,image/webp" />
<div class="form-text">Max 10 MB. JPG, PNG, GIF, or WebP.</div>
</div>
<button type="submit" class="btn btn-primary me-2">
<i class="bi bi-upload me-1"></i>Upload Photo
</button>
@if (Model.HasProfilePicture)
{
<button type="button" class="btn btn-outline-danger" id="deletePhotoBtn">
<i class="bi bi-trash me-1"></i>Remove Photo
</button>
}
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="tab-pane fade" id="appearance-pane" role="tabpanel">
<div class="card">
<div class="card-header d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-palette me-2"></i>Appearance Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Appearance Settings"
data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background â€" click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<form id="appearanceForm">
<!-- Theme -->
<div class="mb-4">
<label class="form-label fw-semibold">Theme</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="theme" id="themeLightRad" value="light"
@(Model.Theme == "light" ? "checked" : "") />
<label class="form-check-label" for="themeLightRad">
<i class="bi bi-sun me-1"></i>Light
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="theme" id="themeDarkRad" value="dark"
@(Model.Theme == "dark" ? "checked" : "") />
<label class="form-check-label" for="themeDarkRad">
<i class="bi bi-moon me-1"></i>Dark
</label>
</div>
</div>
</div>
<!-- Sidebar Color -->
<div class="mb-4">
<label class="form-label fw-semibold">Sidebar Color</label>
<div class="d-flex flex-wrap gap-3 align-items-center" id="sidebarColorPicker">
@{
var swatches = new[] {
("ocean", "#1A1A1C", "Graphite"),
("navy", "#0D1B2E", "Navy"),
("forest", "#0D2116", "Forest"),
("purple", "#1A0D2E", "Plum"),
("crimson", "#1C0A0E", "Crimson"),
("teal", "#0A1F1E", "Teal"),
};
foreach (var (val, hex, label) in swatches)
{
var isActive = Model.SidebarColor == val
|| (val == "ocean" && string.IsNullOrEmpty(Model.SidebarColor));
<button type="button"
class="sidebar-color-btn @(isActive ? "active" : "")"
data-color="@val"
title="@label"
style="width:32px;height:32px;border-radius:50%;background:@hex;border:3px solid @(isActive ? "var(--pcl-ember)" : "transparent");outline:2px solid rgba(255,255,255,0.15);cursor:pointer;transition:border-color .15s,transform .15s;"
onmouseover="this.style.transform='scale(1.15)'"
onmouseout="this.style.transform='scale(1)'">
</button>
}
}
</div>
<input type="hidden" id="sidebarColorInput" name="SidebarColor" value="@Model.SidebarColor" />
</div>
<!-- Date Format -->
<div class="mb-4">
<label class="form-label fw-semibold">Date Format</label>
<select class="form-select" id="dateFormatSelect" name="DateFormat" style="max-width:250px;">
<option value="MM/dd/yyyy" selected="@(Model.DateFormat == "MM/dd/yyyy" ? "selected" : null)">MM/DD/YYYY</option>
<option value="dd/MM/yyyy" selected="@(Model.DateFormat == "dd/MM/yyyy" ? "selected" : null)">DD/MM/YYYY</option>
<option value="yyyy-MM-dd" selected="@(Model.DateFormat == "yyyy-MM-dd" ? "selected" : null)">YYYY-MM-DD</option>
<option value="MMMM d, yyyy" selected="@(Model.DateFormat == "MMMM d, yyyy" ? "selected" : null)">Month D, YYYY</option>
</select>
</div>
<!-- Timezone -->
<div class="mb-4">
<label class="form-label fw-semibold">Timezone</label>
<select class="form-select" id="timezoneInput" name="TimeZone" style="max-width:350px;">
<optgroup label="United States">
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
</optgroup>
<optgroup label="Canada">
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
</optgroup>
<optgroup label="Asia / Pacific">
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
</optgroup>
<optgroup label="UTC">
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
</optgroup>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Appearance
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
const antiForgeryToken = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="csrf-token"]')?.content ?? '';
function showToast(message, success) {
const id = 'toast_' + Date.now();
const html = `<div id="${id}" class="toast align-items-center text-white ${success ? 'bg-success' : 'bg-danger'} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`;
document.getElementById('toastContainer').insertAdjacentHTML('beforeend', html);
const el = document.getElementById(id);
new bootstrap.Toast(el, { delay: 4000 }).show();
}
async function postJson(url, data) {
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': antiForgeryToken
},
body: JSON.stringify(data)
});
return resp.json();
}
// Profile form
document.getElementById('profileForm').addEventListener('submit', async function (e) {
e.preventDefault();
const phoneValue = document.getElementById('phone').value.trim();
const data = {
FirstName: document.getElementById('firstName').value.trim(),
LastName: document.getElementById('lastName').value.trim(),
Phone: phoneValue || null // Send null if empty to avoid validation on empty string
};
const result = await postJson('@Url.Action("UpdateProfile", "Profile")', data);
showToast(result.message, result.success);
if (result.success) {
// Reload to update the name in the sidebar and summary card
setTimeout(() => location.reload(), 1000);
}
});
// Email form
document.getElementById('emailForm').addEventListener('submit', async function (e) {
e.preventDefault();
const data = {
NewEmail: document.getElementById('newEmail').value.trim(),
CurrentPassword: document.getElementById('emailCurrentPassword').value
};
const result = await postJson('@Url.Action("UpdateEmail", "Profile")', data);
showToast(result.message, result.success);
if (result.success) {
this.reset();
// Reload so the displayed email updates
setTimeout(() => location.reload(), 1200);
}
});
// Password form
document.getElementById('passwordForm').addEventListener('submit', async function (e) {
e.preventDefault();
const np = document.getElementById('newPassword').value;
const cp = document.getElementById('confirmPassword').value;
if (np !== cp) { showToast('Passwords do not match.', false); return; }
const data = {
CurrentPassword: document.getElementById('currentPassword').value,
NewPassword: np,
ConfirmPassword: cp
};
const result = await postJson('@Url.Action("ChangePassword", "Profile")', data);
showToast(result.message, result.success);
if (result.success) this.reset();
});
// Photo upload form
document.getElementById('photoForm').addEventListener('submit', async function (e) {
e.preventDefault();
const file = document.getElementById('photoInput').files[0];
if (!file) { showToast('Please select a photo.', false); return; }
const fd = new FormData();
fd.append('photo', file);
fd.append('__RequestVerificationToken', antiForgeryToken);
const resp = await fetch('@Url.Action("UploadPhoto", "Profile")', { method: 'POST', body: fd });
const result = await resp.json();
showToast(result.message, result.success);
if (result.success) setTimeout(() => location.reload(), 1000);
});
// Delete photo
const deleteBtn = document.getElementById('deletePhotoBtn');
if (deleteBtn) {
deleteBtn.addEventListener('click', async function () {
if (!confirm('Remove your profile photo?')) return;
const fd = new FormData();
fd.append('__RequestVerificationToken', antiForgeryToken);
const resp = await fetch('@Url.Action("DeletePhoto", "Profile")', { method: 'POST', body: fd });
const result = await resp.json();
showToast(result.message, result.success);
if (result.success) setTimeout(() => location.reload(), 1000);
});
}
// Sidebar color picker
document.querySelectorAll('.sidebar-color-btn').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.sidebar-color-btn').forEach(b => {
b.classList.remove('active');
b.style.borderColor = 'transparent';
});
this.classList.add('active');
this.style.borderColor = 'var(--pcl-ember)';
const color = this.dataset.color;
document.getElementById('sidebarColorInput').value = color;
document.documentElement.setAttribute('data-sidebar', color);
});
});
// Theme radio â€" map light/dark → paper/ink surface system
document.querySelectorAll('input[name="theme"]').forEach(radio => {
radio.addEventListener('change', function () {
var surface = this.value === 'dark' ? 'ink' : 'paper';
if (window.pclApplyTheme) window.pclApplyTheme(surface);
});
});
// Appearance form
document.getElementById('appearanceForm').addEventListener('submit', async function (e) {
e.preventDefault();
const data = {
Theme: document.querySelector('input[name="theme"]:checked')?.value ?? 'light',
SidebarColor: document.getElementById('sidebarColorInput').value,
DateFormat: document.getElementById('dateFormatSelect').value,
TimeZone: document.getElementById('timezoneInput').value
};
const result = await postJson('@Url.Action("UpdateAppearance", "Profile")', data);
showToast(result.message, result.success);
});
// Activate tab from URL fragment
const hash = window.location.hash;
if (hash) {
const tabEl = document.querySelector(`button[data-bs-target="${hash}-pane"]`);
if (tabEl) new bootstrap.Tab(tabEl).show();
}
</script>
}