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:
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user