feat(admin): GET /admin/belege/{belegnummer}/html - HTML-Detailansicht

Rendert dieselben Daten wie der JSON-Detail-Endpunkt als schoen kategorisierte
HTML-Seite (askama, compile-time-Template). Der Handler destilliert das
DeliveryDetails-Aggregat in ein flaches View-Modell (Artikel-/Lagernamen
aufgeloest, Euro/Datum formatiert, deutsche Status-/Rollen-Labels); die
Template-Datei bleibt frei von Domain-Logik. Kategorien: Uebersicht, Kunde,
Lieferadresse, Positionen (Tabelle, veraenderte Zeilen hervorgehoben),
Geld-Gutschrift, Dienstleistungen, Notizen, Kontakte. Nutzt den bestehenden
get_delivery_details-Use-Case; admin-key-geschuetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-07-10 00:11:05 +02:00
parent af034250e7
commit 02aa216bc6
6 changed files with 680 additions and 1 deletions

81
Cargo.lock generated
View File

@ -91,6 +91,50 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "askama"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
dependencies = [
"askama_derive",
"askama_escape",
"humansize",
"num-traits",
"percent-encoding",
]
[[package]]
name = "askama_derive"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
dependencies = [
"askama_parser",
"basic-toml",
"mime",
"mime_guess",
"proc-macro2",
"quote",
"serde",
"syn",
]
[[package]]
name = "askama_escape"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
[[package]]
name = "askama_parser"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"nom 7.1.3",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@ -165,7 +209,7 @@ dependencies = [
"anyhow", "anyhow",
"arrayvec", "arrayvec",
"log", "log",
"nom", "nom 8.0.0",
"num-rational", "num-rational",
"v_frame", "v_frame",
] ]
@ -250,6 +294,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.3" version = "0.10.3"
@ -962,6 +1015,7 @@ name = "holzleitner-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama",
"axum", "axum",
"chrono", "chrono",
"holzleitner-application", "holzleitner-application",
@ -1087,6 +1141,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@ -1594,6 +1657,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -1657,6 +1726,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"

View File

@ -31,6 +31,8 @@ anyhow.workspace = true
toml.workspace = true toml.workspace = true
sqlx.workspace = true sqlx.workspace = true
tokio-cron-scheduler.workspace = true tokio-cron-scheduler.workspace = true
# Compile-time HTML-Templating für die Admin-Detailansicht (nur hier genutzt).
askama = "0.12"
# Windows-Dienst-Integration (SCM). Nur unter Windows kompiliert; auf # Windows-Dienst-Integration (SCM). Nur unter Windows kompiliert; auf
# anderen Plattformen (z. B. Mac für den Kompiliertest) ausgeblendet. # anderen Plattformen (z. B. Mac für den Kompiliertest) ausgeblendet.

View File

@ -39,6 +39,10 @@ pub fn router() -> Router<AppState> {
get(positions_modified), get(positions_modified),
) )
.route("/admin/belege/{belegnummer}", get(belege_details)) .route("/admin/belege/{belegnummer}", get(belege_details))
.route(
"/admin/belege/{belegnummer}/html",
get(super::belege_view::belege_details_html),
)
.route("/admin/mark-mail-sent", post(mark_mail_sent)) .route("/admin/mark-mail-sent", post(mark_mail_sent))
.route("/admin/reviews", get(list_reviews)) .route("/admin/reviews", get(list_reviews))
.route("/admin/reviews/{delivery_id}/resolve", post(resolve_review)) .route("/admin/reviews/{delivery_id}/resolve", post(resolve_review))

View File

@ -0,0 +1,373 @@
//! Admin-HTML-Ansicht einer Lieferung (`GET /admin/belege/{belegnummer}/html`).
//!
//! Rendert dieselben Daten wie der JSON-Detail-Endpunkt, aber als schön
//! kategorisierte HTML-Seite (askama, compile-time-Template). Der Handler
//! destilliert das `DeliveryDetails`-Aggregat in ein flaches, anzeige-fertiges
//! View-Modell — die Template-Datei bleibt frei von Domain-/Lookup-Logik.
use askama::Template;
use axum::extract::{Path, State};
use axum::response::Html;
use holzleitner_application::dto::DeliveryDetails;
use holzleitner_application::error::ApplicationError;
use holzleitner_domain::{
Address, ContactKind, ContactRole, DeliveryState, ScanStatus,
};
use crate::error::ApiError;
use crate::state::AppState;
/// `GET /admin/belege/{belegnummer}/html` — Detailansicht als HTML.
pub async fn belege_details_html(
State(state): State<AppState>,
Path(belegnummer): Path<String>,
) -> Result<Html<String>, ApiError> {
tracing::info!(%belegnummer, "admin.belege_details_html");
let details = state.get_delivery_details.execute(&belegnummer).await?;
let page = build_page(details);
let html = page.render().map_err(|e| {
ApiError(ApplicationError::Unexpected(format!(
"template-render fehlgeschlagen: {e}"
)))
})?;
Ok(Html(html))
}
// ===== View-Modell =========================================================
#[derive(Template)]
#[template(path = "delivery_details.html")]
struct DeliveryDetailsPage {
belegnummer: String,
state_label: String,
state_class: String,
positions_modified: bool,
tour_date: String,
sort_order: i32,
desired_time: String,
special_agreements: String,
prepaid: String,
total: String,
customer_name: String,
erp_customer_id: i64,
customer_address_lines: Vec<String>,
delivery_address_lines: Vec<String>,
customer_contacts: Vec<ContactVm>,
items: Vec<ItemVm>,
has_credit: bool,
credit_amount: String,
credit_reason: String,
services: Vec<ServiceVm>,
notes: Vec<NoteVm>,
contacts: Vec<ContactSourceVm>,
generated_at: String,
}
struct ItemVm {
article_number: String,
article_name: String,
warehouse: String,
required: i32,
scanned: i32,
credited: i32,
delivered: i32,
unit_price: String,
line_total: String,
status_label: String,
status_class: String,
held_reason: String,
modified: bool,
}
struct ContactVm {
name: String,
phone: String,
email: String,
}
struct ServiceVm {
name: String,
value: String,
}
struct NoteVm {
text: String,
author: i64,
created_at: String,
credit_note: bool,
image: bool,
}
struct ChannelVm {
kind: String,
value: String,
}
struct ContactSourceVm {
role: String,
name: String,
channels: Vec<ChannelVm>,
}
// ===== Assembly ============================================================
fn build_page(d: DeliveryDetails) -> DeliveryDetailsPage {
let delivery = &d.delivery.delivery;
// Positionen: Artikel-/Lagernamen auflösen, Mengen/Preise formatieren.
let mut total = 0.0_f64;
let items: Vec<ItemVm> = d
.delivery
.items
.iter()
.map(|it| {
let delivered = (it.required_quantity - it.scan_state.credited_quantity).max(0);
let line_total = it.unit_price * delivered as f64;
total += line_total;
let article = d.articles.iter().find(|a| a.id == it.article_id);
let warehouse = d.warehouses.iter().find(|w| w.id == it.warehouse_id);
ItemVm {
article_number: article
.map(|a| a.article_number.clone())
.unwrap_or_default(),
article_name: article.map(|a| a.name.clone()).unwrap_or_default(),
warehouse: warehouse.map(|w| w.name.clone()).unwrap_or_default(),
required: it.required_quantity,
scanned: it.scan_state.scanned_quantity,
credited: it.scan_state.credited_quantity,
delivered,
unit_price: fmt_euro(it.unit_price),
line_total: fmt_euro(line_total),
status_label: scan_status_label(it.scan_state.status).to_string(),
status_class: scan_status_class(it.scan_state.status).to_string(),
held_reason: it.scan_state.held_reason.clone().unwrap_or_default(),
modified: it.scan_state.credited_quantity > 0,
}
})
.collect();
// Dienstleistungen: gesetzte Werte mit ihrem Stammdaten-Namen anzeigen.
let services: Vec<ServiceVm> = d
.delivery_services
.iter()
.map(|v| {
let def = d.services.iter().find(|s| s.id == v.service_id);
let name = def.map(|s| s.name.clone()).unwrap_or_default();
let value = if let Some(b) = v.bool_value {
if b { "Ja".to_string() } else { "Nein".to_string() }
} else if let Some(n) = v.numeric_value {
n.to_string()
} else {
"".to_string()
};
ServiceVm { name, value }
})
.collect();
let notes: Vec<NoteVm> = d
.notes
.iter()
.map(|n| NoteVm {
text: n.text.clone().unwrap_or_default(),
author: n.author_personalnummer,
created_at: fmt_datetime(n.created_at),
credit_note: n.is_amount_credit_note || n.credit_delivery_item_id.is_some(),
image: n.image_attachment.is_some(),
})
.collect();
// Kontaktquellen mit ihren Kanälen gruppieren.
let contacts: Vec<ContactSourceVm> = d
.contact_sources
.iter()
.map(|s| {
let name = [
s.anrede.as_deref(),
s.titel.as_deref(),
s.name1.as_deref(),
s.name2.as_deref(),
s.name3.as_deref(),
]
.into_iter()
.flatten()
.filter(|p| !p.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
let channels = d
.contact_channels
.iter()
.filter(|c| c.source_id == s.id)
.map(|c| ChannelVm {
kind: contact_kind_label(c.kind).to_string(),
value: c.value.clone(),
})
.collect();
ContactSourceVm {
role: contact_role_label(s.role).to_string(),
name,
channels,
}
})
.collect();
let customer_contacts: Vec<ContactVm> = d
.customer_contacts
.iter()
.map(|c| ContactVm {
name: c.name.clone(),
phone: c.phone.clone().unwrap_or_default(),
email: c.email.clone().unwrap_or_default(),
})
.collect();
let (has_credit, credit_amount, credit_reason) = match &d.credit {
Some(c) => (true, fmt_euro(c.amount_cents as f64 / 100.0), c.reason.clone()),
None => (false, String::new(), String::new()),
};
DeliveryDetailsPage {
belegnummer: delivery.erp_belegnummer.clone(),
state_label: state_label(delivery.state).to_string(),
state_class: state_class(delivery.state).to_string(),
positions_modified: items_modified(&items) || has_credit,
tour_date: fmt_date(d.tour.date),
sort_order: d.delivery.sort_order,
desired_time: dash(delivery.desired_time.clone()),
special_agreements: dash(delivery.special_agreements.clone()),
prepaid: fmt_euro(delivery.prepaid_amount),
total: fmt_euro(total),
customer_name: d.customer.as_ref().map(|c| c.name.clone()).unwrap_or_default(),
erp_customer_id: d.customer.as_ref().map(|c| c.erp_customer_id).unwrap_or(0),
customer_address_lines: d
.customer
.as_ref()
.map(|c| address_lines(&c.address))
.unwrap_or_default(),
delivery_address_lines: address_lines(&delivery.delivery_address_snapshot),
customer_contacts,
items,
has_credit,
credit_amount,
credit_reason,
services,
notes,
contacts,
generated_at: fmt_datetime(chrono::Utc::now()),
}
}
fn items_modified(items: &[ItemVm]) -> bool {
items.iter().any(|i| i.modified)
}
// ===== Formatier-/Label-Helfer =============================================
/// Deutsche Euro-Formatierung: `1099.0` → `1.099,00 €`.
fn fmt_euro(value: f64) -> String {
let cents = (value * 100.0).round() as i64;
let neg = cents < 0;
let cents = cents.abs();
let euros = cents / 100;
let rest = cents % 100;
// Tausenderpunkte in die Euro-Zahl.
let s = euros.to_string();
let mut grouped = String::new();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
grouped.push('.');
}
grouped.push(ch);
}
format!("{}{},{:02}", if neg { "-" } else { "" }, grouped, rest)
}
fn dash(v: Option<String>) -> String {
match v {
Some(s) if !s.trim().is_empty() => s,
_ => "".to_string(),
}
}
fn address_lines(a: &Address) -> Vec<String> {
let mut lines = Vec::new();
let street = format!("{} {}", a.street, a.house_number);
let street = street.trim().to_string();
if !street.is_empty() {
lines.push(street);
}
let city = format!("{} {}", a.postal_code, a.city);
let city = city.trim().to_string();
if !city.is_empty() {
lines.push(city);
}
if !a.country.trim().is_empty() {
lines.push(a.country.clone());
}
lines
}
fn fmt_date(d: chrono::NaiveDate) -> String {
d.format("%d.%m.%Y").to_string()
}
fn fmt_datetime(dt: chrono::DateTime<chrono::Utc>) -> String {
dt.with_timezone(&chrono::Local)
.format("%d.%m.%Y %H:%M")
.to_string()
}
fn state_label(s: DeliveryState) -> &'static str {
match s {
DeliveryState::Active => "Aktiv",
DeliveryState::Held => "Gehalten",
DeliveryState::Canceled => "Storniert",
DeliveryState::Completed => "Abgeschlossen",
}
}
fn state_class(s: DeliveryState) -> &'static str {
match s {
DeliveryState::Active => "active",
DeliveryState::Held => "held",
DeliveryState::Canceled => "canceled",
DeliveryState::Completed => "completed",
}
}
fn scan_status_label(s: ScanStatus) -> &'static str {
match s {
ScanStatus::InProgress => "In Arbeit",
ScanStatus::Done => "Erledigt",
ScanStatus::Held => "Zurückgestellt",
ScanStatus::Removed => "Entfernt",
}
}
fn scan_status_class(s: ScanStatus) -> &'static str {
match s {
ScanStatus::InProgress => "in_progress",
ScanStatus::Done => "done",
ScanStatus::Held => "held",
ScanStatus::Removed => "removed",
}
}
fn contact_role_label(r: ContactRole) -> &'static str {
match r {
ContactRole::Header => "Belegadresse",
ContactRole::Delivery => "Lieferadresse",
ContactRole::Billing => "Rechnungsadresse",
ContactRole::ContactPerson => "Ansprechpartner",
ContactRole::CustomerMaster => "Kundenstamm",
}
}
fn contact_kind_label(k: ContactKind) -> &'static str {
match k {
ContactKind::Phone => "Telefon",
ContactKind::Mobile => "Mobil",
ContactKind::Email => "E-Mail",
ContactKind::Web => "Web",
}
}

View File

@ -4,6 +4,7 @@
pub mod accounts; pub mod accounts;
pub mod admin; pub mod admin;
pub mod attachments; pub mod attachments;
pub mod belege_view;
pub mod cars; pub mod cars;
pub mod deliveries; pub mod deliveries;
pub mod dev; pub mod dev;

View File

@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Beleg {{ belegnummer }} Lieferdetails</title>
<style>
:root {
--bg: #f4f5f7; --card: #ffffff; --ink: #1f2733; --muted: #6b7684;
--line: #e4e8ee; --accent: #2f6fed; --ok: #1f9d63; --warn: #d97706;
--danger: #d83a3a; --chip: #eef2fb;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 24px;
}
.wrap { max-width: 1040px; margin: 0 auto; }
header.page {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
margin-bottom: 20px;
}
header.page h1 { font-size: 26px; margin: 0; letter-spacing: .3px; }
header.page .sub { color: var(--muted); font-size: 14px; margin-top: 2px; }
.badges { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
.badge {
display: inline-block; padding: 4px 12px; border-radius: 999px;
font-size: 13px; font-weight: 600; background: var(--chip); color: var(--accent);
}
.badge.state-completed { background: #e5f6ec; color: var(--ok); }
.badge.state-active { background: #e8f0fe; color: var(--accent); }
.badge.state-held { background: #fef3e2; color: var(--warn); }
.badge.state-canceled { background: #fce8e8; color: var(--danger); }
.badge.modified { background: #fef3e2; color: var(--warn); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px;
}
.card.full { grid-column: 1 / -1; }
.card h2 {
font-size: 13px; text-transform: uppercase; letter-spacing: .6px;
color: var(--muted); margin: 0 0 12px; font-weight: 700;
}
dl { display: grid; grid-template-columns: minmax(120px, 40%) 1fr; gap: 6px 14px; margin: 0; }
dt { color: var(--muted); }
dd { margin: 0; font-variant-numeric: tabular-nums; }
.addr { line-height: 1.45; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tr.modified td { background: #fff8ec; }
tfoot td { font-weight: 700; border-top: 2px solid var(--line); border-bottom: none; }
.pill { display: inline-block; padding: 1px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; }
.pill.done { background: #e5f6ec; color: var(--ok); }
.pill.removed, .pill.held { background: #fef3e2; color: var(--warn); }
.pill.in_progress { background: #eef2fb; color: var(--accent); }
.note { border-left: 3px solid var(--line); padding: 4px 0 4px 12px; margin: 10px 0; }
.note .meta { color: var(--muted); font-size: 13px; margin-top: 2px; }
.empty { color: var(--muted); font-style: italic; }
.contact { padding: 8px 0; border-bottom: 1px solid var(--line); }
.contact:last-child { border-bottom: none; }
.contact .role { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
.chan { color: var(--muted); font-size: 14px; }
footer.page { color: var(--muted); font-size: 12px; margin-top: 22px; text-align: right; }
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } body { padding: 14px; } }
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<div>
<h1>Beleg {{ belegnummer }}</h1>
<div class="sub">{{ customer_name }} · Tour {{ tour_date }}</div>
</div>
<div class="badges">
<span class="badge state-{{ state_class }}">{{ state_label }}</span>
{% if positions_modified %}<span class="badge modified">Positionen verändert</span>{% endif %}
</div>
</header>
<div class="grid">
<section class="card">
<h2>Übersicht</h2>
<dl>
<dt>Status</dt><dd>{{ state_label }}</dd>
<dt>Tourdatum</dt><dd>{{ tour_date }}</dd>
<dt>Position in Tour</dt><dd>{{ sort_order }}</dd>
<dt>Wunschzeit</dt><dd>{{ desired_time }}</dd>
<dt>Sondervereinbarung</dt><dd>{{ special_agreements }}</dd>
<dt>Vorausbezahlt</dt><dd>{{ prepaid }}</dd>
<dt>Warenwert (geliefert)</dt><dd>{{ total }}</dd>
</dl>
</section>
<section class="card">
<h2>Kunde</h2>
<dl>
<dt>Name</dt><dd>{{ customer_name }}</dd>
<dt>ERP-Kundennr.</dt><dd>{{ erp_customer_id }}</dd>
</dl>
<div class="addr" style="margin-top:10px">
{% for line in customer_address_lines %}{{ line }}<br>{% endfor %}
</div>
{% if !customer_contacts.is_empty() %}
<div style="margin-top:12px">
{% for c in customer_contacts %}
<div class="contact">
<strong>{{ c.name }}</strong>
{% if !c.phone.is_empty() %}<div class="chan">☎ {{ c.phone }}</div>{% endif %}
{% if !c.email.is_empty() %}<div class="chan">✉ {{ c.email }}</div>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</section>
<section class="card">
<h2>Lieferadresse</h2>
<div class="addr">
{% for line in delivery_address_lines %}{{ line }}<br>{% endfor %}
</div>
</section>
<section class="card">
<h2>Geld-Gutschrift</h2>
{% if has_credit %}
<dl>
<dt>Betrag</dt><dd>{{ credit_amount }}</dd>
<dt>Grund</dt><dd>{{ credit_reason }}</dd>
</dl>
{% else %}
<div class="empty">Keine Geld-Gutschrift</div>
{% endif %}
</section>
<section class="card full">
<h2>Positionen</h2>
<table>
<thead>
<tr>
<th>Art.-Nr.</th><th>Bezeichnung</th><th>Lager</th>
<th class="num">Soll</th><th class="num">Gescannt</th>
<th class="num">Gutschr.</th><th class="num">Geliefert</th>
<th class="num">Einzelpreis</th><th class="num">Summe</th><th>Status</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr class="{% if it.modified %}modified{% endif %}">
<td>{{ it.article_number }}</td>
<td>{{ it.article_name }}{% if !it.held_reason.is_empty() %}<br><span class="chan">{{ it.held_reason }}</span>{% endif %}</td>
<td>{{ it.warehouse }}</td>
<td class="num">{{ it.required }}</td>
<td class="num">{{ it.scanned }}</td>
<td class="num">{{ it.credited }}</td>
<td class="num">{{ it.delivered }}</td>
<td class="num">{{ it.unit_price }}</td>
<td class="num">{{ it.line_total }}</td>
<td><span class="pill {{ it.status_class }}">{{ it.status_label }}</span></td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td colspan="8" class="num">Warenwert (geliefert)</td><td class="num">{{ total }}</td><td></td></tr>
</tfoot>
</table>
</section>
<section class="card">
<h2>Dienstleistungen</h2>
{% if services.is_empty() %}
<div class="empty">Keine Dienstleistungen gesetzt</div>
{% else %}
<dl>
{% for s in services %}<dt>{{ s.name }}</dt><dd>{{ s.value }}</dd>{% endfor %}
</dl>
{% endif %}
</section>
<section class="card">
<h2>Notizen</h2>
{% if notes.is_empty() %}
<div class="empty">Keine Notizen</div>
{% else %}
{% for n in notes %}
<div class="note">
<div>{{ n.text }}</div>
<div class="meta">
#{{ n.author }} · {{ n.created_at }}
{% if n.credit_note %} · Gutschrift-Notiz{% endif %}
{% if n.image %} · 📎 Bild{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
</section>
<section class="card full">
<h2>Kontakte</h2>
{% if contacts.is_empty() %}
<div class="empty">Keine Kontaktquellen</div>
{% else %}
{% for c in contacts %}
<div class="contact">
<div class="role">{{ c.role }}</div>
<strong>{{ c.name }}</strong>
{% for ch in c.channels %}<div class="chan">{{ ch.kind }}: {{ ch.value }}</div>{% endfor %}
</div>
{% endfor %}
{% endif %}
</section>
</div>
<footer class="page">Erzeugt am {{ generated_at }} · Holzleitner Backend</footer>
</div>
</body>
</html>