feat: enhance forms with decimal formatting and validation

- Updated CustomersPage.vue to use decimalString for standard discount percent.
- Enhanced IncomingInvoicesPage.vue to format item quantities, unit prices, and tax rates using decimalString.
- Improved ItemsPage.vue with new supplier price management and decimal formatting for prices.
- Modified OrganizationSetupPage.vue to use a dropdown for default tax rates and ensure numeric input for payment days.
- Updated OutgoingInvoicesPage.vue to apply decimal formatting for customer discounts and item details.
- Enhanced PriceImportsPage.vue to include additional fields in the import format.
- Improved PriceRulesPage.vue to use decimal input for markup percentages.
- Updated QuotesPage.vue to apply decimal formatting for customer discounts and item details.
- Enhanced SuppliersPage.vue to use decimal input for standard discount percent.
- Added a new SQL migration to set default unit for items to 'Stck'.
- Introduced format.ts for centralized decimal and currency formatting utilities.
This commit is contained in:
Torsten Schulz (local)
2026-06-03 09:25:10 +02:00
parent 0e539710c0
commit d5b6f39177
22 changed files with 1420 additions and 183 deletions

View File

@@ -585,6 +585,75 @@ fn number_or_pending(number: &str) -> &str {
}
}
fn unit_combo(ui: &mut egui::Ui, unit: &mut String) {
let selected_text = if unit.trim().is_empty() {
"Bitte wählen".to_string()
} else {
unit.clone()
};
egui::ComboBox::from_id_salt(ui.next_auto_id())
.selected_text(selected_text)
.show_ui(ui, |ui| {
ui.text_edit_singleline(unit);
ui.separator();
for option in ["Stck", "kg", "g", "L", "mg", "ml", "qm", "m", "cm", "mm"] {
ui.selectable_value(unit, option.to_string(), option);
}
});
}
fn tax_rate_combo(ui: &mut egui::Ui, tax_rate: &mut String) {
egui::ComboBox::from_id_salt(ui.next_auto_id())
.selected_text(format!("{} %", tax_rate.trim()))
.show_ui(ui, |ui| {
for option in ["0", "7", "19"] {
ui.selectable_value(tax_rate, option.to_string(), format!("{option} %"));
}
});
}
fn normalize_tax_rate(value: &str) -> String {
match value.trim().parse::<f64>() {
Ok(number) if (number - 0.0).abs() < f64::EPSILON => "0".to_string(),
Ok(number) if (number - 7.0).abs() < f64::EPSILON => "7".to_string(),
Ok(number) if (number - 19.0).abs() < f64::EPSILON => "19".to_string(),
_ => value.trim().to_string(),
}
}
fn format_euro(value: Option<&str>) -> String {
let Some(value) = value else {
return "-".to_string();
};
let normalized = if value.contains(',') {
value.replace('.', "").replace(',', ".")
} else {
value.to_string()
};
match normalized.trim().parse::<f64>() {
Ok(number) => format!("{} EUR", format_number_de(number, 2)),
Err(_) => value.to_string(),
}
}
fn format_number_de(number: f64, decimals: usize) -> String {
let raw = format!("{number:.decimals$}");
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, ""));
let mut grouped = String::new();
for (index, character) in integer.chars().rev().enumerate() {
if index > 0 && index % 3 == 0 {
grouped.push('.');
}
grouped.push(character);
}
let integer = grouped.chars().rev().collect::<String>();
if decimals == 0 {
integer
} else {
format!("{integer},{fraction}")
}
}
fn customer_combo(
ui: &mut egui::Ui,
selected_id: &mut String,
@@ -688,7 +757,21 @@ fn apply_quote_item_defaults(line: &mut QuoteItemForm, items: &[Item]) {
.clone()
.unwrap_or_else(|| "0".to_string());
line.original_unit_price = item.default_sales_price.clone();
line.tax_rate = item.tax_rate.clone();
line.tax_rate = normalize_tax_rate(&item.tax_rate);
}
}
fn apply_quote_customer_defaults(form: &mut QuoteForm, customers: &[Customer]) {
if let Some(customer) = customers.iter().find(|customer| customer.id == form.customer_id) {
form.customer_discount_percent = customer.standard_discount_percent.clone();
form.cash_discount_term_id = customer.cash_discount_term_id.clone();
}
}
fn apply_outgoing_invoice_customer_defaults(form: &mut OutgoingInvoiceForm, customers: &[Customer]) {
if let Some(customer) = customers.iter().find(|customer| customer.id == form.customer_id) {
form.customer_discount_percent = customer.standard_discount_percent.clone();
form.cash_discount_term_id = customer.cash_discount_term_id.clone();
}
}
@@ -721,7 +804,7 @@ fn invoice_items_editor(
.clone()
.unwrap_or_else(|| "0".to_string());
line.original_unit_price = item.default_sales_price.clone();
line.tax_rate = item.tax_rate.clone();
line.tax_rate = normalize_tax_rate(&item.tax_rate);
}
}
form_row(ui, "Beschreibung", |ui| {
@@ -741,7 +824,7 @@ fn invoice_items_editor(
ui.text_edit_singleline(&mut line.discount_percent);
ui.end_row();
ui.label("Steuer %");
ui.text_edit_singleline(&mut line.tax_rate);
tax_rate_combo(ui, &mut line.tax_rate);
ui.end_row();
});
if can_remove && ui.button("Entfernen").clicked() {
@@ -794,7 +877,7 @@ fn incoming_invoice_items_editor(
ui.text_edit_singleline(&mut line.unit_price);
ui.end_row();
ui.label("Steuer %");
ui.text_edit_singleline(&mut line.tax_rate);
tax_rate_combo(ui, &mut line.tax_rate);
ui.end_row();
});
if can_remove && ui.button("Entfernen").clicked() {
@@ -1006,6 +1089,7 @@ struct CompanyToolApp {
customer_list_search: String,
supplier_list_search: String,
item_list_search: String,
item_sort_mode: String,
activity_list_search: String,
quote_list_search: String,
outgoing_invoice_list_search: String,
@@ -1142,6 +1226,7 @@ impl CompanyToolApp {
customer_list_search: String::new(),
supplier_list_search: String::new(),
item_list_search: String::new(),
item_sort_mode: "name_desc".to_string(),
activity_list_search: String::new(),
quote_list_search: String::new(),
outgoing_invoice_list_search: String::new(),
@@ -1626,7 +1711,7 @@ impl CompanyToolApp {
},
AdminEvent::ActivityDeleted(result) => {
self.activities_status = result
.map(|_| "Aktivität storniert.".to_string())
.map(|_| "Aktivität deaktiviert.".to_string())
.unwrap_or_else(|message| message);
self.load_activities();
}
@@ -2638,6 +2723,7 @@ impl CompanyToolApp {
self.restore_window("items");
self.items_window_open = true;
self.load_items();
self.load_suppliers();
}
fn open_activities_window(&mut self) {
@@ -4091,8 +4177,25 @@ impl CompanyToolApp {
self.reserve_next_number("items");
}
ui.text_edit_singleline(&mut self.item_list_search);
egui::ComboBox::from_id_salt("item_sort_mode")
.selected_text(match self.item_sort_mode.as_str() {
"number_desc" => "Artikelnummer absteigend",
_ => "Artikelname absteigend",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.item_sort_mode,
"name_desc".to_string(),
"Artikelname absteigend",
);
ui.selectable_value(
&mut self.item_sort_mode,
"number_desc".to_string(),
"Artikelnummer absteigend",
);
});
ui.add_space(6.0);
let filtered_items: Vec<Item> = self
let mut filtered_items: Vec<Item> = self
.items
.iter()
.filter(|item| {
@@ -4104,6 +4207,18 @@ impl CompanyToolApp {
})
.cloned()
.collect();
if self.item_sort_mode == "number_desc" {
filtered_items.sort_by(|left, right| {
right
.item_number
.to_lowercase()
.cmp(&left.item_number.to_lowercase())
});
} else {
filtered_items.sort_by(|left, right| {
right.name.to_lowercase().cmp(&left.name.to_lowercase())
});
}
for item in filtered_items {
if ui
.selectable_label(
@@ -4125,14 +4240,17 @@ impl CompanyToolApp {
form_row(ui, "Artikelnummer", |ui| {
ui.label(number_or_pending(&self.item_form.item_number));
});
form_row(ui, "Hersteller-Code", |ui| {
ui.text_edit_singleline(&mut self.item_form.manufacturer_code);
});
form_row(ui, "Bezeichnung", |ui| {
ui.text_edit_singleline(&mut self.item_form.name);
});
form_row(ui, "Einheit", |ui| {
ui.text_edit_singleline(&mut self.item_form.unit);
unit_combo(ui, &mut self.item_form.unit);
});
form_row(ui, "Steuersatz %", |ui| {
ui.text_edit_singleline(&mut self.item_form.tax_rate);
tax_rate_combo(ui, &mut self.item_form.tax_rate);
});
form_row(ui, "Einkaufspreis", |ui| {
ui.text_edit_singleline(&mut self.item_form.default_purchase_price);
@@ -4140,6 +4258,46 @@ impl CompanyToolApp {
form_row(ui, "Verkaufspreis", |ui| {
ui.text_edit_singleline(&mut self.item_form.default_sales_price);
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.heading("Lieferantenpreise");
if ui.button("Lieferant hinzufügen").clicked() {
self.item_form
.supplier_prices
.push(ItemSupplierPriceForm::default());
}
});
let suppliers = self.suppliers.clone();
let mut remove_supplier_price = None;
for (index, supplier_price) in
self.item_form.supplier_prices.iter_mut().enumerate()
{
ui.separator();
form_row(ui, "Lieferant", |ui| {
supplier_combo(
ui,
&mut supplier_price.supplier_id,
&suppliers,
&mut self.supplier_lookup_search,
);
});
form_row(ui, "Externe Artikelnr.", |ui| {
ui.text_edit_singleline(&mut supplier_price.external_item_number);
});
form_row(ui, "Einkaufspreis", |ui| {
ui.text_edit_singleline(&mut supplier_price.purchase_price);
});
form_row(ui, "Währung", |ui| {
ui.text_edit_singleline(&mut supplier_price.currency);
});
ui.checkbox(&mut supplier_price.is_preferred, "Bevorzugt");
if ui.button("Lieferantenpreis entfernen").clicked() {
remove_supplier_price = Some(index);
}
}
if let Some(index) = remove_supplier_price {
self.item_form.supplier_prices.remove(index);
}
ui.horizontal(|ui| {
if ui.button("Speichern").clicked() {
self.save_item();
@@ -4166,12 +4324,9 @@ impl CompanyToolApp {
ui.label(format!("ID {}", &entry.id[..entry.id.len().min(8)]));
ui.label(format!(
"EK {}",
entry.purchase_price.as_deref().unwrap_or("-")
));
ui.label(format!(
"VK {}",
entry.sales_price.as_deref().unwrap_or("-")
format_euro(entry.purchase_price.as_deref())
));
ui.label(format!("VK {}", format_euro(entry.sales_price.as_deref())));
ui.label(&entry.source);
ui.label(format!(
"Item {}",
@@ -4237,6 +4392,8 @@ impl CompanyToolApp {
ui.label("Aktion");
ui.label("Artikelnummer");
ui.label("Name");
ui.label("Hersteller");
ui.label("Lieferant");
ui.label("Einheit");
ui.label("Steuer");
ui.label("EK");
@@ -4248,9 +4405,26 @@ impl CompanyToolApp {
ui.label(&row.action);
ui.label(&row.item_number);
ui.label(&row.name);
ui.label(row.manufacturer_code.as_deref().unwrap_or("-"));
ui.label(
row.supplier_number
.as_deref()
.map(|supplier| {
format!(
"{} / {}",
supplier,
row.supplier_item_number.as_deref().unwrap_or("-")
)
})
.unwrap_or_else(|| "-".to_string()),
);
ui.label(&row.unit);
ui.label(&row.tax_rate);
ui.label(row.purchase_price.as_deref().unwrap_or("-"));
ui.label(format!(
"{} {}",
row.purchase_price.as_deref().unwrap_or("-"),
row.currency.as_deref().unwrap_or("EUR")
));
ui.label(row.sales_price.as_deref().unwrap_or("-"));
ui.label(row.error.as_deref().unwrap_or("-"));
ui.end_row();
@@ -4624,12 +4798,16 @@ impl CompanyToolApp {
ui.label(number_or_pending(&self.quote_form.quote_number));
});
form_row(ui, "Kunde", |ui| {
let previous_customer_id = self.quote_form.customer_id.clone();
customer_combo(
ui,
&mut self.quote_form.customer_id,
&self.customers,
&mut self.customer_lookup_search,
);
if self.quote_form.customer_id != previous_customer_id {
apply_quote_customer_defaults(&mut self.quote_form, &self.customers);
}
});
form_row(ui, "Status", |ui| {
egui::ComboBox::from_id_salt("quote_status")
@@ -4703,7 +4881,7 @@ impl CompanyToolApp {
ui.text_edit_singleline(&mut line.discount_percent);
ui.end_row();
ui.label("Steuer %");
ui.text_edit_singleline(&mut line.tax_rate);
tax_rate_combo(ui, &mut line.tax_rate);
ui.end_row();
});
if can_remove_quote_item && ui.button("Position entfernen").clicked() {
@@ -4814,12 +4992,20 @@ impl CompanyToolApp {
));
});
form_row(ui, "Kunde", |ui| {
let previous_customer_id =
self.outgoing_invoice_form.customer_id.clone();
customer_combo(
ui,
&mut self.outgoing_invoice_form.customer_id,
&self.customers,
&mut self.customer_lookup_search,
);
if self.outgoing_invoice_form.customer_id != previous_customer_id {
apply_outgoing_invoice_customer_defaults(
&mut self.outgoing_invoice_form,
&self.customers,
);
}
});
form_row(ui, "Status", |ui| {
ui.text_edit_singleline(&mut self.outgoing_invoice_form.status);
@@ -5063,7 +5249,23 @@ impl CompanyToolApp {
ui.text_edit_singleline(&mut self.activity_form.activity_type);
});
form_row(ui, "Status", |ui| {
ui.text_edit_singleline(&mut self.activity_form.status);
egui::ComboBox::from_id_salt("activity_status")
.selected_text(match self.activity_form.status.as_str() {
"inactive" => "Nicht aktiv",
_ => "Aktiv",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.activity_form.status,
"active".to_string(),
"Aktiv",
);
ui.selectable_value(
&mut self.activity_form.status,
"inactive".to_string(),
"Nicht aktiv",
);
});
});
form_row(ui, "Priorität", |ui| {
ui.text_edit_singleline(&mut self.activity_form.priority);
@@ -5078,7 +5280,7 @@ impl CompanyToolApp {
if ui
.add_enabled(
self.selected_activity_id.is_some(),
egui::Button::new("Stornieren"),
egui::Button::new("Deaktivieren"),
)
.clicked()
{
@@ -5536,11 +5738,22 @@ struct Item {
id: String,
item_number: String,
name: String,
manufacturer_code: Option<String>,
unit: String,
tax_rate: String,
default_purchase_price: Option<String>,
default_sales_price: Option<String>,
status: String,
supplier_prices: Vec<ItemSupplierPrice>,
}
#[derive(Debug, Clone, Deserialize)]
struct ItemSupplierPrice {
supplier_id: String,
external_item_number: String,
purchase_price: String,
currency: String,
is_preferred: bool,
}
#[derive(Debug, Clone, Deserialize)]
@@ -5558,22 +5771,35 @@ struct ItemPriceHistory {
struct ItemForm {
item_number: String,
name: String,
manufacturer_code: String,
unit: String,
tax_rate: String,
default_purchase_price: String,
default_sales_price: String,
status: String,
supplier_prices: Vec<ItemSupplierPriceForm>,
}
#[derive(Debug, Clone, Serialize)]
struct ItemSupplierPriceForm {
supplier_id: String,
external_item_number: String,
purchase_price: String,
currency: String,
is_preferred: bool,
}
impl Default for ItemForm {
fn default() -> Self {
Self {
item_number: String::new(),
name: String::new(),
unit: "Stk".to_string(),
manufacturer_code: String::new(),
unit: "Stck".to_string(),
tax_rate: "19".to_string(),
default_purchase_price: "0".to_string(),
default_sales_price: "0".to_string(),
status: "active".to_string(),
supplier_prices: Vec::new(),
}
}
}
@@ -5582,11 +5808,41 @@ impl From<&Item> for ItemForm {
Self {
item_number: value.item_number.clone(),
name: value.name.clone(),
manufacturer_code: value.manufacturer_code.clone().unwrap_or_default(),
unit: value.unit.clone(),
tax_rate: value.tax_rate.clone(),
tax_rate: normalize_tax_rate(&value.tax_rate),
default_purchase_price: value.default_purchase_price.clone().unwrap_or_default(),
default_sales_price: value.default_sales_price.clone().unwrap_or_default(),
status: value.status.clone(),
supplier_prices: value
.supplier_prices
.iter()
.map(ItemSupplierPriceForm::from)
.collect(),
}
}
}
impl Default for ItemSupplierPriceForm {
fn default() -> Self {
Self {
supplier_id: String::new(),
external_item_number: String::new(),
purchase_price: "0".to_string(),
currency: "EUR".to_string(),
is_preferred: false,
}
}
}
impl From<&ItemSupplierPrice> for ItemSupplierPriceForm {
fn from(value: &ItemSupplierPrice) -> Self {
Self {
supplier_id: value.supplier_id.clone(),
external_item_number: value.external_item_number.clone(),
purchase_price: value.purchase_price.clone(),
currency: value.currency.clone(),
is_preferred: value.is_preferred,
}
}
}
@@ -5695,7 +5951,7 @@ impl From<&QuoteItem> for QuoteItemForm {
unit_price: value.unit_price.clone(),
original_unit_price: value.original_unit_price.clone(),
discount_percent: value.discount_percent.clone(),
tax_rate: value.tax_rate.clone(),
tax_rate: normalize_tax_rate(&value.tax_rate),
}
}
}
@@ -5813,7 +6069,7 @@ impl From<&OutgoingInvoiceItem> for OutgoingInvoiceItemForm {
unit_price: value.unit_price.clone(),
original_unit_price: value.original_unit_price.clone(),
discount_percent: value.discount_percent.clone(),
tax_rate: value.tax_rate.clone(),
tax_rate: normalize_tax_rate(&value.tax_rate),
}
}
}
@@ -5913,7 +6169,7 @@ impl From<&IncomingInvoiceItem> for IncomingInvoiceItemForm {
description: value.description.clone(),
quantity: value.quantity.clone(),
unit_price: value.unit_price.clone(),
tax_rate: value.tax_rate.clone(),
tax_rate: normalize_tax_rate(&value.tax_rate),
}
}
}
@@ -5946,7 +6202,7 @@ impl Default for ActivityForm {
activity_type: "task".to_string(),
title: String::new(),
body: String::new(),
status: "open".to_string(),
status: "active".to_string(),
priority: "normal".to_string(),
due_at: None,
}
@@ -5977,7 +6233,7 @@ impl Default for PriceListImportForm {
fn default() -> Self {
Self {
source_name: "Preisliste.csv".to_string(),
content: "item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00".to_string(),
content: "item_number;name;manufacturer_code;unit;tax_rate;purchase_price;sales_price;supplier_number;supplier_item_number;currency\nAR-IMPORT-1;Importartikel;HERST-001;Stck;19,00;10,00;25,00;LI000000001;EXT-4711;EUR".to_string(),
delimiter: Some(";".to_string()),
}
}
@@ -5996,10 +6252,14 @@ struct PriceListImportRow {
row_number: usize,
item_number: String,
name: String,
manufacturer_code: Option<String>,
unit: String,
tax_rate: String,
purchase_price: Option<String>,
sales_price: Option<String>,
supplier_number: Option<String>,
supplier_item_number: Option<String>,
currency: Option<String>,
action: String,
error: Option<String>,
}