From d5b6f39177a64d5c37e915dd3fb9610bcf89b02c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 3 Jun 2026 09:25:10 +0200 Subject: [PATCH] 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. --- PLANUNG.md | 22 +- .../0013_item_unit_default.sql | 2 + .../0014_activity_line_sources.sql | 13 + .../0015_activity_status_active_inactive.sql | 15 + backend/src/api.rs | 599 +++++++++++++++--- desktop-client/src/main.rs | 308 ++++++++- web-frontend/src/api.ts | 23 +- web-frontend/src/components/SearchSelect.vue | 18 +- web-frontend/src/format.ts | 62 ++ web-frontend/src/styles.css | 61 +- web-frontend/src/types.ts | 24 +- web-frontend/src/views/ActivitiesPage.vue | 8 +- .../src/views/CashDiscountTermsPage.vue | 13 +- web-frontend/src/views/CustomersPage.vue | 11 +- .../src/views/IncomingInvoicesPage.vue | 37 +- web-frontend/src/views/ItemsPage.vue | 100 ++- .../src/views/OrganizationSetupPage.vue | 11 +- .../src/views/OutgoingInvoicesPage.vue | 116 +++- web-frontend/src/views/PriceImportsPage.vue | 6 +- web-frontend/src/views/PriceRulesPage.vue | 9 +- web-frontend/src/views/QuotesPage.vue | 134 +++- web-frontend/src/views/SuppliersPage.vue | 11 +- 22 files changed, 1420 insertions(+), 183 deletions(-) create mode 100644 backend/company-migrations/0013_item_unit_default.sql create mode 100644 backend/company-migrations/0014_activity_line_sources.sql create mode 100644 backend/company-migrations/0015_activity_status_active_inactive.sql create mode 100644 web-frontend/src/format.ts diff --git a/PLANUNG.md b/PLANUNG.md index e4e1fa4..2e0269a 100644 --- a/PLANUNG.md +++ b/PLANUNG.md @@ -809,8 +809,12 @@ Geplante Daten: - Einkaufspreis - Verkaufspreis - Steuersatz -- Lieferant -- Herstellerartikelnummer +- Hersteller-Code / Herstellerartikelnummer +- mehrere Lieferantenreferenzen je Artikel +- externe Artikelnummer je Lieferant +- Einkaufspreis je Lieferant und externer Artikelnummer +- Währung je Lieferantenpreis +- Kennzeichnung bevorzugter Lieferant - EAN/GTIN - Preisgültigkeit - Lagerbestand @@ -823,6 +827,8 @@ Preisberechnung: - Einkaufspreis aus Quelle übernehmen - Einkaufspreis mal Multiplikator - Staffelpreise +- günstigsten aktiven Lieferantenpreis ermitteln +- bevorzugten Lieferanten optional gegenüber dem günstigsten Preis priorisieren - kundenspezifische Preise - kundenbezogener Standardrabatt - positionsbezogener Sonderpreis oder Sonderrabatt @@ -1021,6 +1027,18 @@ Import-Pipeline: 8. Verkaufspreise neu berechnen 9. betroffene Clients per WebSocket informieren +Preislistenimporte berücksichtigen optional: + +- Hersteller-Code (`manufacturer_code`) +- Lieferantennummer (`supplier_number`) +- externe Lieferanten-Artikelnummer (`supplier_item_number`) +- Lieferanten-Einkaufspreis (`purchase_price`) +- Währung (`currency`) + +Wenn Lieferantennummer und externe Artikelnummer im Import vorhanden sind, wird +neben dem internen Artikel eine Lieferantenpreis-Verknüpfung aktualisiert. Die +interne Artikelnummer bleibt unabhängig davon der primäre Objekt-Identifier. + ### Frei konfigurierbare Preislisten Da Lieferantenpreislisten frei konfigurierbar sein sollen, braucht das System diff --git a/backend/company-migrations/0013_item_unit_default.sql b/backend/company-migrations/0013_item_unit_default.sql new file mode 100644 index 0000000..9908ccb --- /dev/null +++ b/backend/company-migrations/0013_item_unit_default.sql @@ -0,0 +1,2 @@ +alter table {schema}.items + alter column unit set default 'Stck'; diff --git a/backend/company-migrations/0014_activity_line_sources.sql b/backend/company-migrations/0014_activity_line_sources.sql new file mode 100644 index 0000000..26164f6 --- /dev/null +++ b/backend/company-migrations/0014_activity_line_sources.sql @@ -0,0 +1,13 @@ +alter table {schema}.quote_items + add column if not exists line_kind text not null default 'item', + add column if not exists activity_id uuid references {schema}.activities(id); + +alter table {schema}.quote_items + alter column item_id drop not null; + +alter table {schema}.outgoing_invoice_items + add column if not exists line_kind text not null default 'item', + add column if not exists activity_id uuid references {schema}.activities(id); + +alter table {schema}.outgoing_invoice_items + alter column item_id drop not null; diff --git a/backend/company-migrations/0015_activity_status_active_inactive.sql b/backend/company-migrations/0015_activity_status_active_inactive.sql new file mode 100644 index 0000000..a411e7d --- /dev/null +++ b/backend/company-migrations/0015_activity_status_active_inactive.sql @@ -0,0 +1,15 @@ +alter table {schema}.activities + drop constraint if exists activities_status_valid; + +update {schema}.activities +set status = case + when status in ('open', 'in_progress') then 'active' + else 'inactive' +end +where status in ('open', 'in_progress', 'done', 'cancelled'); + +alter table {schema}.activities + alter column status set default 'active'; + +alter table {schema}.activities + add constraint activities_status_valid check (status in ('active', 'inactive')); diff --git a/backend/src/api.rs b/backend/src/api.rs index 91c93b5..37e3f5d 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -12,7 +12,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use chrono::{DateTime, Utc}; use companytool_shared_protocol::{RecordSummary, ServerMessage}; use rand_core::RngCore; -use serde::{Deserialize, Serialize}; +use serde::{de, Deserialize, Deserializer, Serialize}; use serde_json::json; use sha2::{Digest, Sha256}; use sqlx::{PgPool, Postgres, Row, Transaction}; @@ -189,11 +189,79 @@ pub struct SupplierResponse { pub struct ItemRequest { pub item_number: String, pub name: String, + #[serde(default)] + pub manufacturer_code: Option, pub unit: String, + #[serde(deserialize_with = "deserialize_string_or_number")] pub tax_rate: String, + #[serde(default, deserialize_with = "deserialize_optional_string_or_number")] pub default_purchase_price: Option, + #[serde(default, deserialize_with = "deserialize_optional_string_or_number")] pub default_sales_price: Option, pub status: String, + #[serde(default)] + pub supplier_prices: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ItemSupplierPriceRequest { + pub supplier_id: Uuid, + pub external_item_number: String, + #[serde(deserialize_with = "deserialize_string_or_number")] + pub purchase_price: String, + #[serde(default = "default_currency")] + pub currency: String, + #[serde(default)] + pub is_preferred: bool, + pub valid_from: Option, + pub valid_until: Option, +} + +fn default_currency() -> String { + "EUR".to_string() +} + +fn default_line_kind() -> String { + "item".to_string() +} + +fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + value_to_string(value).map_err(de::Error::custom) +} + +fn deserialize_optional_string_or_number<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + value + .map(value_to_string) + .transpose() + .map(|value| { + value.and_then(|text| { + if text.trim().is_empty() { + None + } else { + Some(text) + } + }) + }) + .map_err(de::Error::custom) +} + +fn value_to_string(value: serde_json::Value) -> Result { + match value { + serde_json::Value::String(value) => Ok(value), + serde_json::Value::Number(value) => Ok(value.to_string()), + serde_json::Value::Null => Ok(String::new()), + _ => Err("Wert muss Text oder Zahl sein".to_string()), + } } #[derive(Debug, Serialize)] @@ -201,11 +269,28 @@ pub struct ItemResponse { pub id: Uuid, pub item_number: String, pub name: String, + pub manufacturer_code: Option, pub unit: String, pub tax_rate: String, pub default_purchase_price: Option, pub default_sales_price: Option, pub status: String, + pub supplier_prices: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ItemSupplierPriceResponse { + pub id: Uuid, + pub supplier_id: Uuid, + pub supplier_number: String, + pub supplier_name: String, + pub external_item_number: String, + pub purchase_price: String, + pub currency: String, + pub is_preferred: bool, + pub valid_from: Option, + pub valid_until: Option, + pub source: String, } #[derive(Debug, Serialize)] @@ -251,7 +336,10 @@ pub struct CashDiscountTermResponse { #[derive(Debug, Deserialize)] pub struct QuoteItemRequest { - pub item_id: Uuid, + #[serde(default = "default_line_kind")] + pub line_kind: String, + pub item_id: Option, + pub activity_id: Option, pub description: String, pub quantity: String, pub unit_price: String, @@ -276,7 +364,9 @@ pub struct QuoteRequest { pub struct QuoteItemResponse { pub id: Uuid, pub line_number: i32, - pub item_id: Uuid, + pub line_kind: String, + pub item_id: Option, + pub activity_id: Option, pub description: String, pub quantity: String, pub unit_price: String, @@ -301,7 +391,10 @@ pub struct QuoteResponse { #[derive(Debug, Deserialize)] pub struct OutgoingInvoiceItemRequest { - pub item_id: Uuid, + #[serde(default = "default_line_kind")] + pub line_kind: String, + pub item_id: Option, + pub activity_id: Option, pub description: String, pub quantity: String, pub unit_price: String, @@ -327,7 +420,9 @@ pub struct OutgoingInvoiceRequest { pub struct OutgoingInvoiceItemResponse { pub id: Uuid, pub line_number: i32, - pub item_id: Uuid, + pub line_kind: String, + pub item_id: Option, + pub activity_id: Option, pub description: String, pub quantity: String, pub unit_price: String, @@ -415,10 +510,14 @@ pub struct PriceListImportRow { pub row_number: usize, pub item_number: String, pub name: String, + pub manufacturer_code: Option, pub unit: String, pub tax_rate: String, pub purchase_price: Option, pub sales_price: Option, + pub supplier_number: Option, + pub supplier_item_number: Option, + pub currency: Option, pub action: String, pub error: Option, } @@ -1907,7 +2006,9 @@ pub async fn convert_quote_to_outgoing_invoice( .items .iter() .map(|item| OutgoingInvoiceItemRequest { + line_kind: item.line_kind.clone(), item_id: item.item_id, + activity_id: item.activity_id, description: item.description.clone(), quantity: item.quantity.clone(), unit_price: item.unit_price.clone(), @@ -2777,7 +2878,7 @@ pub async fn list_items( let context = require_permission(db, &headers, "items.read").await?; let sql = format!( r#" - select id, item_number, name_ciphertext, name_nonce, name_key_id, unit, + select id, item_number, manufacturer_code, name_ciphertext, name_nonce, name_key_id, unit, tax_rate::text tax_rate, default_purchase_price::text default_purchase_price, default_sales_price::text default_sales_price, status from {schema}.items order by updated_at desc, item_number @@ -2787,7 +2888,7 @@ pub async fn list_items( let rows = sqlx::query(&sql).fetch_all(db).await?; let mut items = Vec::with_capacity(rows.len()); for row in rows { - items.push(item_from_row(&state.crypto, row)?); + items.push(item_from_row(db, &state.crypto, &context.schema_name, row).await?); } Ok(Json(items)) } @@ -2813,9 +2914,12 @@ pub async fn create_item( true, ) .await?; + save_item_supplier_prices(db, &context.schema_name, item_id, &payload, "manual").await?; record_item_price_history(db, &context.schema_name, item_id, &payload, context.user_id).await?; emit_change(&state, "Artikel angelegt"); - Ok(Json(item_response(item_id, payload))) + Ok(Json( + load_item_response_by_id(db, &state.crypto, &context.schema_name, item_id).await?, + )) } pub async fn update_item( @@ -2836,9 +2940,12 @@ pub async fn update_item( false, ) .await?; + save_item_supplier_prices(db, &context.schema_name, item_id, &payload, "manual").await?; record_item_price_history(db, &context.schema_name, item_id, &payload, context.user_id).await?; emit_change(&state, "Artikel geändert"); - Ok(Json(item_response(item_id, payload))) + Ok(Json( + load_item_response_by_id(db, &state.crypto, &context.schema_name, item_id).await?, + )) } pub async fn list_item_price_history( @@ -2945,12 +3052,12 @@ pub async fn delete_activity( let db = state.db()?; let context = require_permission(db, &headers, "activities.delete").await?; let sql = format!( - "update {schema}.activities set status = 'cancelled', updated_at = now() where id = $1", + "update {schema}.activities set status = 'inactive', updated_at = now() where id = $1", schema = context.schema_name ); let result = sqlx::query(&sql).bind(activity_id).execute(db).await?; ensure_changed(result.rows_affected(), "Aktivität nicht gefunden")?; - emit_change(&state, "Aktivität storniert"); + emit_change(&state, "Aktivität deaktiviert"); Ok(Json(json!({ "deleted": true, "id": activity_id }))) } @@ -3654,12 +3761,13 @@ fn validate_quote_request(payload: &QuoteRequest) -> Result<(), ApiError> { )); } for item in &payload.items { + validate_line_source(&item.line_kind, item.item_id, item.activity_id)?; validate_number(&item.quantity, "Menge")?; if item.quantity.parse::().unwrap_or(0.0) <= 0.0 { return Err(ApiError::bad_request("Menge muss größer als 0 sein")); } validate_number(&item.unit_price, "Positionspreis")?; - validate_number(&item.tax_rate, "Steuersatz")?; + validate_tax_rate(&item.tax_rate)?; validate_percent(&item.discount_percent, "Positionsrabatt")?; if let Some(original_price) = &item.original_unit_price { validate_number(original_price, "Originalpreis")?; @@ -3679,7 +3787,12 @@ async fn write_quote( ensure_safe_schema_name(&context.schema_name)?; ensure_customer_exists(db, &context.schema_name, payload.customer_id).await?; for item in &payload.items { - ensure_item_exists(db, &context.schema_name, item.item_id).await?; + if let Some(item_id) = item.item_id { + ensure_item_exists(db, &context.schema_name, item_id).await?; + } + if let Some(activity_id) = item.activity_id { + ensure_activity_exists(db, &context.schema_name, activity_id).await?; + } } let notes = if payload.notes.trim().is_empty() { None @@ -3751,10 +3864,10 @@ async fn write_quote_items( let sql = format!( r#" insert into {schema}.quote_items ( - id, quote_id, line_number, item_id, description_ciphertext, + id, quote_id, line_number, line_kind, item_id, activity_id, description_ciphertext, description_nonce, description_key_id, quantity, unit_price, original_unit_price, discount_percent, price_overridden, tax_rate - ) values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric,$11::numeric,$12,$13::numeric) + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::numeric,$11::numeric,$12::numeric,$13::numeric,$14,$15::numeric) "#, schema = schema_name ); @@ -3772,7 +3885,9 @@ async fn write_quote_items( .bind(Uuid::new_v4()) .bind(quote_id) .bind((index + 1) as i32) + .bind(item.line_kind.trim()) .bind(item.item_id) + .bind(item.activity_id) .bind(description.as_ref().map(|field| field.ciphertext.clone())) .bind(description.as_ref().map(|field| field.nonce.clone())) .bind(description.as_ref().map(|field| field.key_id.clone())) @@ -3828,6 +3943,44 @@ async fn ensure_item_exists(db: &PgPool, schema_name: &str, item_id: Uuid) -> Re } } +async fn ensure_activity_exists( + db: &PgPool, + schema_name: &str, + activity_id: Uuid, +) -> Result<(), ApiError> { + let sql = format!( + "select exists(select 1 from {schema}.activities where id=$1 and status = 'active')", + schema = schema_name + ); + if sqlx::query_scalar::<_, bool>(&sql) + .bind(activity_id) + .fetch_one(db) + .await? + { + Ok(()) + } else { + Err(ApiError::bad_request( + "Aktivität ist nicht vorhanden oder inaktiv", + )) + } +} + +fn validate_line_source( + line_kind: &str, + item_id: Option, + activity_id: Option, +) -> Result<(), ApiError> { + match line_kind { + "item" if item_id.is_some() && activity_id.is_none() => Ok(()), + "activity" if activity_id.is_some() && item_id.is_none() => Ok(()), + "item" => Err(ApiError::bad_request("Artikelposition braucht einen Artikel")), + "activity" => Err(ApiError::bad_request( + "Aktivitätsposition braucht eine Aktivität", + )), + _ => Err(ApiError::bad_request("Ungültiger Positionstyp")), + } +} + fn quote_response(id: Uuid, payload: QuoteRequest) -> QuoteResponse { QuoteResponse { id, @@ -3850,7 +4003,9 @@ fn quote_response(id: Uuid, payload: QuoteRequest) -> QuoteResponse { QuoteItemResponse { id: Uuid::new_v4(), line_number: (index + 1) as i32, + line_kind: item.line_kind, item_id: item.item_id, + activity_id: item.activity_id, description: item.description, quantity: item.quantity, unit_price: item.unit_price, @@ -3901,7 +4056,7 @@ async fn load_quote_items( ensure_safe_schema_name(schema_name)?; let sql = format!( r#" - select id, line_number, item_id, description_ciphertext, description_nonce, + select id, line_number, line_kind, item_id, activity_id, description_ciphertext, description_nonce, description_key_id, quantity::text quantity, unit_price::text unit_price, original_unit_price::text original_unit_price, discount_percent::text discount_percent, tax_rate::text tax_rate, @@ -3926,7 +4081,9 @@ async fn load_quote_items( items.push(QuoteItemResponse { id: row.get("id"), line_number: row.get("line_number"), + line_kind: row.get("line_kind"), item_id: row.get("item_id"), + activity_id: row.get("activity_id"), description, quantity: row.get("quantity"), unit_price: row.get("unit_price"), @@ -3977,12 +4134,13 @@ fn validate_outgoing_invoice_request(payload: &OutgoingInvoiceRequest) -> Result )); } for item in &payload.items { + validate_line_source(&item.line_kind, item.item_id, item.activity_id)?; validate_number(&item.quantity, "Menge")?; if item.quantity.parse::().unwrap_or(0.0) <= 0.0 { return Err(ApiError::bad_request("Menge muss größer als 0 sein")); } validate_number(&item.unit_price, "Positionspreis")?; - validate_number(&item.tax_rate, "Steuersatz")?; + validate_tax_rate(&item.tax_rate)?; validate_percent(&item.discount_percent, "Positionsrabatt")?; if let Some(original_price) = &item.original_unit_price { validate_number(original_price, "Originalpreis")?; @@ -4002,7 +4160,12 @@ async fn write_outgoing_invoice( ensure_safe_schema_name(&context.schema_name)?; ensure_customer_exists(db, &context.schema_name, payload.customer_id).await?; for item in &payload.items { - ensure_item_exists(db, &context.schema_name, item.item_id).await?; + if let Some(item_id) = item.item_id { + ensure_item_exists(db, &context.schema_name, item_id).await?; + } + if let Some(activity_id) = item.activity_id { + ensure_activity_exists(db, &context.schema_name, activity_id).await?; + } } let sql = if insert { format!( @@ -4068,11 +4231,11 @@ async fn write_outgoing_invoice_items( let sql = format!( r#" insert into {schema}.outgoing_invoice_items ( - id, invoice_id, line_number, item_id, description_ciphertext, + id, invoice_id, line_number, line_kind, item_id, activity_id, description_ciphertext, description_nonce, description_key_id, quantity, unit_price, original_unit_price, discount_percent, price_overridden, price_overridden_by_user_id, price_overridden_at, tax_rate - ) values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric,$11::numeric,$12,$13,$14,$15::numeric) + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::numeric,$11::numeric,$12::numeric,$13::numeric,$14,$15,$16,$17::numeric) "#, schema = context.schema_name ); @@ -4090,7 +4253,9 @@ async fn write_outgoing_invoice_items( .bind(Uuid::new_v4()) .bind(invoice_id) .bind((index + 1) as i32) + .bind(item.line_kind.trim()) .bind(item.item_id) + .bind(item.activity_id) .bind(description.as_ref().map(|field| field.ciphertext.clone())) .bind(description.as_ref().map(|field| field.nonce.clone())) .bind(description.as_ref().map(|field| field.key_id.clone())) @@ -4144,7 +4309,9 @@ fn outgoing_invoice_response( OutgoingInvoiceItemResponse { id: Uuid::new_v4(), line_number: (index + 1) as i32, + line_kind: item.line_kind, item_id: item.item_id, + activity_id: item.activity_id, description: item.description, quantity: item.quantity, unit_price: item.unit_price, @@ -4191,7 +4358,7 @@ async fn load_outgoing_invoice_items( ensure_safe_schema_name(schema_name)?; let sql = format!( r#" - select id, line_number, item_id, description_ciphertext, description_nonce, + select id, line_number, line_kind, item_id, activity_id, description_ciphertext, description_nonce, description_key_id, quantity::text quantity, unit_price::text unit_price, original_unit_price::text original_unit_price, discount_percent::text discount_percent, tax_rate::text tax_rate, @@ -4216,7 +4383,9 @@ async fn load_outgoing_invoice_items( items.push(OutgoingInvoiceItemResponse { id: row.get("id"), line_number: row.get("line_number"), + line_kind: row.get("line_kind"), item_id: row.get("item_id"), + activity_id: row.get("activity_id"), description, quantity: row.get("quantity"), unit_price: row.get("unit_price"), @@ -4282,7 +4451,7 @@ fn validate_incoming_invoice_request(payload: &IncomingInvoiceRequest) -> Result return Err(ApiError::bad_request("Menge muss größer als 0 sein")); } validate_number(&item.unit_price, "Positionspreis")?; - validate_number(&item.tax_rate, "Steuersatz")?; + validate_tax_rate(&item.tax_rate)?; } Ok(()) } @@ -4524,14 +4693,7 @@ async fn parse_price_list_rows( .next() .ok_or_else(|| ApiError::bad_request("CSV-Datei enthält keine Kopfzeile"))?; let headers = split_csv_line(header, delimiter); - let required = [ - "item_number", - "name", - "unit", - "tax_rate", - "purchase_price", - "sales_price", - ]; + let required = ["item_number", "name", "unit", "tax_rate"]; for column in required { if !headers.iter().any(|header| header == column) { return Err(ApiError::bad_request(&format!( @@ -4553,27 +4715,44 @@ async fn parse_price_list_rows( let row_number = index + 2; let item_number = value("item_number"); let name = value("name"); + let manufacturer_code = empty_string_to_none(value("manufacturer_code")); let unit = value("unit"); let tax_rate = value("tax_rate"); let purchase_price = empty_string_to_none(value("purchase_price")); let sales_price = empty_string_to_none(value("sales_price")); + let supplier_number = empty_string_to_none(value("supplier_number")); + let supplier_item_number = empty_string_to_none(value("supplier_item_number")); + let currency = empty_string_to_none(value("currency")).map(|value| value.to_uppercase()); let mut error = None; if item_number.trim().is_empty() { error = Some("Artikelnummer fehlt".to_string()); } else if name.trim().is_empty() { error = Some("Bezeichnung fehlt".to_string()); - } else if tax_rate.parse::().is_err() { + } else if validate_tax_rate(&tax_rate).is_err() { error = Some("Steuersatz ist ungültig".to_string()); } else if purchase_price .as_ref() - .is_some_and(|price| price.parse::().is_err()) + .is_some_and(|price| parse_number(price).is_err()) { error = Some("Einkaufspreis ist ungültig".to_string()); } else if sales_price .as_ref() - .is_some_and(|price| price.parse::().is_err()) + .is_some_and(|price| parse_number(price).is_err()) { error = Some("Verkaufspreis ist ungültig".to_string()); + } else if currency + .as_ref() + .is_some_and(|currency| currency.len() != 3) + { + error = Some("Währung muss aus drei Zeichen bestehen".to_string()); + } else if supplier_number.is_some() && supplier_item_number.is_none() { + error = Some("Externe Lieferanten-Artikelnummer fehlt".to_string()); + } else if supplier_item_number.is_some() && supplier_number.is_none() { + error = Some("Lieferantennummer fehlt".to_string()); + } else if let Some(supplier_number) = &supplier_number { + if !supplier_number_exists(db, schema_name, supplier_number).await? { + error = Some("Lieferant nicht gefunden".to_string()); + } } let action = if item_number_exists(db, schema_name, &item_number).await? { "update" @@ -4584,14 +4763,18 @@ async fn parse_price_list_rows( row_number, item_number, name, + manufacturer_code, unit: if unit.trim().is_empty() { - "Stk".to_string() + "Stck".to_string() } else { unit }, tax_rate, purchase_price, sales_price, + supplier_number, + supplier_item_number, + currency, action: action.to_string(), error, }); @@ -4628,6 +4811,21 @@ async fn item_number_exists( .await?) } +async fn supplier_number_exists( + db: &PgPool, + schema_name: &str, + supplier_number: &str, +) -> Result { + let sql = format!( + "select exists(select 1 from {schema}.suppliers where supplier_number=$1)", + schema = schema_name + ); + Ok(sqlx::query_scalar::<_, bool>(&sql) + .bind(supplier_number.trim()) + .fetch_one(db) + .await?) +} + async fn upsert_imported_item( db: &PgPool, crypto: &DataCrypto, @@ -4642,9 +4840,9 @@ async fn upsert_imported_item( r#" update {schema}.items set name_ciphertext=$2, name_nonce=$3, name_key_id=$4, - unit=$5, tax_rate=$6::numeric, - default_purchase_price=$7::numeric, - default_sales_price=$8::numeric, + manufacturer_code=$5, unit=$6, tax_rate=$7::numeric, + default_purchase_price=$8::numeric, + default_sales_price=$9::numeric, status='active', updated_at=now() where id=$1 "#, @@ -4655,10 +4853,11 @@ async fn upsert_imported_item( .bind(name.ciphertext) .bind(name.nonce) .bind(name.key_id) + .bind(row.manufacturer_code.as_deref().map(str::trim)) .bind(row.unit.trim()) - .bind(row.tax_rate.trim()) - .bind(row.purchase_price.as_deref()) - .bind(row.sales_price.as_deref()) + .bind(normalize_number_string(&row.tax_rate)) + .bind(normalize_optional_number(row.purchase_price.as_deref())) + .bind(normalize_optional_number(row.sales_price.as_deref())) .execute(db) .await?; item_id @@ -4669,27 +4868,48 @@ async fn upsert_imported_item( let sql = format!( r#" insert into {schema}.items ( - id, item_number, name_ciphertext, name_nonce, name_key_id, + id, item_number, manufacturer_code, name_ciphertext, name_nonce, name_key_id, unit, tax_rate, default_purchase_price, default_sales_price, status - ) values ($1,$2,$3,$4,$5,$6,$7::numeric,$8::numeric,$9::numeric,'active') + ) values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric,'active') "#, schema = context.schema_name ); sqlx::query(&sql) .bind(item_id) .bind(row.item_number.trim()) + .bind(row.manufacturer_code.as_deref().map(str::trim)) .bind(name.ciphertext) .bind(name.nonce) .bind(name.key_id) .bind(row.unit.trim()) - .bind(row.tax_rate.trim()) - .bind(row.purchase_price.as_deref()) - .bind(row.sales_price.as_deref()) + .bind(normalize_number_string(&row.tax_rate)) + .bind(normalize_optional_number(row.purchase_price.as_deref())) + .bind(normalize_optional_number(row.sales_price.as_deref())) .execute(db) .await?; item_id } }; + if let (Some(supplier_number), Some(external_number), Some(purchase_price)) = ( + row.supplier_number.as_deref(), + row.supplier_item_number.as_deref(), + row.purchase_price.as_deref(), + ) { + let supplier_id = find_supplier_id_by_number(db, &context.schema_name, supplier_number) + .await? + .ok_or_else(|| ApiError::bad_request("Lieferant nicht gefunden"))?; + upsert_item_supplier_price( + db, + &context.schema_name, + item_id, + supplier_id, + external_number, + purchase_price, + row.currency.as_deref().unwrap_or("EUR"), + &format!("import:{import_id}"), + ) + .await?; + } record_item_price_history_with_source( db, &context.schema_name, @@ -4717,6 +4937,59 @@ async fn find_item_id_by_number( .await?) } +async fn find_supplier_id_by_number( + db: &PgPool, + schema_name: &str, + supplier_number: &str, +) -> Result, ApiError> { + let sql = format!( + "select id from {schema}.suppliers where supplier_number=$1", + schema = schema_name + ); + Ok(sqlx::query_scalar::<_, Uuid>(&sql) + .bind(supplier_number.trim()) + .fetch_optional(db) + .await?) +} + +async fn upsert_item_supplier_price( + db: &PgPool, + schema_name: &str, + item_id: Uuid, + supplier_id: Uuid, + external_item_number: &str, + purchase_price: &str, + currency: &str, + source: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + insert into {schema}.item_supplier_prices ( + id, item_id, supplier_id, external_item_number, purchase_price, currency, source + ) values ($1,$2,$3,$4,$5::numeric,$6,$7) + on conflict (supplier_id, external_item_number) + do update set item_id=excluded.item_id, + purchase_price=excluded.purchase_price, + currency=excluded.currency, + source=excluded.source, + updated_at=now() + "#, + schema = schema_name + ); + sqlx::query(&sql) + .bind(Uuid::new_v4()) + .bind(item_id) + .bind(supplier_id) + .bind(external_item_number.trim()) + .bind(normalize_number_string(purchase_price)) + .bind(currency.trim().to_uppercase()) + .bind(source) + .execute(db) + .await?; + Ok(()) +} + fn validate_api_connector_request(payload: &ApiConnectorRequest) -> Result<(), ApiError> { if payload.code.trim().is_empty() || payload.name.trim().len() < 2 { return Err(ApiError::bad_request("Code und Name sind erforderlich")); @@ -5345,13 +5618,26 @@ fn validate_item_request(payload: &ItemRequest) -> Result<(), ApiError> { return Err(ApiError::bad_request("Artikelname ist erforderlich")); } validate_status(&payload.status)?; - validate_number(&payload.tax_rate, "Steuersatz")?; + validate_tax_rate(&payload.tax_rate)?; if let Some(price) = &payload.default_purchase_price { validate_number(price, "Einkaufspreis")?; } if let Some(price) = &payload.default_sales_price { validate_number(price, "Verkaufspreis")?; } + for supplier_price in &payload.supplier_prices { + if supplier_price.external_item_number.trim().is_empty() { + return Err(ApiError::bad_request( + "Externe Lieferanten-Artikelnummer fehlt", + )); + } + validate_number(&supplier_price.purchase_price, "Lieferantenpreis")?; + if supplier_price.currency.trim().len() != 3 { + return Err(ApiError::bad_request( + "Währung muss aus drei Zeichen bestehen", + )); + } + } Ok(()) } @@ -5366,27 +5652,38 @@ async fn write_item( let name = crypto.encrypt(&payload.name.trim().to_string())?; let sql = if insert { format!( - r#"insert into {schema}.items (id,item_number,name_ciphertext,name_nonce,name_key_id,unit,tax_rate,default_purchase_price,default_sales_price,status) - values ($1,$2,$3,$4,$5,$6,$7::numeric,$8::numeric,$9::numeric,$10)"#, + r#"insert into {schema}.items (id,item_number,manufacturer_code,name_ciphertext,name_nonce,name_key_id,unit,tax_rate,default_purchase_price,default_sales_price,status) + values ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9::numeric,$10::numeric,$11)"#, schema = schema_name ) } else { format!( - r#"update {schema}.items set item_number=item_number,name_ciphertext=$3,name_nonce=$4,name_key_id=$5,unit=$6,tax_rate=$7::numeric, - default_purchase_price=$8::numeric,default_sales_price=$9::numeric,status=$10,updated_at=now() where id=$1"#, + r#"update {schema}.items set item_number=item_number,manufacturer_code=$3,name_ciphertext=$4,name_nonce=$5,name_key_id=$6,unit=$7,tax_rate=$8::numeric, + default_purchase_price=$9::numeric,default_sales_price=$10::numeric,status=$11,updated_at=now() where id=$1"#, schema = schema_name ) }; let result = sqlx::query(&sql) .bind(item_id) .bind(payload.item_number.trim()) + .bind( + payload + .manufacturer_code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + ) .bind(name.ciphertext) .bind(name.nonce) .bind(name.key_id) .bind(payload.unit.trim()) - .bind(payload.tax_rate.trim()) - .bind(payload.default_purchase_price.as_deref()) - .bind(payload.default_sales_price.as_deref()) + .bind(normalize_number_string(&payload.tax_rate)) + .bind(normalize_optional_number( + payload.default_purchase_price.as_deref(), + )) + .bind(normalize_optional_number( + payload.default_sales_price.as_deref(), + )) .bind(&payload.status) .execute(db) .await?; @@ -5396,39 +5693,151 @@ async fn write_item( Ok(()) } -fn item_response(id: Uuid, payload: ItemRequest) -> ItemResponse { - ItemResponse { - id, - item_number: payload.item_number, - name: payload.name, - unit: payload.unit, - tax_rate: payload.tax_rate, - default_purchase_price: payload.default_purchase_price, - default_sales_price: payload.default_sales_price, - status: payload.status, - } -} - -fn item_from_row( +async fn item_from_row( + db: &PgPool, crypto: &DataCrypto, + schema_name: &str, row: sqlx::postgres::PgRow, ) -> Result { + let item_id = row.get("id"); Ok(ItemResponse { - id: row.get("id"), + id: item_id, item_number: row.get("item_number"), name: crypto.decrypt( &row.get::, _>("name_ciphertext"), &row.get::, _>("name_nonce"), &row.get::("name_key_id"), )?, + manufacturer_code: row.get("manufacturer_code"), unit: row.get("unit"), tax_rate: row.get("tax_rate"), default_purchase_price: row.get("default_purchase_price"), default_sales_price: row.get("default_sales_price"), status: row.get("status"), + supplier_prices: load_item_supplier_prices(db, crypto, schema_name, item_id).await?, }) } +async fn load_item_response_by_id( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + item_id: Uuid, +) -> Result { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select id, item_number, manufacturer_code, name_ciphertext, name_nonce, name_key_id, unit, + tax_rate::text tax_rate, default_purchase_price::text default_purchase_price, + default_sales_price::text default_sales_price, status + from {schema}.items + where id=$1 + "#, + schema = schema_name + ); + let row = sqlx::query(&sql) + .bind(item_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Artikel nicht gefunden"))?; + item_from_row(db, crypto, schema_name, row).await +} + +async fn load_item_supplier_prices( + db: &PgPool, + crypto: &DataCrypto, + schema_name: &str, + item_id: Uuid, +) -> Result, ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select p.id, p.supplier_id, s.supplier_number, s.name_ciphertext, s.name_nonce, + s.name_key_id, p.external_item_number, p.purchase_price::text purchase_price, + p.currency, p.is_preferred, p.valid_from::text valid_from, + p.valid_until::text valid_until, p.source + from {schema}.item_supplier_prices p + join {schema}.suppliers s on s.id = p.supplier_id + where p.item_id = $1 + order by p.purchase_price asc, s.supplier_number + "#, + schema = schema_name + ); + let rows = sqlx::query(&sql).bind(item_id).fetch_all(db).await?; + let mut prices = Vec::with_capacity(rows.len()); + for row in rows { + prices.push(ItemSupplierPriceResponse { + id: row.get("id"), + supplier_id: row.get("supplier_id"), + supplier_number: row.get("supplier_number"), + supplier_name: crypto.decrypt( + &row.get::, _>("name_ciphertext"), + &row.get::, _>("name_nonce"), + &row.get::("name_key_id"), + )?, + external_item_number: row.get("external_item_number"), + purchase_price: row.get("purchase_price"), + currency: row.get("currency"), + is_preferred: row.get("is_preferred"), + valid_from: row.get("valid_from"), + valid_until: row.get("valid_until"), + source: row.get("source"), + }); + } + Ok(prices) +} + +async fn save_item_supplier_prices( + db: &PgPool, + schema_name: &str, + item_id: Uuid, + payload: &ItemRequest, + source: &str, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let delete_sql = format!( + "delete from {schema}.item_supplier_prices where item_id=$1", + schema = schema_name + ); + sqlx::query(&delete_sql).bind(item_id).execute(db).await?; + for supplier_price in &payload.supplier_prices { + ensure_supplier_exists(db, schema_name, supplier_price.supplier_id).await?; + let insert_sql = format!( + r#" + insert into {schema}.item_supplier_prices ( + id, item_id, supplier_id, external_item_number, purchase_price, currency, + is_preferred, valid_from, valid_until, source + ) values ($1,$2,$3,$4,$5::numeric,$6,$7,$8::date,$9::date,$10) + "#, + schema = schema_name + ); + sqlx::query(&insert_sql) + .bind(Uuid::new_v4()) + .bind(item_id) + .bind(supplier_price.supplier_id) + .bind(supplier_price.external_item_number.trim()) + .bind(normalize_number_string(&supplier_price.purchase_price)) + .bind(supplier_price.currency.trim().to_uppercase()) + .bind(supplier_price.is_preferred) + .bind( + supplier_price + .valid_from + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind( + supplier_price + .valid_until + .as_deref() + .filter(|value| !value.is_empty()), + ) + .bind(source) + .execute(db) + .await?; + } + Ok(()) +} + async fn record_item_price_history( db: &PgPool, schema_name: &str, @@ -5509,7 +5918,7 @@ fn validate_activity_request(payload: &ActivityRequest) -> Result<(), ApiError> { return Err(ApiError::bad_request("Ungültiger Aktivitätstyp")); } - if !["open", "in_progress", "done", "cancelled"].contains(&payload.status.as_str()) { + if !["active", "inactive"].contains(&payload.status.as_str()) { return Err(ApiError::bad_request("Ungültiger Aktivitätsstatus")); } if !["low", "normal", "high", "critical"].contains(&payload.priority.as_str()) { @@ -5650,8 +6059,7 @@ fn validate_status_choice(value: &str, allowed: &[&str], message: &str) -> Resul } fn validate_number(value: &str, field: &str) -> Result<(), ApiError> { - let parsed = value - .parse::() + let parsed = parse_number(value) .map_err(|_| ApiError::bad_request(&format!("Ungültiger Wert: {field}")))?; if parsed < 0.0 { Err(ApiError::bad_request(&format!( @@ -5662,9 +6070,16 @@ fn validate_number(value: &str, field: &str) -> Result<(), ApiError> { } } +fn validate_tax_rate(value: &str) -> Result<(), ApiError> { + let normalized = normalize_number_string(value); + match normalized.as_str() { + "0" | "0.0" | "0.00" | "7" | "7.0" | "7.00" | "19" | "19.0" | "19.00" => Ok(()), + _ => Err(ApiError::bad_request("Steuersatz muss 0, 7 oder 19 % sein")), + } +} + fn validate_percent(value: &str, field: &str) -> Result<(), ApiError> { - let parsed = value - .parse::() + let parsed = parse_number(value) .map_err(|_| ApiError::bad_request(&format!("Ungültiger Wert: {field}")))?; if (0.0..=100.0).contains(&parsed) { Ok(()) @@ -5675,6 +6090,41 @@ fn validate_percent(value: &str, field: &str) -> Result<(), ApiError> { } } +fn parse_number(value: &str) -> Result { + normalize_number_string(value).parse::() +} + +fn normalize_number_string(value: &str) -> String { + let trimmed = value.trim().replace(' ', ""); + if trimmed.contains(',') { + trimmed.replace('.', "").replace(',', ".") + } else if trimmed.matches('.').count() > 1 { + trimmed.replace('.', "") + } else if is_german_thousands_without_decimal(&trimmed) { + trimmed.replace('.', "") + } else { + trimmed + } +} + +fn is_german_thousands_without_decimal(value: &str) -> bool { + let unsigned = value.strip_prefix('-').unwrap_or(value); + let Some((head, tail)) = unsigned.split_once('.') else { + return false; + }; + !head.is_empty() + && head.len() <= 3 + && head.chars().all(|character| character.is_ascii_digit()) + && tail.len() == 3 + && tail.chars().all(|character| character.is_ascii_digit()) +} + +fn normalize_optional_number(value: Option<&str>) -> Option { + value + .map(normalize_number_string) + .filter(|value| !value.trim().is_empty()) +} + fn validate_number_range(payload: &NumberRangeRequest) -> Result<(), ApiError> { if !payload.pattern.contains("{counter}") { return Err(ApiError::bad_request( @@ -6183,6 +6633,9 @@ async fn provision_company_schema_tx( include_str!("../company-migrations/0010_communications_documents.sql"), include_str!("../company-migrations/0011_user_settings.sql"), include_str!("../company-migrations/0012_item_supplier_prices.sql"), + include_str!("../company-migrations/0013_item_unit_default.sql"), + include_str!("../company-migrations/0014_activity_line_sources.sql"), + include_str!("../company-migrations/0015_activity_status_active_inactive.sql"), ] { let sql = template .lines() diff --git a/desktop-client/src/main.rs b/desktop-client/src/main.rs index 2dd48e0..4acec19 100644 --- a/desktop-client/src/main.rs +++ b/desktop-client/src/main.rs @@ -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::() { + 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::() { + 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::(); + 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 = self + let mut filtered_items: Vec = 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, unit: String, tax_rate: String, default_purchase_price: Option, default_sales_price: Option, status: String, + supplier_prices: Vec, +} + +#[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, +} + +#[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, unit: String, tax_rate: String, purchase_price: Option, sales_price: Option, + supplier_number: Option, + supplier_item_number: Option, + currency: Option, action: String, error: Option, } diff --git a/web-frontend/src/api.ts b/web-frontend/src/api.ts index 5644af5..50ef647 100644 --- a/web-frontend/src/api.ts +++ b/web-frontend/src/api.ts @@ -35,13 +35,13 @@ async function apiRequest(method: string, path: string, body?: unknown): Prom }); const text = await response.text(); - const data = text ? JSON.parse(text) : {}; + const data = text ? parseResponseBody(text) : {}; if (!response.ok) { - return { ok: false, message: data.message ?? `HTTP ${response.status}` }; + return { ok: false, message: responseMessage(data, text, response.status) }; } - return { ok: true, data }; + return { ok: true, data: data as T }; } catch (error) { return { ok: false, @@ -49,3 +49,20 @@ async function apiRequest(method: string, path: string, body?: unknown): Prom }; } } + +function parseResponseBody(text: string): Record { + try { + const parsed = JSON.parse(text); + return typeof parsed === "object" && parsed !== null ? parsed as Record : { message: String(parsed) }; + } catch { + return { message: text }; + } +} + +function responseMessage(data: Record, text: string, status: number): string { + for (const key of ["message", "detail"]) { + const value = data[key]; + if (typeof value === "string" && value.trim()) return value; + } + return text || `HTTP ${status}`; +} diff --git a/web-frontend/src/components/SearchSelect.vue b/web-frontend/src/components/SearchSelect.vue index 3f812fb..42bf037 100644 --- a/web-frontend/src/components/SearchSelect.vue +++ b/web-frontend/src/components/SearchSelect.vue @@ -12,10 +12,11 @@ const props = defineProps<{ const emit = defineEmits<{ "update:modelValue": [value: string | null]; - change: []; + change: [value: string | null]; }>(); const query = ref(""); +const open = ref(false); const selected = computed(() => props.options.find((option) => option.id === props.modelValue)); const normalizedQuery = computed(() => query.value.trim().toLowerCase()); @@ -33,7 +34,15 @@ const filteredOptions = computed(() => { function select(value: string | null) { emit("update:modelValue", value); - emit("change"); + emit("change", value); + query.value = ""; + open.value = false; +} + +function closeSoon() { + window.setTimeout(() => { + open.value = false; + }, 120); } @@ -44,11 +53,14 @@ function select(value: string | null) { type="search" :placeholder="placeholder ?? 'Nach Nummer oder Name suchen'" :required="required && !modelValue" + @focus="open = true" + @input="open = true" + @blur="closeSoon" />
Ausgewählt: {{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}
-
+