Two things set the Windows program apart from any web page, and both appear as break points in the list from lesson M1.2. B7: the invoice printing draws onto a printer with PrintDocument and Graphics.DrawString,
and there is no such printer in the browser. B8: the export writes a file into a folder on a network drive that a server in Azure cannot see. Both are seams N6 and N7 from lesson M1.4. Both are the moment when modernizers like to
"rebuild" something instead of asking what it was there for. Ms. Aydin does not want a PrintDocument page; she wants an invoice she can hand to the customer. Accounting does not want a network drive; it wants, every morning, the file its program reads.
This lesson replaces the means and keeps the purpose.
You know background work for long jobs from K2, module 2.8 (Background and integration); this lesson covers what makes it special in the Order Book.
Printing: browser or PDF from the server
The decision comes first because it determines everything else. There are three ways to an invoice on paper or as a file:
| Way | Who produces the document | What it is suited for | What it costs |
|---|---|---|---|
| The browser prints the page (the page's print layout) | the browser on the user's machine | viewing the invoice, printing it, having it saved as PDF | the page looks slightly different on every machine; no document on the server |
| PDF on the server | the server, with a library or a service | the invoice by email to the customer, filing in the archive, retention | one more dependency, fonts and page breaks are your job |
Keep using PrintDocument in the Windows program |
the Windows program | nothing new; it stays as long as the program stays | stays tied to Windows and dies with the program |
For the Order Book: the display and the printing go through the page, and that is the way of this lesson. For an invoice as a file that the customer receives by email or that has to go into an archive, you need the second row, and the choice of library is a decision for an ADR (lesson M1.1), with the questions the table names: license, fonts, maintenance, data protection if a service sees the content. The Microsoft documentation we read for this course names no library for this, so none is named here either. What matters is that the invoice's data comes from the API, however it is printed later: then, if you change the method, only the part that draws changes.
Extending the contract with the invoice
Everything InvoicePrint used to draw is now delivered by the API: invoice number, customer address, title, the line items from the calculator, and the VAT rate. This is a compatible change according to the table from lesson M4.1: a new path,
GET /api/v1/orders/{id}/invoice, and a new type in the contract. The path test turns red until you enter the path: that is intended.
// Everything that is on an invoice. The server assembles it; the page and the Windows printing only display it.
public sealed record InvoiceDto(
string InvoiceNumber,
string Customer,
string Street,
string PostalCode,
string City,
string Title,
InvoiceAmount Amount,
decimal VatRate);
The endpoint exists only for invoiced orders: an invoice without a number would be a draft, which does not exist according to lesson M4.1, and the API says so with 409.
group.MapGet("/{id:int}/invoice", ReadInvoice)
.WithName("ReadInvoice")
.WithSummary("Everything that is on the invoice; only for invoiced orders.")
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict);
private static async Task<Results<Ok<InvoiceDto>, ProblemHttpResult>> ReadInvoice(
int id,
OrderBookContext db,
IOptions<PricesOptions> prices,
CancellationToken ct)
{
Order? order = await db.Orders.AsNoTracking().Include(o => o.Customer).SingleOrDefaultAsync(o => o.OrderId == id, ct);
if (order is null)
{
return NotFound(id);
}
if (order.Status != OrderStatus.Invoiced || order.InvoiceNumber is null)
{
return TypedResults.Problem(title: "The order has not been invoiced yet.", statusCode: StatusCodes.Status409Conflict);
}
PricesOptions p = prices.Value;
return TypedResults.Ok(new InvoiceDto(
order.InvoiceNumber,
order.Customer.Name,
order.Customer.Street,
order.Customer.PostalCode,
order.Customer.City,
order.Title,
p.Calculator().Calculate(new Service(order.Kind, order.Hours, order.Material)),
p.Vat));
}
The client gets one more method (ReadInvoiceAsync in IOrderApi and in OrderApiClient, the same form as the others), and the fake in the component tests from lesson M4.3 follows:
Task<ApiResult<InvoiceDto>> ReadInvoiceAsync(int orderId, CancellationToken ct = default);
public Task<ApiResult<InvoiceDto>> ReadInvoiceAsync(int orderId, CancellationToken ct = default) =>
SendAsync<InvoiceDto>(() => http.GetAsync($"api/v1/orders/{orderId}/invoice", ct), ct);
The page
The invoice is a component of the library, like the list; so it runs in the browser and, through the bridge from lesson M4.4, in the Windows program:
@inject IOrderApi Api
@inject IJSRuntime Js
<div class="invoice-view">
@if (_loading)
{
<p role="status">Loading ...</p>
}
else if (_invoice is null)
{
<p role="alert" class="error">@(_error is null ? "The invoice could not be loaded." : ApiMessages.For(_error))</p>
}
else
{
<p class="no-print"><button type="button" @onclick="PrintAsync">Print</button></p>
<article class="invoice">
<h1>Invoice @_invoice.InvoiceNumber</h1>
<p>
@_invoice.Customer<br />
@_invoice.Street<br />
@_invoice.PostalCode @_invoice.City
</p>
<p>@_invoice.Title</p>
<table>
<caption>Amounts in euros</caption>
<tbody>
<tr><th scope="row">Labor</th><td class="amount">@Money(_invoice.Amount.Labor)</td></tr>
<tr><th scope="row">Call-out</th><td class="amount">@Money(_invoice.Amount.CallOut)</td></tr>
<tr><th scope="row">Material</th><td class="amount">@Money(_invoice.Amount.Material)</td></tr>
<tr><th scope="row">Net</th><td class="amount">@Money(_invoice.Amount.Net)</td></tr>
<tr><th scope="row">Gross (@((_invoice.VatRate * 100).ToString("0.##"))% VAT)</th><td class="amount">@Money(_invoice.Amount.Gross)</td></tr>
</tbody>
</table>
</article>
}
</div>
<style>
.invoice .amount { text-align: right; }
@@media print {
.no-print { display: none; }
}
</style>
@code {
[Parameter, EditorRequired]
public int OrderId { get; set; }
private InvoiceDto? _invoice;
private ApiError? _error;
private bool _loading = true;
protected override async Task OnParametersSetAsync()
{
_loading = true;
ApiResult<InvoiceDto> result = await Api.ReadInvoiceAsync(OrderId);
_loading = false;
(_invoice, _error) = (result.IsOk ? result.Value : null, result.Error);
}
// window.print exists in every browser and in every WebView; the print dialog belongs to the host.
private async Task PrintAsync() => await Js.InvokeVoidAsync("window.print");
private static string Money(decimal amount) => amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE"));
}
Three decisions:
- The print layout is in the page's style. The
@media printblock hides everything that does not belong on the paper (here the button). In Razor files the@of a style rule is doubled (@@media) because@otherwise starts Razor code. The layout belongs to the page, not to the printer: the same page is printed in the browser or saved as PDF. - Printing is a call to
window.print. The component usesIJSRuntime, the shared abstraction that, according to Microsoft's documentation, is available in all hosting models and on all platforms. The print dialog belongs to the host: browser or WebView. - The amounts are in German format and without a currency symbol in the cell. The table's caption says "Amounts in euros". The numbers are exactly those of the API; the page calculates nothing.
The web app gets a page for this that shows only the component:
@page "/orders/{OrderId:int}/invoice"
@rendermode InteractiveServer
<PageTitle>Invoice for order @OrderId</PageTitle>
<InvoiceView OrderId="OrderId" />
@code {
[Parameter]
public int OrderId { get; set; }
}