feat: add delete button to exports list and detail pages

Add DELETE /api/exports/{id} endpoint with cascade delete, trash icon
buttons on both the exports list and export detail pages, and disable
browser caching for static files to prevent stale JS issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 09:34:59 -05:00
parent e072919a59
commit 5de40ebafd
8 changed files with 174 additions and 98 deletions

View File

@@ -246,6 +246,22 @@ namespace FabWorks.Api.Controllers
};
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var record = await _db.ExportRecords
.Include(r => r.BomItems).ThenInclude(b => b.CutTemplate)
.Include(r => r.BomItems).ThenInclude(b => b.FormProgram)
.FirstOrDefaultAsync(r => r.Id == id);
if (record == null) return NotFound();
_db.ExportRecords.Remove(record);
await _db.SaveChangesAsync();
return NoContent();
}
[HttpGet("{id}/download-dxfs")]
public async Task<IActionResult> DownloadAllDxfs(int id)
{

View File

@@ -17,6 +17,10 @@ builder.Services.AddScoped<IFileStorageService, FileStorageService>();
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store")
});
app.MapControllers();
app.Run();

View File

@@ -384,6 +384,18 @@ tbody tr:last-child td { border-bottom: none; }
color: var(--amber);
}
.btn-red {
background: rgba(217, 45, 32, 0.08);
color: var(--red);
border-color: rgba(217, 45, 32, 0.25);
}
.btn-red:hover {
background: rgba(217, 45, 32, 0.15);
border-color: rgba(217, 45, 32, 0.4);
color: var(--red);
}
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* ─── Search ─── */

View File

@@ -46,11 +46,11 @@
<div class="page-content" id="page-content"></div>
</div>
<script src="js/icons.js"></script>
<script src="js/helpers.js"></script>
<script src="js/components.js"></script>
<script src="js/pages.js"></script>
<script src="js/router.js"></script>
<script src="js/icons.js?v=2"></script>
<script src="js/helpers.js?v=2"></script>
<script src="js/components.js?v=2"></script>
<script src="js/pages.js?v=2"></script>
<script src="js/router.js?v=2"></script>
<script>router.init();</script>
</body>
</html>

View File

@@ -32,5 +32,19 @@ const api = {
const r = await fetch(url);
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
},
async del(url) {
const r = await fetch(url, { method: 'DELETE' });
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
}
};
async function deleteExport(id) {
if (!confirm('Delete this export record? This cannot be undone.')) return;
try {
await api.del(`/api/exports/${id}`);
router.dispatch();
} catch (err) {
alert('Failed to delete: ' + err.message);
}
}

View File

@@ -9,6 +9,7 @@ const icons = {
chevron: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>`,
laser: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--cyan)" stroke-width="1.2"><circle cx="8" cy="8" r="2"/><path d="M8 2v3M8 11v3M2 8h3M11 8h3" opacity="0.5"/></svg>`,
bend: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.2"><path d="M3 13V7a4 4 0 0 1 4-4h6"/><polyline points="10 6 13 3 10 0" transform="translate(0,2)"/></svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
};
function fileIcon(name) {

View File

@@ -29,87 +29,34 @@ const pages = {
return;
}
// Deduplicate: keep only the latest export per drawing number
const seen = new Set();
const unique = data.items.filter(e => {
const dn = e.drawingNumber || '';
if (seen.has(dn)) return false;
seen.add(dn);
return true;
});
setPage('Exports', `${data.items.length} exports`);
// Group by equipment number (first token of drawing number)
const groups = new Map();
unique.forEach(e => {
const dn = e.drawingNumber || '';
const spaceIdx = dn.indexOf(' ');
const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other');
if (!groups.has(equip)) groups.set(equip, []);
groups.get(equip).push(e);
});
// Sort equipment groups by number descending (most recent equipment first)
const sortedGroups = [...groups.entries()].sort((a, b) => {
const numA = parseInt(a[0]) || 0;
const numB = parseInt(b[0]) || 0;
return numB - numA;
});
const uniqueEquip = sortedGroups.length;
const uniqueDrawings = unique.length;
setPage('Exports', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`);
const groupsHtml = sortedGroups.map(([equip, items], gi) => {
const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0);
const rows = items.map((e, i) => {
const dn = e.drawingNumber || '';
const spaceIdx = dn.indexOf(' ');
const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn;
return `
<tr class="clickable" onclick="router.go('export-detail', {id: ${e.id}})" style="animation: fadeSlideIn 0.2s ease ${0.02 * i}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);color:var(--text-dim);font-size:13px">${e.id}</td>
<td><strong>${esc(drawingPart) || '<span style="color:var(--text-dim)">\u2014</span>'}</strong></td>
<td style="color:var(--text-secondary);font-size:13px">${esc(e.title) || ''}</td>
<td><span class="badge badge-count">${e.bomItemCount}</span></td>
<td style="color:var(--text-secondary)">${esc(e.exportedBy)}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);white-space:nowrap">${fmtDate(e.exportedAt)}</td>
</tr>`;
}).join('');
return `
<div class="equip-group animate-in" id="equip-${esc(equip)}" style="animation-delay:${0.04 * gi}s">
<div class="equip-header" onclick="toggleEquipGroup('equip-${esc(equip)}')">
<span class="chevron-toggle open" id="equip-${esc(equip)}-icon">${icons.chevron}</span>
<span class="equip-header-number">${esc(equip)}</span>
<div class="equip-header-meta">
<span class="equip-header-stat"><strong>${items.length}</strong> exports</span>
<span class="equip-header-stat"><strong>${totalBom}</strong> items</span>
</div>
</div>
<div class="equip-body">
<table>
<thead><tr>
<th style="width:50px">#</th>
<th>Drawing</th>
<th>Title</th>
<th style="width:80px">Items</th>
<th>Exported By</th>
<th style="width:180px">Date</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}).join('');
const rows = data.items.map((e, i) => `
<tr class="clickable" onclick="router.go('export-detail', {id: ${e.id}})" style="animation: fadeSlideIn 0.2s ease ${0.02 * Math.min(i, 25)}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);color:var(--text-dim);font-size:13px">${e.id}</td>
<td><strong>${esc(e.drawingNumber) || '<span style="color:var(--text-dim)">\u2014</span>'}</strong></td>
<td style="color:var(--text-secondary);font-size:13px">${esc(e.title) || ''}</td>
<td><span class="badge badge-count">${e.bomItemCount}</span></td>
<td style="color:var(--text-secondary)">${esc(e.exportedBy)}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);white-space:nowrap">${fmtDate(e.exportedAt)}</td>
<td><button class="btn btn-red btn-sm" onclick="event.stopPropagation();deleteExport(${e.id})">${icons.trash}</button></td>
</tr>`).join('');
content.innerHTML = `
<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Drawings</div><div class="stat-value">${uniqueDrawings}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Equipment</div><div class="stat-value">${uniqueEquip}</div></div>
</div>
${groupsHtml}`;
<div class="card animate-in">
<table>
<thead><tr>
<th style="width:50px">#</th>
<th>Drawing</th>
<th>Title</th>
<th style="width:80px">Items</th>
<th>Exported By</th>
<th style="width:180px">Date</th>
<th style="width:50px"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
@@ -171,6 +118,7 @@ const pages = {
<span style="margin-left:auto;display:flex;gap:6px">
${exp.pdfContentHash ? `<a class="btn btn-amber btn-sm" href="/api/filebrowser/download?hash=${encodeURIComponent(exp.pdfContentHash)}&ext=pdf&name=${encodeURIComponent((exp.drawingNumber || 'drawing') + '.pdf')}">${icons.download} PDF</a>` : ''}
${dxfCount > 0 ? `<a class="btn btn-cyan btn-sm" href="/api/exports/${exp.id}/download-dxfs">${icons.download} All DXFs</a>` : ''}
<button class="btn btn-red btn-sm" onclick="deleteExport(${exp.id})">${icons.trash} Delete</button>
</span>
</div>
${exp.bomItems?.length ? `
@@ -193,34 +141,115 @@ const pages = {
}
},
async drawings() {
async drawings(params) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Drawings');
actions.innerHTML = '';
const searchVal = (params && params.q) || '';
actions.innerHTML = `
<div class="search-box">
${icons.search}
<input type="text" id="drawing-search" placeholder="Search drawing, part, user..." value="${esc(searchVal)}">
</div>`;
content.innerHTML = `<div class="loading">Loading drawings</div>`;
const searchInput = document.getElementById('drawing-search');
let debounce;
searchInput.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => router.go('drawings', { q: searchInput.value }), 400);
});
try {
const numbers = await api.get('/api/exports/drawing-numbers');
if (numbers.length === 0) {
const searchQ = searchVal ? `&search=${encodeURIComponent(searchVal)}` : '';
const data = await api.get(`/api/exports?take=500${searchQ}`);
if (data.items.length === 0) {
content.innerHTML = `<div class="empty">No drawings found.</div>`;
return;
}
numbers.sort();
setPage('Drawings', `${numbers.length} drawings`);
// Deduplicate: keep only the latest export per drawing number
const seen = new Set();
const unique = data.items.filter(e => {
const dn = e.drawingNumber || '';
if (seen.has(dn)) return false;
seen.add(dn);
return true;
});
const cards = numbers.map((d, i) => `
<div class="drawing-card" onclick="router.go('drawing-detail', {id: '${encodeURIComponent(d)}'})" style="animation: fadeSlideIn 0.3s ease ${0.025 * Math.min(i, 20)}s forwards; opacity: 0">
<div class="drawing-card-title">${esc(d)}</div>
<div class="drawing-card-sub">Drawing</div>
</div>`).join('');
// Group by equipment number (first token of drawing number)
const groups = new Map();
unique.forEach(e => {
const dn = e.drawingNumber || '';
const spaceIdx = dn.indexOf(' ');
const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other');
if (!groups.has(equip)) groups.set(equip, []);
groups.get(equip).push(e);
});
// Sort equipment groups by number descending (most recent equipment first)
const sortedGroups = [...groups.entries()].sort((a, b) => {
const numA = parseInt(a[0]) || 0;
const numB = parseInt(b[0]) || 0;
return numB - numA;
});
const uniqueEquip = sortedGroups.length;
const uniqueDrawings = unique.length;
setPage('Drawings', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`);
const groupsHtml = sortedGroups.map(([equip, items], gi) => {
const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0);
const rows = items.map((e, i) => {
const dn = e.drawingNumber || '';
const spaceIdx = dn.indexOf(' ');
const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn;
return `
<tr class="clickable" onclick="router.go('drawing-detail', {id: '${encodeURIComponent(e.drawingNumber)}'})" style="animation: fadeSlideIn 0.2s ease ${0.02 * i}s forwards; opacity: 0">
<td><strong>${esc(drawingPart) || '<span style="color:var(--text-dim)">\u2014</span>'}</strong></td>
<td style="color:var(--text-secondary);font-size:13px">${esc(e.title) || ''}</td>
<td><span class="badge badge-count">${e.bomItemCount}</span></td>
<td style="color:var(--text-secondary)">${esc(e.exportedBy)}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);white-space:nowrap">${fmtDate(e.exportedAt)}</td>
</tr>`;
}).join('');
return `
<div class="equip-group animate-in" id="equip-${esc(equip)}" style="animation-delay:${0.04 * gi}s">
<div class="equip-header" onclick="toggleEquipGroup('equip-${esc(equip)}')">
<span class="chevron-toggle open" id="equip-${esc(equip)}-icon">${icons.chevron}</span>
<span class="equip-header-number">${esc(equip)}</span>
<div class="equip-header-meta">
<span class="equip-header-stat"><strong>${items.length}</strong> drawings</span>
<span class="equip-header-stat"><strong>${totalBom}</strong> items</span>
</div>
</div>
<div class="equip-body">
<table>
<thead><tr>
<th>Drawing</th>
<th>Title</th>
<th style="width:80px">Items</th>
<th>Exported By</th>
<th style="width:180px">Latest Export</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}).join('');
content.innerHTML = `
<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Total Drawings</div><div class="stat-value">${numbers.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Drawings</div><div class="stat-value">${uniqueDrawings}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Equipment</div><div class="stat-value">${uniqueEquip}</div></div>
</div>
<div class="drawings-grid">${cards}</div>`;
${groupsHtml}`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}

View File

@@ -26,7 +26,7 @@ const router = {
switch(page) {
case 'exports': pages.exports(params); break;
case 'export-detail': pages.exportDetail(id); break;
case 'drawings': pages.drawings(); break;
case 'drawings': pages.drawings(params); break;
case 'drawing-detail': pages.drawingDetail(id, params); break;
case 'files': pages.files(params); break;
default: pages.exports(params);