Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,415 @@
@using PowderCoating.Web.Controllers
@model List<EntityPurgeStat>
@{
ViewData["Title"] = "Data Purge & Cleanup";
var groups = Model.GroupBy(s => s.Group).ToList();
var totalSoftDeleted = Model.Sum(s => s.Total);
}
@section Styles {
<style>
[data-bs-theme="dark"] .table-light th,
[data-bs-theme="dark"] .table-light td {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .card-header {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] #previewResults .bg-light {
background-color: var(--bs-secondary-bg) !important;
color: var(--bs-body-color);
}
[data-bs-theme="dark"] #confirmSummary {
background-color: rgba(220,53,69,.15) !important;
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .border-top {
border-color: var(--bs-border-color) !important;
}
</style>
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Data Purge &amp; Cleanup</h4>
<small class="text-muted">Permanently remove soft-deleted records from the database</small>
</div>
<span class="badge bg-danger fs-6">@totalSoftDeleted.ToString("N0") soft-deleted records</span>
</div>
@* Alert messages *@
@if (TempData["PurgeSuccess"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["PurgeSuccess"]
@if (TempData["PurgeDetail"] != null)
{
<pre class="mb-0 mt-2 small">@TempData["PurgeDetail"]</pre>
}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@* Warning banner *@
<div class="alert alert-warning d-flex gap-3 align-items-start mb-3">
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
<div>
<strong>Destructive operation — this cannot be undone.</strong>
Purging permanently deletes records from the database. Soft-deleted records are hidden from users but still occupy database space. Use this tool periodically to reclaim space and keep the database clean.
Job photo blobs in Azure Storage are also deleted when purging job photo records.
</div>
</div>
<div class="row g-3">
@* Left: Entity stats *@
<div class="col-lg-8">
@foreach (var group in groups)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-transparent border-bottom py-2">
<h6 class="mb-0 text-muted text-uppercase fw-semibold" style="font-size:0.75rem;letter-spacing:.05em">
@group.Key
</h6>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 small">
<thead class="table-light">
<tr>
<th style="width:36px"></th>
<th>Entity</th>
<th class="text-end" style="width:90px">Total</th>
<th class="text-end" style="width:100px">030d</th>
<th class="text-end" style="width:100px">3090d</th>
<th class="text-end" style="width:100px">&gt;90d</th>
<th style="width:130px">Oldest</th>
<th style="width:42px">
<input type="checkbox" class="form-check-input group-select-all"
title="Select all in group"
data-group="@group.Key" />
</th>
</tr>
</thead>
<tbody>
@foreach (var s in group)
{
<tr class="@(s.Total == 0 ? "opacity-50" : "")">
<td class="text-center">
<i class="bi @s.Icon text-secondary"></i>
</td>
<td>@s.Label</td>
<td class="text-end">
@if (s.Total > 0)
{
<span class="badge bg-secondary">@s.Total</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-end">
@if (s.DeletedLast30Days > 0)
{
<span class="badge bg-success-subtle text-success">@s.DeletedLast30Days</span>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-end">
@if (s.Deleted30To90Days > 0)
{
<span class="badge bg-warning-subtle text-warning">@s.Deleted30To90Days</span>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-end">
@if (s.DeletedOlderThan90Days > 0)
{
<span class="badge bg-danger-subtle text-danger">@s.DeletedOlderThan90Days</span>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-muted">
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input entity-select"
name="entities"
value="@s.EntityName"
data-group="@group.Key"
@(s.Total == 0 ? "disabled" : "") />
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view for this group — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="px-3 pt-2 pb-1">
<span class="text-muted text-uppercase fw-semibold" style="font-size:0.7rem;letter-spacing:.05em">@group.Key</span>
</div>
<div class="mobile-card-list">
@foreach (var s in group)
{
<div class="mobile-data-card @(s.Total == 0 ? "opacity-50" : "")">
<div class="mobile-card-header">
<div class="mobile-card-icon @(s.Total > 0 ? "bg-danger" : "bg-secondary")">
<i class="bi @s.Icon"></i>
</div>
<div class="mobile-card-title">
<h6>@s.Label</h6>
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Total deleted</span>
<span class="mobile-card-value">
@if (s.Total > 0)
{
<span class="badge bg-secondary">@s.Total</span>
}
else { <span class="text-muted">—</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">030d / 3090d / &gt;90d</span>
<span class="mobile-card-value">@s.DeletedLast30Days / @s.Deleted30To90Days / @s.DeletedOlderThan90Days</span>
</div>
</div>
<div class="mobile-card-footer">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input entity-select"
value="@s.EntityName"
data-group="@group.Key"
id="mobile-cb-@s.EntityName"
@(s.Total == 0 ? "disabled" : "") />
<label class="form-check-label small" for="mobile-cb-@s.EntityName">
Select for purge
</label>
</div>
</div>
</div>
}
</div>
</div>
</div>
}
</div>
@* Right: Purge controls *@
<div class="col-lg-4">
<div class="card border-0 shadow-sm sticky-top" style="top:1rem">
<div class="card-header bg-danger text-white py-2">
<h6 class="mb-0"><i class="bi bi-trash3 me-2"></i>Purge Options</h6>
</div>
<div class="card-body">
@* Threshold *@
<div class="mb-3">
<label class="form-label fw-semibold">Delete records older than</label>
<select id="olderThanDays" class="form-select">
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90" selected>90 days</option>
<option value="180">180 days (6 months)</option>
<option value="365">365 days (1 year)</option>
</select>
<div class="form-text">Only soft-deleted records older than this threshold will be affected.</div>
</div>
@* Select All *@
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="selectAll">
<label class="form-check-label fw-semibold" for="selectAll">Select all entity types</label>
</div>
@* Preview *@
<button type="button" class="btn btn-outline-secondary w-100 mb-2" id="previewBtn">
<i class="bi bi-eye me-2"></i>Preview
</button>
@* Preview results *@
<div id="previewResults" class="d-none mb-3">
<div class="border rounded p-2 bg-light small">
<strong class="d-block mb-1">Would delete:</strong>
<div id="previewList"></div>
<hr class="my-1">
<strong id="previewTotal" class="text-danger"></strong>
</div>
</div>
@* Execute form *@
<form method="post" asp-action="Execute" id="purgeForm">
@Html.AntiForgeryToken()
<input type="hidden" name="olderThanDays" id="hiddenDays" value="90" />
<div id="hiddenEntities"></div>
<button type="button" class="btn btn-danger w-100" id="purgeBtn" disabled>
<i class="bi bi-trash3-fill me-2"></i>Purge Selected
</button>
</form>
<div class="form-text text-danger mt-2">
<i class="bi bi-lock me-1"></i>Action is logged to Audit Log.
</div>
</div>
</div>
</div>
</div>
</div>
@* Confirm modal *@
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Confirm Purge</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to <strong class="text-danger">permanently delete</strong> records from the database. This <strong>cannot be undone</strong>.</p>
<div id="confirmSummary" class="border rounded p-2 bg-danger bg-opacity-10 small mb-2"></div>
<p class="mb-0 text-muted">Are you sure you want to continue?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmPurgeBtn">
<i class="bi bi-trash3-fill me-2"></i>Yes, Purge Permanently
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
(function () {
const selectAll = document.getElementById('selectAll');
const previewBtn = document.getElementById('previewBtn');
const purgeBtn = document.getElementById('purgeBtn');
const previewRes = document.getElementById('previewResults');
const previewList = document.getElementById('previewList');
const previewTotal = document.getElementById('previewTotal');
const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
const confirmSummary= document.getElementById('confirmSummary');
// ── Select all ──────────────────────────────────────────────────────────
selectAll.addEventListener('change', () => {
document.querySelectorAll('.entity-select:not(:disabled)').forEach(cb => {
cb.checked = selectAll.checked;
});
updatePurgeBtn();
});
// ── Group select all ────────────────────────────────────────────────────
document.querySelectorAll('.group-select-all').forEach(ga => {
ga.addEventListener('change', () => {
document.querySelectorAll(`.entity-select[data-group="${ga.dataset.group}"]:not(:disabled)`)
.forEach(cb => { cb.checked = ga.checked; });
updatePurgeBtn();
});
});
document.querySelectorAll('.entity-select').forEach(cb => {
cb.addEventListener('change', updatePurgeBtn);
});
function getSelectedEntities() {
// Deduplicate since mobile cards duplicate the checkboxes
const seen = new Set();
return [...document.querySelectorAll('.entity-select:checked')]
.filter(cb => { if (seen.has(cb.value)) return false; seen.add(cb.value); return true; })
.map(cb => cb.value);
}
function updatePurgeBtn() {
const any = getSelectedEntities().length > 0;
purgeBtn.disabled = !any;
previewRes.classList.add('d-none');
}
// ── Preview ─────────────────────────────────────────────────────────────
previewBtn.addEventListener('click', async () => {
const entities = getSelectedEntities();
if (!entities.length) {
alert('Select at least one entity type to preview.');
return;
}
previewBtn.disabled = true;
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
const days = document.getElementById('olderThanDays').value;
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
try {
const resp = await fetch('@Url.Action("Preview")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({ olderThanDays: parseInt(days), entities })
});
const data = await resp.json();
let html = '';
let total = 0;
data.forEach(r => {
total += r.count;
const cls = r.count > 0 ? 'text-danger' : 'text-muted';
const oldest = r.oldest ? ` <span class="text-muted">(oldest: ${r.oldest})</span>` : '';
html += `<div class="${cls}">${r.entity}: <strong>${r.count}</strong>${oldest}</div>`;
});
previewList.innerHTML = html || '<div class="text-muted">Nothing to delete.</div>';
previewTotal.textContent = `Total: ${total.toLocaleString()} records`;
previewRes.classList.remove('d-none');
} catch (e) {
alert('Preview failed: ' + e.message);
} finally {
previewBtn.disabled = false;
previewBtn.innerHTML = '<i class="bi bi-eye me-2"></i>Preview';
}
});
// ── Purge button → modal ────────────────────────────────────────────────
purgeBtn.addEventListener('click', () => {
const entities = getSelectedEntities();
const days = document.getElementById('olderThanDays').value;
let summary = `<strong>Threshold:</strong> older than ${days} days<br>`;
summary += `<strong>Entity types:</strong> ${entities.join(', ')}`;
confirmSummary.innerHTML = summary;
confirmModal.show();
});
// ── Confirm → submit form ───────────────────────────────────────────────
document.getElementById('confirmPurgeBtn').addEventListener('click', () => {
const entities = getSelectedEntities();
const days = document.getElementById('olderThanDays').value;
document.getElementById('hiddenDays').value = days;
const container = document.getElementById('hiddenEntities');
container.innerHTML = '';
entities.forEach(e => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'entities';
inp.value = e;
container.appendChild(inp);
});
confirmModal.hide();
document.getElementById('purgeForm').submit();
});
})();
</script>
}