diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2998dc6..c00178d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,45 @@ "permissions": { "allow": [ "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Bash(dotnet build:*)", + "Bash(msbuild:*)", + "Bash(powershell.exe -File \"C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1\" getprojects)", + "Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 instances\")", + "Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 build -ProjectName ExportDXF\")", + "Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 projects\")", + "Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 errors\")", + "Bash(powershell -Command:*)", + "Bash(curl:*)", + "Bash(python:*)", + "Skill(roslyn-bridge)", + "Bash(dotnet add:*)", + "Bash(nuget restore:*)", + "mcp__RoslynBridge__get_solution_overview", + "mcp__RoslynBridge__get_files", + "mcp__RoslynBridge__list_instances", + "mcp__RoslynBridge__refresh_workspace", + "mcp__RoslynBridge__get_diagnostics", + "mcp__RoslynBridge__build_project", + "mcp__RoslynBridge__get_projects", + "Bash(findstr:*)", + "Bash(Select-String -Pattern \"error|Build succeeded\")", + "Bash(Select-String -NotMatch \"CA1416\")", + "Bash(dir:*)", + "Bash(git checkout:*)", + "Bash(ls \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\docs\\\\plans\"\" 2>nul || echo \"No plans dir \")", + "Bash(dotnet new:*)", + "Bash(dotnet sln:*)", + "Bash(del \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\FabWorks.Core\\\\Class1.cs\")", + "Bash(copy \"S:\\\\4980 GMCH Lockport\\\\4980 A05 Degreaser Casing\\\\4980 A05-1 Access door\\\\Forms\\\\4980 A05-1 PT02.pgm\" \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\FabWorks.Tests\\\\TestData\\\\sample.pgm\")", + "Bash(dotnet test:*)", + "Bash(del \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\FabWorks.Api\\\\FabWorks.Api.http\")", + "Bash(dotnet ef:*)", + "Bash(git log:*)", + "mcp__RoslynBridge__get_namespace_types", + "Bash(test:*)", + "mcp__RoslynBridge__search_symbol", + "mcp__RoslynBridge__search_code" ], "deny": [], "ask": [] diff --git a/ExportDXF/ExportDXF.csproj b/ExportDXF/ExportDXF.csproj index e68e775..738ca32 100644 --- a/ExportDXF/ExportDXF.csproj +++ b/ExportDXF/ExportDXF.csproj @@ -14,7 +14,6 @@ - diff --git a/ExportDXF/Program.cs b/ExportDXF/Program.cs index 05b5db5..a2efb30 100644 --- a/ExportDXF/Program.cs +++ b/ExportDXF/Program.cs @@ -3,7 +3,10 @@ using ExportDXF.Forms; using ExportDXF.Services; using System; using System.Configuration; +using System.Diagnostics; +using System.IO; using System.Net.Http; +using System.Threading; using System.Windows.Forms; namespace ExportDXF @@ -44,6 +47,8 @@ namespace ExportDXF var partExporter = new PartExporter(); var drawingExporter = new DrawingExporter(); + EnsureApiRunning(); + var httpClient = new HttpClient { BaseAddress = new Uri(_apiBaseUrl), @@ -60,5 +65,52 @@ namespace ExportDXF return new MainForm(solidWorksService, exportService, apiClient); } + + private void EnsureApiRunning() + { + // Check if API is already responding + using (var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }) + { + try + { + var response = probe.GetAsync(_apiBaseUrl + "/api/exports?take=1").Result; + if (response.IsSuccessStatusCode) + return; // already running + } + catch { } + } + + // Find the API executable relative to this assembly + var exeDir = AppContext.BaseDirectory; + var apiExe = Path.GetFullPath(Path.Combine(exeDir, @"..\..\..\FabWorks.Api\bin\Debug\net8.0\FabWorks.Api.exe")); + if (!File.Exists(apiExe)) + return; // can't find it, skip + + var startInfo = new ProcessStartInfo + { + FileName = apiExe, + WorkingDirectory = Path.GetDirectoryName(apiExe), + UseShellExecute = false, + CreateNoWindow = true + }; + + Process.Start(startInfo); + + // Wait up to 10 seconds for API to become ready + using (var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }) + { + for (int i = 0; i < 20; i++) + { + Thread.Sleep(500); + try + { + var response = probe.GetAsync(_apiBaseUrl + "/api/exports?take=1").Result; + if (response.IsSuccessStatusCode) + return; + } + catch { } + } + } + } } } diff --git a/ExportDXF/Services/DxfExportService.cs b/ExportDXF/Services/DxfExportService.cs index f6528be..bf8a5b4 100644 --- a/ExportDXF/Services/DxfExportService.cs +++ b/ExportDXF/Services/DxfExportService.cs @@ -237,6 +237,9 @@ namespace ExportDXF.Services pdfHash, exportRecord?.Id); + if (exportRecord != null) + await _apiClient.UpdatePdfHashAsync(exportRecord.Id, pdfHash); + if (uploadResult != null) { if (uploadResult.WasUnchanged) diff --git a/ExportDXF/Services/PartExporter.cs b/ExportDXF/Services/PartExporter.cs index 40df580..bf482cc 100644 --- a/ExportDXF/Services/PartExporter.cs +++ b/ExportDXF/Services/PartExporter.cs @@ -54,7 +54,7 @@ namespace ExportDXF.Services try { - var fileName = GetSinglePartFileName(model, context.FilePrefix); + var fileName = GetSinglePartFileName(model, context.Equipment); var savePath = Path.Combine(saveDirectory, fileName + ".dxf"); // Build result item with metadata @@ -139,7 +139,7 @@ namespace ExportDXF.Services EnrichItemWithMetadata(item, model, part); - var fileName = GetItemFileName(item, context.FilePrefix); + var fileName = GetItemFileName(item, context); var savePath = Path.Combine(saveDirectory, fileName + ".dxf"); var templateDrawing = context.GetOrCreateTemplateDrawing(); @@ -332,23 +332,33 @@ namespace ExportDXF.Services } } - private string GetSinglePartFileName(ModelDoc2 model, string prefix) + private string GetSinglePartFileName(ModelDoc2 model, string equipment) { var title = model.GetTitle().Replace(".SLDPRT", ""); var config = model.ConfigurationManager.ActiveConfiguration.Name; var isDefaultConfig = string.Equals(config, "default", StringComparison.OrdinalIgnoreCase); - return isDefaultConfig ? title : $"{title} [{config}]"; + var name = isDefaultConfig ? title : $"{title} [{config}]"; + return string.IsNullOrWhiteSpace(equipment) ? name : $"{equipment} {name}"; } - private string GetItemFileName(Item item, string prefix) + private string GetItemFileName(Item item, ExportContext context) { + if (string.IsNullOrWhiteSpace(context.DrawingNo)) + { + // No drawing number: preserve part name, prefix with EquipmentNo + var equipment = context.Equipment; + return string.IsNullOrWhiteSpace(equipment) + ? item.PartName + : $"{equipment} {item.PartName}"; + } + if (string.IsNullOrWhiteSpace(item.ItemNo)) return item.PartName; - prefix = prefix?.Replace("\"", "''") ?? string.Empty; + var prefix = context.FilePrefix?.Replace("\"", "''") ?? string.Empty; var num = item.ItemNo.PadLeft(2, '0'); - // Expected format: {DrawingNo} PT{ItemNo} + // Expected format: {EquipNo} {DrawingNo} PT{ItemNo} return string.IsNullOrWhiteSpace(prefix) ? $"PT{num}" : $"{prefix} PT{num}"; diff --git a/ExportDXF/Utilities/ContentHasher.cs b/ExportDXF/Utilities/ContentHasher.cs index 318e359..dba5c0c 100644 --- a/ExportDXF/Utilities/ContentHasher.cs +++ b/ExportDXF/Utilities/ContentHasher.cs @@ -7,11 +7,10 @@ using System.Security.Cryptography; using System.Text; using ACadSharp.Entities; using ACadSharp.IO; -using CoenM.ImageHash; -using CoenM.ImageHash.HashAlgorithms; using PDFtoImage; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace ExportDXF.Utilities { @@ -48,13 +47,12 @@ namespace ExportDXF.Utilities using (var pngStream = new MemoryStream()) { Conversion.SavePng(pngStream, pdfStream, page: 0, - options: new RenderOptions(Dpi: 72)); + options: new RenderOptions(Dpi: 150)); pngStream.Position = 0; using (var image = Image.Load(pngStream)) { - var algorithm = new DifferenceHash(); - var hash = algorithm.Hash(image); + var hash = ComputeDifferenceHash(image); return hash.ToString("x16"); } } @@ -78,6 +76,38 @@ namespace ExportDXF.Utilities } } + /// + /// DifferenceHash: resize to 9x8 grayscale, compare adjacent pixels. + /// Produces a 64-bit hash. Implemented directly against ImageSharp 3.x API + /// (CoenM.ImageHash uses the removed GetPixelRowSpan from ImageSharp 2.x). + /// + private static ulong ComputeDifferenceHash(Image image) + { + // Resize to 9 wide x 8 tall for 8x8 = 64 bit comparisons + image.Mutate(ctx => ctx.Resize(9, 8)); + + ulong hash = 0; + int bit = 0; + + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + var left = image[x, y]; + var right = image[x + 1, y]; + var leftGray = 0.299 * left.R + 0.587 * left.G + 0.114 * left.B; + var rightGray = 0.299 * right.R + 0.587 * right.G + 0.114 * right.B; + + if (leftGray > rightGray) + hash |= (1UL << bit); + + bit++; + } + } + + return hash; + } + private static string ComputeGeometricHash(string filePath) { using (var reader = new DxfReader(filePath)) diff --git a/FabWorks.Api/Controllers/ExportsController.cs b/FabWorks.Api/Controllers/ExportsController.cs index 6ed38d5..28322d7 100644 --- a/FabWorks.Api/Controllers/ExportsController.cs +++ b/FabWorks.Api/Controllers/ExportsController.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.IO.Compression; +using System.Numerics; using FabWorks.Api.DTOs; using FabWorks.Api.Services; using FabWorks.Core.Data; @@ -215,8 +217,9 @@ namespace FabWorks.Api.Controllers if (!string.IsNullOrEmpty(record.DrawingNumber) && !string.IsNullOrEmpty(request.PdfContentHash)) { - var drawing = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash); - record.DrawingId = drawing.Id; + var (drawing, revision) = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash); + record.Drawing = drawing; + record.DrawingRevision = revision; } await _db.SaveChangesAsync(); @@ -224,36 +227,42 @@ namespace FabWorks.Api.Controllers return NoContent(); } - private async Task ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash) + private async Task<(Drawing drawing, int revision)> ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash) { var drawing = await _db.Drawings .FirstOrDefaultAsync(d => d.DrawingNumber == drawingNumber); + // Get the highest revision recorded for this drawing across all exports + var lastRevision = await _db.ExportRecords + .Where(r => r.DrawingNumber == drawingNumber && r.DrawingRevision != null) + .OrderByDescending(r => r.DrawingRevision) + .Select(r => r.DrawingRevision) + .FirstOrDefaultAsync() ?? 0; + if (drawing == null) { drawing = new Drawing { DrawingNumber = drawingNumber, Title = title, - PdfContentHash = pdfContentHash, - Revision = 1 + PdfContentHash = pdfContentHash }; _db.Drawings.Add(drawing); - } - else if (drawing.PdfContentHash != pdfContentHash) - { - drawing.PdfContentHash = pdfContentHash; - drawing.Revision++; - if (!string.IsNullOrEmpty(title)) - drawing.Title = title; - } - // If hash matches, keep same revision (just update title if needed) - else if (!string.IsNullOrEmpty(title)) - { - drawing.Title = title; + return (drawing, 1); } - return drawing; + if (!string.IsNullOrEmpty(title)) + drawing.Title = title; + + if (ArePerceptualHashesSimilar(drawing.PdfContentHash, pdfContentHash)) + { + // Hash unchanged — keep same revision + return (drawing, lastRevision == 0 ? 1 : lastRevision); + } + + // Hash changed — bump revision and update stored hash + drawing.PdfContentHash = pdfContentHash; + return (drawing, lastRevision + 1); } [HttpGet("previous-cut-template")] @@ -316,6 +325,37 @@ namespace FabWorks.Api.Controllers if (dxfItems.Count == 0) return NotFound("No DXF files for this export."); + var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip"; + return BuildDxfZip(dxfItems, zipName); + } + + [HttpGet("download-dxfs")] + public async Task DownloadDxfsByDrawing([FromQuery] string drawingNumber) + { + if (string.IsNullOrEmpty(drawingNumber)) + return BadRequest("drawingNumber is required."); + + var dxfItems = await _db.BomItems + .Include(b => b.CutTemplate) + .Where(b => b.ExportRecord.DrawingNumber == drawingNumber + && b.CutTemplate != null + && b.CutTemplate.ContentHash != null) + .ToListAsync(); + + if (dxfItems.Count == 0) return NotFound("No DXF files for this drawing."); + + // Deduplicate by content hash (keep latest) + dxfItems = dxfItems + .GroupBy(b => b.CutTemplate.ContentHash) + .Select(g => g.Last()) + .ToList(); + + var zipName = $"{drawingNumber} DXFs.zip"; + return BuildDxfZip(dxfItems, zipName); + } + + private FileResult BuildDxfZip(List dxfItems, string zipName) + { var ms = new MemoryStream(); using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { @@ -341,16 +381,41 @@ namespace FabWorks.Api.Controllers var entry = zip.CreateEntry(fileName, CompressionLevel.Fastest); using var entryStream = entry.Open(); - await blobStream.CopyToAsync(entryStream); + blobStream.CopyTo(entryStream); blobStream.Dispose(); } } ms.Position = 0; - var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip"; return File(ms, "application/zip", zipName); } + /// + /// Compares two perceptual hashes using Hamming distance. + /// Perceptual hashes (16 hex chars / 64 bits) are compared with a tolerance + /// of up to 10 differing bits (~84% similarity). SHA256 fallback hashes + /// (64 hex chars) use exact comparison. + /// + private static bool ArePerceptualHashesSimilar(string hash1, string hash2) + { + if (hash1 == hash2) return true; + if (string.IsNullOrEmpty(hash1) || string.IsNullOrEmpty(hash2)) return false; + + // Perceptual hashes are 16 hex chars (64-bit DifferenceHash) + // SHA256 fallback hashes are 64 hex chars — require exact match + if (hash1.Length != 16 || hash2.Length != 16) + return false; + + if (ulong.TryParse(hash1, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h1) && + ulong.TryParse(hash2, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h2)) + { + var hammingDistance = BitOperations.PopCount(h1 ^ h2); + return hammingDistance <= 10; + } + + return false; + } + private static ExportDetailDto MapToDto(ExportRecord r) => new() { Id = r.Id, diff --git a/FabWorks.Api/Controllers/FileBrowserController.cs b/FabWorks.Api/Controllers/FileBrowserController.cs index 8c50dbb..fbc7124 100644 --- a/FabWorks.Api/Controllers/FileBrowserController.cs +++ b/FabWorks.Api/Controllers/FileBrowserController.cs @@ -88,7 +88,7 @@ namespace FabWorks.Api.Controllers r.DrawingNumber, r.PdfContentHash, r.ExportedAt, - DrawingRevision = r.Drawing != null ? (int?)r.Drawing.Revision : null + r.DrawingRevision }); if (!string.IsNullOrWhiteSpace(search)) diff --git a/FabWorks.Api/Controllers/FilesController.cs b/FabWorks.Api/Controllers/FilesController.cs index b8263f1..4c07f45 100644 --- a/FabWorks.Api/Controllers/FilesController.cs +++ b/FabWorks.Api/Controllers/FilesController.cs @@ -30,7 +30,7 @@ namespace FabWorks.Api.Controllers return BadRequest("No file uploaded."); using var stream = file.OpenReadStream(); - var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash); + var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash, file.FileName); return Ok(new FileUploadResponse { diff --git a/FabWorks.Api/Services/FileStorageService.cs b/FabWorks.Api/Services/FileStorageService.cs index 52cdb0b..2425c96 100644 --- a/FabWorks.Api/Services/FileStorageService.cs +++ b/FabWorks.Api/Services/FileStorageService.cs @@ -16,7 +16,7 @@ namespace FabWorks.Api.Services public interface IFileStorageService { string OutputFolder { get; } - Task StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash); + Task StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null); Task StorePdfAsync(Stream stream, string equipment, string drawingNo, string contentHash, int? exportRecordId = null); Stream OpenBlob(string contentHash, string extension); bool BlobExists(string contentHash, string extension); @@ -39,9 +39,9 @@ namespace FabWorks.Api.Services Directory.CreateDirectory(blobRoot); } - public async Task StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash) + public async Task StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null) { - var fileName = BuildDxfFileName(drawingNo, equipment, itemNo); + var fileName = BuildDxfFileName(drawingNo, equipment, itemNo, originalFileName); // Look up previous hash by drawing number + item number var drawingNumber = BuildDrawingNumber(equipment, drawingNo); @@ -147,8 +147,16 @@ namespace FabWorks.Api.Services return drawingNo ?? ""; } - private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo) + private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo, string originalFileName = null) { + // No drawing number: use the original filename from the client + if (string.IsNullOrEmpty(drawingNo) && !string.IsNullOrEmpty(originalFileName)) + { + return originalFileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) + ? originalFileName + : originalFileName + ".dxf"; + } + var drawingNumber = BuildDrawingNumber(equipment, drawingNo); var paddedItem = (itemNo ?? "").PadLeft(2, '0'); if (!string.IsNullOrEmpty(drawingNumber) && !string.IsNullOrEmpty(itemNo)) diff --git a/FabWorks.Api/wwwroot/css/styles.css b/FabWorks.Api/wwwroot/css/styles.css index eadff8d..f875c7c 100644 --- a/FabWorks.Api/wwwroot/css/styles.css +++ b/FabWorks.Api/wwwroot/css/styles.css @@ -735,6 +735,85 @@ tbody tr:last-child td { border-bottom: none; } .equip-group.collapsed .equip-body { display: none; } .equip-group.collapsed .equip-header { border-radius: 6px; } +/* ─── Modal ─── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.modal-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + width: 90%; + max-width: 640px; + max-height: 80vh; + display: flex; + flex-direction: column; + animation: fadeSlideIn 0.2s ease forwards; +} + +.modal-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); + font-family: var(--font-display); + font-weight: 600; + font-size: 14px; + letter-spacing: 0.02em; + display: flex; + align-items: center; + gap: 10px; + text-transform: uppercase; + color: var(--text-secondary); +} + +.modal-header svg { width: 16px; height: 16px; } + +.modal-body { + overflow-y: auto; + flex: 1; +} + +.btn-green { + background: var(--green-dim); + color: var(--green); + border-color: rgba(6, 118, 71, 0.25); +} + +.btn-green:hover { + background: rgba(6, 118, 71, 0.15); + border-color: rgba(6, 118, 71, 0.4); + color: var(--green); +} + +/* ─── Toast ─── */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--text); + color: #fff; + padding: 8px 20px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 13px; + z-index: 300; + animation: fadeSlideIn 0.2s ease, fadeOut 0.3s ease 2s forwards; +} + +@keyframes fadeOut { to { opacity: 0; } } + /* ─── Responsive ─── */ @media (max-width: 768px) { .sidebar { display: none; } diff --git a/FabWorks.Api/wwwroot/index.html b/FabWorks.Api/wwwroot/index.html index 4b1b4c4..0f7c4ce 100644 --- a/FabWorks.Api/wwwroot/index.html +++ b/FabWorks.Api/wwwroot/index.html @@ -46,11 +46,11 @@
- - - - - + + + + + diff --git a/FabWorks.Api/wwwroot/js/components.js b/FabWorks.Api/wwwroot/js/components.js index fac1db5..792af2a 100644 --- a/FabWorks.Api/wwwroot/js/components.js +++ b/FabWorks.Api/wwwroot/js/components.js @@ -58,3 +58,98 @@ function toggleBomRow(id) { row.style.display = visible ? 'none' : ''; if (icon) icon.classList.toggle('open', !visible); } + +/* ─── Cut List Modal ─── */ +function showCutListModal(bomItems) { + const cutItems = bomItems.filter(b => b.cutTemplate); + if (cutItems.length === 0) { + showToast('No cut templates found'); + return; + } + + const rows = cutItems.map(b => { + const ct = b.cutTemplate; + const name = ct.cutTemplateName || ct.dxfFilePath?.split(/[/\\]/).pop()?.replace(/\.dxf$/i, '') || b.partName || ''; + const qty = b.qty ?? ''; + return { name, qty }; + }); + + const tableRows = rows.map((r, i) => ` + + ${esc(r.name)} + ${r.qty} + `).join(''); + + // Remove existing modal if any + const existing = document.getElementById('cut-list-modal'); + if (existing) existing.remove(); + + const modal = document.createElement('div'); + modal.id = 'cut-list-modal'; + modal.className = 'modal-overlay'; + modal.innerHTML = ` + `; + + document.body.appendChild(modal); + // Store data for copy + modal._cutData = rows; + // Close on backdrop click + modal.addEventListener('click', e => { if (e.target === modal) closeCutListModal(); }); + // Close on Escape + modal._keyHandler = e => { if (e.key === 'Escape') closeCutListModal(); }; + document.addEventListener('keydown', modal._keyHandler); +} + +function closeCutListModal() { + const modal = document.getElementById('cut-list-modal'); + if (!modal) return; + document.removeEventListener('keydown', modal._keyHandler); + modal.remove(); +} + +function copyCutList() { + const modal = document.getElementById('cut-list-modal'); + if (!modal || !modal._cutData) return; + + const text = modal._cutData.map(r => `${r.name}\t${r.qty}`).join('\n'); + + navigator.clipboard.writeText(text).then(() => { + const btn = document.getElementById('copy-cut-list-btn'); + if (btn) { + btn.innerHTML = `${icons.check} Copied!`; + btn.classList.remove('btn-cyan'); + btn.classList.add('btn-green'); + setTimeout(() => { + btn.innerHTML = `${icons.clipboard} Copy`; + btn.classList.remove('btn-green'); + btn.classList.add('btn-cyan'); + }, 2000); + } + }); +} + +function showToast(msg) { + const t = document.createElement('div'); + t.className = 'toast'; + t.textContent = msg; + document.body.appendChild(t); + setTimeout(() => t.remove(), 2500); +} diff --git a/FabWorks.Api/wwwroot/js/icons.js b/FabWorks.Api/wwwroot/js/icons.js index 5af4ad1..1e5199e 100644 --- a/FabWorks.Api/wwwroot/js/icons.js +++ b/FabWorks.Api/wwwroot/js/icons.js @@ -10,6 +10,9 @@ const icons = { laser: ``, bend: ``, trash: ``, + clipboard: ``, + check: ``, + close: ``, }; function fileIcon(name) { diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js index d0d4ecb..ab16e25 100644 --- a/FabWorks.Api/wwwroot/js/pages.js +++ b/FabWorks.Api/wwwroot/js/pages.js @@ -213,6 +213,9 @@ const pages = { allBom = [...bomByItem.values()]; } + // Store for cut list modal + window._currentBom = allBom; + const bomRows = allBom.map((b, i) => { const hasDetails = b.cutTemplate || b.formProgram; const toggleId = `dbom-${b.id}`; @@ -265,8 +268,12 @@ const pages = { ${bomHeader} ${allBom.length} items + ${dxfCount > 0 ? `` : ''} ${pdfHash ? `${icons.download} PDF` : ''} - ${dxfCount > 0 ? `${icons.download} All DXFs` : ''} + ${dxfCount > 0 ? (singleExport + ? `${icons.download} All DXFs` + : `${icons.download} All DXFs` + ) : ''} ${allBom.length ? ` diff --git a/FabWorks.Core/Migrations/20260220171747_MoveRevisionFromDrawingToExportRecord.Designer.cs b/FabWorks.Core/Migrations/20260220171747_MoveRevisionFromDrawingToExportRecord.Designer.cs new file mode 100644 index 0000000..6c506eb --- /dev/null +++ b/FabWorks.Core/Migrations/20260220171747_MoveRevisionFromDrawingToExportRecord.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using FabWorks.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + [DbContext(typeof(FabWorksDbContext))] + [Migration("20260220171747_MoveRevisionFromDrawingToExportRecord")] + partial class MoveRevisionFromDrawingToExportRecord + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FabWorks.Core.Models.BomItem", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("ConfigurationName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ExportRecordId") + .HasColumnType("int"); + + b.Property("ItemNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalQty") + .HasColumnType("int"); + + b.HasKey("ID"); + + b.HasIndex("ExportRecordId"); + + b.ToTable("BomItems"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CutTemplateName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DefaultBendRadius") + .HasColumnType("float"); + + b.Property("DxfFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("Revision") + .HasColumnType("int"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("CutTemplates"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.Drawing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DrawingNumber") + .IsUnique() + .HasFilter("[DrawingNumber] IS NOT NULL"); + + b.ToTable("Drawings"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingId") + .HasColumnType("int"); + + b.Property("DrawingNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DrawingRevision") + .HasColumnType("int"); + + b.Property("EquipmentNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ExportedAt") + .HasColumnType("datetime2"); + + b.Property("ExportedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OutputFolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DrawingId"); + + b.ToTable("ExportRecords"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BendCount") + .HasColumnType("int"); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("LowerToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaterialType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProgramFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ProgramName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SetupNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.Property("UpperToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("FormPrograms"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.BomItem", b => + { + b.HasOne("FabWorks.Core.Models.ExportRecord", "ExportRecord") + .WithMany("BomItems") + .HasForeignKey("ExportRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExportRecord"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.HasOne("FabWorks.Core.Models.BomItem", "BomItem") + .WithOne("CutTemplate") + .HasForeignKey("FabWorks.Core.Models.CutTemplate", "BomItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BomItem"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.HasOne("FabWorks.Core.Models.Drawing", "Drawing") + .WithMany("ExportRecords") + .HasForeignKey("DrawingId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Drawing"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.HasOne("FabWorks.Core.Models.BomItem", "BomItem") + .WithOne("FormProgram") + .HasForeignKey("FabWorks.Core.Models.FormProgram", "BomItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BomItem"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.BomItem", b => + { + b.Navigation("CutTemplate"); + + b.Navigation("FormProgram"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.Drawing", b => + { + b.Navigation("ExportRecords"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Navigation("BomItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FabWorks.Core/Migrations/20260220171747_MoveRevisionFromDrawingToExportRecord.cs b/FabWorks.Core/Migrations/20260220171747_MoveRevisionFromDrawingToExportRecord.cs new file mode 100644 index 0000000..fce74ad --- /dev/null +++ b/FabWorks.Core/Migrations/20260220171747_MoveRevisionFromDrawingToExportRecord.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + /// + public partial class MoveRevisionFromDrawingToExportRecord : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Revision", + table: "Drawings"); + + migrationBuilder.AddColumn( + name: "DrawingRevision", + table: "ExportRecords", + type: "int", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DrawingRevision", + table: "ExportRecords"); + + migrationBuilder.AddColumn( + name: "Revision", + table: "Drawings", + type: "int", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs index de602de..c6e37f1 100644 --- a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs +++ b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs @@ -132,9 +132,6 @@ namespace FabWorks.Core.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("Revision") - .HasColumnType("int"); - b.Property("Title") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); @@ -167,6 +164,9 @@ namespace FabWorks.Core.Migrations .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("DrawingRevision") + .HasColumnType("int"); + b.Property("EquipmentNo") .HasMaxLength(50) .HasColumnType("nvarchar(50)"); diff --git a/FabWorks.Core/Models/Drawing.cs b/FabWorks.Core/Models/Drawing.cs index e23b0c2..7a369be 100644 --- a/FabWorks.Core/Models/Drawing.cs +++ b/FabWorks.Core/Models/Drawing.cs @@ -8,7 +8,6 @@ namespace FabWorks.Core.Models public string DrawingNumber { get; set; } public string Title { get; set; } public string PdfContentHash { get; set; } - public int Revision { get; set; } = 1; public virtual ICollection ExportRecords { get; set; } = new List(); } diff --git a/FabWorks.Core/Models/ExportRecord.cs b/FabWorks.Core/Models/ExportRecord.cs index 0e1491a..1ba36d1 100644 --- a/FabWorks.Core/Models/ExportRecord.cs +++ b/FabWorks.Core/Models/ExportRecord.cs @@ -17,6 +17,7 @@ namespace FabWorks.Core.Models public string PdfContentHash { get; set; } public int? DrawingId { get; set; } + public int? DrawingRevision { get; set; } public virtual Drawing Drawing { get; set; } public virtual ICollection BomItems { get; set; } = new List();