Add support for invoice attachments
Introduced the `InvoiceAttachment` entity and its relationship with `ZugferdInvoice` to manage extracted invoice attachments. Updated `AppDbContext` and added a migration to create the `InvoiceAttachments` table with cascading delete behavior and an index for optimized queries. Enhanced the UI to display attachments in `Details.cshtml`, including file type icons, file size, and extraction date. Added a new `ViewAttachment` page to render or download attachments based on their type, with support for XML, plain text, images, and downloads. Implemented `AttachmentViewerService` to determine viewer types and MIME types for attachments. Registered the service in the DI container. Updated `Upload.cshtml.cs` to save extracted attachments to the database. Improved user experience with syntax highlighting for XML files and appropriate messages for unsupported file types.
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
@model DXApp.TemplateKitProject.Pages.Invoices.DetailsModel
|
||||
@{
|
||||
ViewData["Title"] = "Rechnungsdetails";
|
||||
|
||||
string FormatFileSize(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return $"{bytes} Bytes";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:N2} KB";
|
||||
return $"{bytes / (1024.0 * 1024.0):N2} MB";
|
||||
}
|
||||
}
|
||||
|
||||
<h2>📄 Rechnungsdetails</h2>
|
||||
@@ -11,7 +18,7 @@
|
||||
{
|
||||
<button class="btn btn-primary mb-3 ms-2"
|
||||
onclick="openPdfViewer(@Model.Invoice.Id)">
|
||||
📄 PDF anzeigen
|
||||
📄 Ergebnis anzeigen
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -50,6 +57,51 @@ else
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@* Anhänge-Sektion *@
|
||||
@if (Model.Invoice.Attachments.Any())
|
||||
{
|
||||
<h4 class="mt-4">📎 Anhänge (@Model.Invoice.Attachments.Count)</h4>
|
||||
<div class="list-group">
|
||||
@foreach (var attachment in Model.Invoice.Attachments)
|
||||
{
|
||||
var icon = attachment.IsZugferdXml ? "📋" : "📄";
|
||||
var extension = System.IO.Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
|
||||
icon = extension switch
|
||||
{
|
||||
".xml" => "📋",
|
||||
".pdf" => "📄",
|
||||
".jpg" or ".jpeg" or ".png" or ".gif" => "🖼️",
|
||||
".txt" => "📝",
|
||||
_ => "📎"
|
||||
};
|
||||
|
||||
<a href="/Invoices/ViewAttachment?filePath=@Uri.EscapeDataString(attachment.SavedFilePath)"
|
||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||
target="_blank">
|
||||
<div>
|
||||
<span class="me-2">@icon</span>
|
||||
<strong>@attachment.OriginalFileName</strong>
|
||||
@if (attachment.IsZugferdXml)
|
||||
{
|
||||
<span class="badge bg-success ms-2">ZUGFeRD-XML</span>
|
||||
}
|
||||
<br />
|
||||
<small class="text-muted">
|
||||
Größe: @FormatFileSize(attachment.FileSizeBytes) ·
|
||||
Extrahiert: @attachment.ExtractedAt.ToString("dd.MM.yyyy HH:mm")
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-primary">Öffnen</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h4 class="mt-4">📎 Anhänge</h4>
|
||||
<div class="alert alert-info">Keine Anhänge extrahiert.</div>
|
||||
}
|
||||
|
||||
@(Html.DevExtreme().Popup()
|
||||
.ID("pdf-viewer-popup")
|
||||
.Title("PDF Viewer")
|
||||
|
||||
@@ -12,7 +12,9 @@ public class DetailsModel(AppDbContext db) : PageModel
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
Invoice = await db.ZugferdInvoices.FirstOrDefaultAsync(i => i.Id == id);
|
||||
Invoice = await db.ZugferdInvoices
|
||||
.Include(i => i.Attachments)
|
||||
.FirstOrDefaultAsync(i => i.Id == id);
|
||||
|
||||
if (Invoice is null)
|
||||
return NotFound();
|
||||
|
||||
@@ -75,6 +75,29 @@ public class UploadModel(
|
||||
ImportedInvoice.ResultFilePath = ResultFilePath;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 4. Attachments in DB speichern
|
||||
if (Result.HasAttachments)
|
||||
{
|
||||
foreach (var attachment in Result.Attachments)
|
||||
{
|
||||
var invoiceAttachment = new InvoiceAttachment
|
||||
{
|
||||
ZugferdInvoiceId = ImportedInvoice.Id,
|
||||
OriginalFileName = attachment.OriginalFileName,
|
||||
SavedFilePath = attachment.SavedFilePath,
|
||||
FileSizeBytes = attachment.FileSizeBytes,
|
||||
IsZugferdXml = attachment.IsZugferdXml,
|
||||
ExtractedAt = DateTime.UtcNow
|
||||
};
|
||||
db.InvoiceAttachments.Add(invoiceAttachment);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"Rechnung '{InvoiceNumber}': {Count} Anhang/Anhänge in DB gespeichert.",
|
||||
ImportedInvoice.InvoiceNumber, Result.Attachments.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
@page
|
||||
@using DXApp.TemplateKitProject.Services
|
||||
@model DXApp.TemplateKitProject.Pages.Invoices.ViewAttachmentModel
|
||||
@{
|
||||
ViewData["Title"] = $"Attachment: {Model.FileName}";
|
||||
}
|
||||
|
||||
<h2>?? Attachment: @Model.FileName</h2>
|
||||
|
||||
<div class="mb-3">
|
||||
<a href="/Invoices/Details" class="btn btn-secondary">? Zurück</a>
|
||||
<a href="?handler=Download&filePath=@Request.Query["filePath"]"
|
||||
class="btn btn-primary">
|
||||
?? Download
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@switch (Model.ViewerType)
|
||||
{
|
||||
case AttachmentViewerType.Pdf:
|
||||
<div class="alert alert-info">
|
||||
PDF-Dateien können Sie über die Result-PDF-Funktion anzeigen.
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AttachmentViewerType.Xml:
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<strong>XML-Inhalt</strong> (ZUGFeRD/Factur-X)
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="xml-viewer" style="display:none;">@Model.TextContent</textarea>
|
||||
<div id="xml-rendered"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/monokai.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
|
||||
<script>
|
||||
var editor = CodeMirror(document.getElementById('xml-rendered'), {
|
||||
value: document.getElementById('xml-viewer').value,
|
||||
mode: 'xml',
|
||||
lineNumbers: true,
|
||||
readOnly: true,
|
||||
theme: 'monokai',
|
||||
lineWrapping: true
|
||||
});
|
||||
</script>
|
||||
break;
|
||||
|
||||
case AttachmentViewerType.Text:
|
||||
<div class="card">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<strong>Text-Inhalt</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; max-height: 600px; overflow: auto;">@Model.TextContent</pre>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AttachmentViewerType.Image:
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<img src="?handler=Download&filePath=@Request.Query["filePath"]"
|
||||
alt="@Model.FileName"
|
||||
class="img-fluid"
|
||||
style="max-height: 800px;">
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AttachmentViewerType.Word:
|
||||
<div class="alert alert-warning">
|
||||
<strong>Word-Dokumente können nicht direkt angezeigt werden.</strong>
|
||||
<p>Bitte laden Sie die Datei herunter oder implementieren Sie eine Konvertierung zu PDF.</p>
|
||||
</div>
|
||||
break;
|
||||
|
||||
default:
|
||||
<div class="alert alert-warning">
|
||||
<strong>Dieser Dateityp kann nicht angezeigt werden.</strong>
|
||||
<p>Bitte laden Sie die Datei herunter.</p>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using DXApp.TemplateKitProject.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using System.Text;
|
||||
|
||||
namespace DXApp.TemplateKitProject.Pages.Invoices;
|
||||
|
||||
public class ViewAttachmentModel(AttachmentViewerService viewerService) : PageModel
|
||||
{
|
||||
public string FileName { get; private set; } = string.Empty;
|
||||
public AttachmentViewerType ViewerType { get; private set; }
|
||||
public string? TextContent { get; private set; }
|
||||
|
||||
public IActionResult OnGet(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath))
|
||||
return NotFound();
|
||||
|
||||
FileName = Path.GetFileName(filePath);
|
||||
ViewerType = viewerService.DetermineViewerType(FileName);
|
||||
|
||||
// Für Text/XML: Inhalt laden
|
||||
if (ViewerType == AttachmentViewerType.Xml || ViewerType == AttachmentViewerType.Text)
|
||||
{
|
||||
TextContent = System.IO.File.ReadAllText(filePath, Encoding.UTF8);
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public IActionResult OnGetDownload(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath))
|
||||
return NotFound();
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var mimeType = viewerService.GetMimeType(fileName);
|
||||
var bytes = System.IO.File.ReadAllBytes(filePath);
|
||||
|
||||
return File(bytes, mimeType, fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user