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:
81
Cargo.lock
generated
81
Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
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