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:
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user