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:
@ -31,6 +31,8 @@ anyhow.workspace = true
|
||||
toml.workspace = true
|
||||
sqlx.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
|
||||
# anderen Plattformen (z. B. Mac für den Kompiliertest) ausgeblendet.
|
||||
|
||||
@ -39,6 +39,10 @@ pub fn router() -> Router<AppState> {
|
||||
get(positions_modified),
|
||||
)
|
||||
.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/reviews", get(list_reviews))
|
||||
.route("/admin/reviews/{delivery_id}/resolve", post(resolve_review))
|
||||
|
||||
373
crates/api/src/routes/belege_view.rs
Normal file
373
crates/api/src/routes/belege_view.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
pub mod accounts;
|
||||
pub mod admin;
|
||||
pub mod attachments;
|
||||
pub mod belege_view;
|
||||
pub mod cars;
|
||||
pub mod deliveries;
|
||||
pub mod dev;
|
||||
|
||||
220
crates/api/templates/delivery_details.html
Normal file
220
crates/api/templates/delivery_details.html
Normal 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>
|
||||
Reference in New Issue
Block a user