From af928a838fbf510e9b18874db40ef792f8ee9972 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 4 Jun 2026 21:47:42 +0200 Subject: [PATCH] feat: enhance UI and functionality for price rules, quotes, and suppliers - Updated PriceRulesPage.vue to replace "Neu" button with a more intuitive "+" button for creating new rules. - Enhanced QuotesPage.vue with improved handling of quote lines, including read-only states, unique line validation, and better formatting for monetary values. - Added accordion sections for better organization of quote details and improved user experience. - Updated SuppliersPage.vue to replace "Neu" button with a "+" button for adding new suppliers. - Introduced new database migrations to add price category and default sales price fields to activities, and to include 'invoice_created' status in quotes. --- .../0016_activity_price_fields.sql | 17 ++ .../0017_quote_invoice_created_status.sql | 7 + backend/src/api.rs | 253 +++++++++++++++-- desktop-client/src/main.rs | 67 ++++- web-frontend/src/styles.css | 154 +++++++++- web-frontend/src/types.ts | 2 + web-frontend/src/views/ActivitiesPage.vue | 23 +- web-frontend/src/views/ApiConnectorsPage.vue | 2 +- .../src/views/CashDiscountTermsPage.vue | 2 +- web-frontend/src/views/CommunicationsPage.vue | 4 +- web-frontend/src/views/CustomersPage.vue | 2 +- web-frontend/src/views/DocumentsPage.vue | 4 +- .../src/views/IncomingInvoicesPage.vue | 121 ++++++-- web-frontend/src/views/ItemsPage.vue | 12 +- .../src/views/OutgoingInvoicesPage.vue | 217 ++++++++++---- web-frontend/src/views/PriceRulesPage.vue | 2 +- web-frontend/src/views/QuotesPage.vue | 264 ++++++++++++++---- web-frontend/src/views/SuppliersPage.vue | 2 +- 18 files changed, 970 insertions(+), 185 deletions(-) create mode 100644 backend/company-migrations/0016_activity_price_fields.sql create mode 100644 backend/company-migrations/0017_quote_invoice_created_status.sql diff --git a/backend/company-migrations/0016_activity_price_fields.sql b/backend/company-migrations/0016_activity_price_fields.sql new file mode 100644 index 0000000..4acfa02 --- /dev/null +++ b/backend/company-migrations/0016_activity_price_fields.sql @@ -0,0 +1,17 @@ +alter table {schema}.activities + add column if not exists price_category text not null default 'h', + add column if not exists default_sales_price numeric(14, 4); + +alter table {schema}.activities + drop constraint if exists activities_price_category_valid; + +alter table {schema}.activities + add constraint activities_price_category_valid check (price_category in ('h', 'tag', 'pauschal')); + +alter table {schema}.activities + drop constraint if exists activities_default_sales_price_non_negative; + +alter table {schema}.activities + add constraint activities_default_sales_price_non_negative check ( + default_sales_price is null or default_sales_price >= 0 + ); diff --git a/backend/company-migrations/0017_quote_invoice_created_status.sql b/backend/company-migrations/0017_quote_invoice_created_status.sql new file mode 100644 index 0000000..5128606 --- /dev/null +++ b/backend/company-migrations/0017_quote_invoice_created_status.sql @@ -0,0 +1,7 @@ +alter table {schema}.quotes + drop constraint if exists quotes_status_valid; + +alter table {schema}.quotes + add constraint quotes_status_valid check ( + status in ('draft', 'sent', 'accepted', 'rejected', 'expired', 'cancelled', 'invoice_created') + ); diff --git a/backend/src/api.rs b/backend/src/api.rs index 37e3f5d..a26b772 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -225,6 +225,14 @@ fn default_line_kind() -> String { "item".to_string() } +fn default_activity_type() -> String { + "work_step".to_string() +} + +fn default_price_category() -> String { + "h".to_string() +} + fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -659,10 +667,15 @@ pub struct DocumentAuditLogResponse { pub struct ActivityRequest { #[serde(default)] pub activity_number: Option, + #[serde(default = "default_activity_type")] pub activity_type: String, pub title: String, pub body: String, pub status: String, + #[serde(default = "default_price_category")] + pub price_category: String, + #[serde(default, deserialize_with = "deserialize_optional_string_or_number")] + pub default_sales_price: Option, pub priority: String, pub due_at: Option>, } @@ -675,6 +688,8 @@ pub struct ActivityResponse { pub title: String, pub body: String, pub status: String, + pub price_category: String, + pub default_sales_price: Option, pub priority: String, pub due_at: Option>, } @@ -1959,6 +1974,7 @@ pub async fn update_quote( ) -> Result, ApiError> { let db = state.db()?; let context = require_permission(db, &headers, "quotes.write").await?; + ensure_quote_editable(db, &context.schema_name, quote_id).await?; let mut payload = payload; apply_customer_terms_to_quote(db, &context.schema_name, &mut payload).await?; validate_quote_request(&payload)?; @@ -1974,6 +1990,7 @@ pub async fn delete_quote( ) -> Result, ApiError> { let db = state.db()?; let context = require_permission(db, &headers, "quotes.delete").await?; + ensure_quote_editable(db, &context.schema_name, quote_id).await?; let sql = format!( "update {schema}.quotes set status='cancelled', updated_at=now() where id=$1", schema = context.schema_name @@ -1992,6 +2009,11 @@ pub async fn convert_quote_to_outgoing_invoice( let db = state.db()?; let context = require_permission(db, &headers, "quotes.convert_to_invoice").await?; let quote = load_quote_by_id(db, &state.crypto, &context.schema_name, quote_id).await?; + if quote.status != "accepted" { + return Err(ApiError::bad_request( + "Nur angenommene Angebote können in eine Rechnung umgewandelt werden", + )); + } let invoice_id = Uuid::new_v4(); let invoice = OutgoingInvoiceRequest { invoice_number: next_number(db, &context.schema_name, "outgoing_invoices").await?, @@ -2020,6 +2042,12 @@ pub async fn convert_quote_to_outgoing_invoice( }; validate_outgoing_invoice_request(&invoice)?; write_outgoing_invoice(db, &state.crypto, &context, invoice_id, &invoice, true).await?; + ensure_safe_schema_name(&context.schema_name)?; + let status_sql = format!( + "update {schema}.quotes set status='invoice_created', updated_at=now() where id=$1", + schema = context.schema_name + ); + sqlx::query(&status_sql).bind(quote_id).execute(db).await?; emit_change(&state, "Angebot in Rechnung umgewandelt"); Ok(Json(outgoing_invoice_response(invoice_id, invoice, None))) } @@ -2101,11 +2129,21 @@ pub async fn delete_outgoing_invoice( let context = require_permission(db, &headers, "outgoing_invoices.delete").await?; ensure_outgoing_invoice_editable(db, &context.schema_name, invoice_id).await?; let sql = format!( - "update {schema}.outgoing_invoices set status='cancelled', updated_at=now() where id=$1", + "update {schema}.outgoing_invoices set status='cancelled', updated_at=now() where id=$1 returning source_quote_id", schema = context.schema_name ); - let result = sqlx::query(&sql).bind(invoice_id).execute(db).await?; - ensure_changed(result.rows_affected(), "Ausgangsrechnung nicht gefunden")?; + let source_quote_id = sqlx::query_scalar::<_, Option>(&sql) + .bind(invoice_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Ausgangsrechnung nicht gefunden"))?; + if let Some(quote_id) = source_quote_id { + let quote_sql = format!( + "update {schema}.quotes set status='accepted', updated_at=now() where id=$1 and status='invoice_created'", + schema = context.schema_name + ); + sqlx::query("e_sql).bind(quote_id).execute(db).await?; + } emit_change(&state, "Ausgangsrechnung storniert"); Ok(Json(json!({ "deleted": true, "id": invoice_id }))) } @@ -2192,6 +2230,7 @@ pub async fn update_incoming_invoice( ) -> Result, ApiError> { let db = state.db()?; let context = require_permission(db, &headers, "incoming_invoices.write").await?; + ensure_incoming_invoice_editable(db, &context.schema_name, invoice_id).await?; let mut payload = payload; apply_supplier_terms_to_incoming_invoice(db, &context.schema_name, &mut payload).await?; validate_incoming_invoice_request(&payload)?; @@ -2207,6 +2246,7 @@ pub async fn delete_incoming_invoice( ) -> Result, ApiError> { let db = state.db()?; let context = require_permission(db, &headers, "incoming_invoices.delete").await?; + ensure_incoming_invoice_editable(db, &context.schema_name, invoice_id).await?; let sql = format!( "update {schema}.incoming_invoices set status='cancelled', updated_at=now() where id=$1", schema = context.schema_name @@ -2979,8 +3019,25 @@ pub async fn delete_item( ) -> Result, ApiError> { let db = state.db()?; let context = require_permission(db, &headers, "items.delete").await?; - set_record_inactive(db, &context.schema_name, "items", item_id).await?; - emit_change(&state, "Artikel deaktiviert"); + ensure_item_unused(db, &context.schema_name, item_id).await?; + let delete_supplier_prices = format!( + "delete from {schema}.item_supplier_prices where item_id=$1", + schema = context.schema_name + ); + sqlx::query(&delete_supplier_prices) + .bind(item_id) + .execute(db) + .await?; + let delete_price_history = format!( + "delete from {schema}.item_price_history where item_id=$1", + schema = context.schema_name + ); + sqlx::query(&delete_price_history) + .bind(item_id) + .execute(db) + .await?; + delete_record(db, &context.schema_name, "items", item_id, "Artikel nicht gefunden").await?; + emit_change(&state, "Artikel gelöscht"); Ok(Json(json!({ "deleted": true, "id": item_id }))) } @@ -2993,7 +3050,8 @@ pub async fn list_activities( let sql = format!( r#" select id, activity_number, activity_type, title_ciphertext, title_nonce, title_key_id, - body_ciphertext, body_nonce, body_key_id, status, priority, due_at + body_ciphertext, body_nonce, body_key_id, status, price_category, + default_sales_price::text default_sales_price, priority, due_at from {schema}.activities order by updated_at desc, due_at "#, schema = context.schema_name @@ -3051,13 +3109,24 @@ pub async fn delete_activity( ) -> Result, ApiError> { let db = state.db()?; let context = require_permission(db, &headers, "activities.delete").await?; - let sql = format!( - "update {schema}.activities set status = 'inactive', updated_at = now() where id = $1", + ensure_activity_unused(db, &context.schema_name, activity_id).await?; + let delete_links = format!( + "delete from {schema}.activity_links where activity_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 deaktiviert"); + sqlx::query(&delete_links) + .bind(activity_id) + .execute(db) + .await?; + delete_record( + db, + &context.schema_name, + "activities", + activity_id, + "Aktivität nicht gefunden", + ) + .await?; + emit_change(&state, "Aktivität gelöscht"); Ok(Json(json!({ "deleted": true, "id": activity_id }))) } @@ -3748,6 +3817,7 @@ fn validate_quote_request(payload: &QuoteRequest) -> Result<(), ApiError> { "draft", "sent", "accepted", + "invoice_created", "rejected", "expired", "cancelled", @@ -3965,6 +4035,57 @@ async fn ensure_activity_exists( } } +async fn ensure_item_unused(db: &PgPool, schema_name: &str, item_id: Uuid) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select + exists(select 1 from {schema}.quote_items where item_id=$1) + or exists(select 1 from {schema}.outgoing_invoice_items where item_id=$1) + or exists(select 1 from {schema}.incoming_invoice_items where item_id=$1) + "#, + schema = schema_name + ); + if sqlx::query_scalar::<_, bool>(&sql) + .bind(item_id) + .fetch_one(db) + .await? + { + Err(ApiError::bad_request( + "Artikel wurde bereits verwendet und kann nur deaktiviert werden", + )) + } else { + Ok(()) + } +} + +async fn ensure_activity_unused( + db: &PgPool, + schema_name: &str, + activity_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + r#" + select + exists(select 1 from {schema}.quote_items where activity_id=$1) + or exists(select 1 from {schema}.outgoing_invoice_items where activity_id=$1) + "#, + schema = schema_name + ); + if sqlx::query_scalar::<_, bool>(&sql) + .bind(activity_id) + .fetch_one(db) + .await? + { + Err(ApiError::bad_request( + "Aktivität wurde bereits verwendet und kann nur deaktiviert werden", + )) + } else { + Ok(()) + } +} + fn validate_line_source( line_kind: &str, item_id: Option, @@ -4405,17 +4526,64 @@ async fn ensure_outgoing_invoice_editable( ) -> Result<(), ApiError> { ensure_safe_schema_name(schema_name)?; let sql = format!( - "select finalized_at from {schema}.outgoing_invoices where id=$1", + "select status, finalized_at from {schema}.outgoing_invoices where id=$1", schema = schema_name ); - let finalized_at = sqlx::query_scalar::<_, Option>>(&sql) + let row = sqlx::query(&sql) .bind(invoice_id) .fetch_optional(db) .await? .ok_or_else(|| ApiError::not_found("Ausgangsrechnung nicht gefunden"))?; - if finalized_at.is_some() { + let status: String = row.get("status"); + let finalized_at: Option> = row.get("finalized_at"); + if finalized_at.is_some() || ["finalized", "paid", "cancelled"].contains(&status.as_str()) { Err(ApiError::bad_request( - "Abgeschlossene Rechnungen dürfen nicht geändert werden", + "Abgeschlossene, bezahlte oder stornierte Rechnungen dürfen nicht geändert werden", + )) + } else { + Ok(()) + } +} + +async fn ensure_quote_editable( + db: &PgPool, + schema_name: &str, + quote_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!("select status from {schema}.quotes where id=$1", schema = schema_name); + let status = sqlx::query_scalar::<_, String>(&sql) + .bind(quote_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Angebot nicht gefunden"))?; + if ["invoice_created", "cancelled"].contains(&status.as_str()) { + Err(ApiError::bad_request( + "Angebote mit Rechnung oder stornierte Angebote dürfen nicht geändert werden", + )) + } else { + Ok(()) + } +} + +async fn ensure_incoming_invoice_editable( + db: &PgPool, + schema_name: &str, + invoice_id: Uuid, +) -> Result<(), ApiError> { + ensure_safe_schema_name(schema_name)?; + let sql = format!( + "select status from {schema}.incoming_invoices where id=$1", + schema = schema_name + ); + let status = sqlx::query_scalar::<_, String>(&sql) + .bind(invoice_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::not_found("Eingangsrechnung nicht gefunden"))?; + if ["paid", "cancelled"].contains(&status.as_str()) { + Err(ApiError::bad_request( + "Bezahlte oder stornierte Eingangsrechnungen dürfen nicht geändert werden", )) } else { Ok(()) @@ -5921,6 +6089,12 @@ fn validate_activity_request(payload: &ActivityRequest) -> Result<(), ApiError> if !["active", "inactive"].contains(&payload.status.as_str()) { return Err(ApiError::bad_request("Ungültiger Aktivitätsstatus")); } + if !["h", "tag", "pauschal"].contains(&payload.price_category.as_str()) { + return Err(ApiError::bad_request("Ungültige Preiskategorie")); + } + if let Some(price) = &payload.default_sales_price { + validate_number(price, "Aktivitätspreis")?; + } if !["low", "normal", "high", "critical"].contains(&payload.priority.as_str()) { return Err(ApiError::bad_request("Ungültige Priorität")); } @@ -5943,13 +6117,21 @@ async fn write_activity( }; let sql = if insert { format!( - r#"insert into {schema}.activities (id,activity_number,activity_type,title_ciphertext,title_nonce,title_key_id,body_ciphertext,body_nonce,body_key_id,status,priority,due_at,created_by_user_id) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)"#, + r#"insert into {schema}.activities ( + id, activity_number, activity_type, title_ciphertext, title_nonce, title_key_id, + body_ciphertext, body_nonce, body_key_id, status, price_category, + default_sales_price, priority, due_at, created_by_user_id + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::numeric,$13,$14,$15)"#, schema = context.schema_name ) } else { format!( - r#"update {schema}.activities set activity_number=activity_number,activity_type=$3,title_ciphertext=$4,title_nonce=$5,title_key_id=$6,body_ciphertext=$7,body_nonce=$8,body_key_id=$9,status=$10,priority=$11,due_at=$12,created_by_user_id=coalesce(created_by_user_id,$13),updated_at=now() where id=$1"#, + r#"update {schema}.activities set activity_number=activity_number,activity_type=$3, + title_ciphertext=$4,title_nonce=$5,title_key_id=$6, + body_ciphertext=$7,body_nonce=$8,body_key_id=$9,status=$10, + price_category=$11,default_sales_price=$12::numeric,priority=$13, + due_at=$14,created_by_user_id=coalesce(created_by_user_id,$15), + updated_at=now() where id=$1"#, schema = context.schema_name ) }; @@ -5970,6 +6152,10 @@ async fn write_activity( .bind(body.as_ref().map(|field| field.nonce.clone())) .bind(body.as_ref().map(|field| field.key_id.clone())) .bind(&payload.status) + .bind(&payload.price_category) + .bind(normalize_optional_number( + payload.default_sales_price.as_deref(), + )) .bind(&payload.priority) .bind(payload.due_at) .bind(context.user_id) @@ -5989,6 +6175,8 @@ fn activity_response(id: Uuid, payload: ActivityRequest) -> ActivityResponse { title: payload.title, body: payload.body, status: payload.status, + price_category: payload.price_category, + default_sales_price: payload.default_sales_price, priority: payload.priority, due_at: payload.due_at, } @@ -6017,6 +6205,8 @@ fn activity_from_row( )?, body, status: row.get("status"), + price_category: row.get("price_category"), + default_sales_price: row.get("default_sales_price"), priority: row.get("priority"), due_at: row.get("due_at"), }) @@ -6034,6 +6224,18 @@ async fn set_record_inactive( ensure_changed(result.rows_affected(), "Datensatz nicht gefunden") } +async fn delete_record( + db: &PgPool, + schema_name: &str, + table: &str, + id: Uuid, + not_found_message: &str, +) -> Result<(), ApiError> { + let sql = format!("delete from {schema_name}.{table} where id=$1"); + let result = sqlx::query(&sql).bind(id).execute(db).await?; + ensure_changed(result.rows_affected(), not_found_message) +} + fn ensure_changed(rows_affected: u64, message: &str) -> Result<(), ApiError> { if rows_affected == 0 { Err(ApiError::not_found(message)) @@ -6071,10 +6273,15 @@ 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")), + let parsed = parse_number(value) + .map_err(|_| ApiError::bad_request("Steuersatz muss 0, 7 oder 19 % sein"))?; + if [0.0, 7.0, 19.0] + .iter() + .any(|allowed| (parsed - allowed).abs() < f64::EPSILON) + { + Ok(()) + } else { + Err(ApiError::bad_request("Steuersatz muss 0, 7 oder 19 % sein")) } } @@ -6636,6 +6843,8 @@ async fn provision_company_schema_tx( 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"), + include_str!("../company-migrations/0016_activity_price_fields.sql"), + include_str!("../company-migrations/0017_quote_invoice_created_status.sql"), ] { let sql = template .lines() diff --git a/desktop-client/src/main.rs b/desktop-client/src/main.rs index 4acec19..d513470 100644 --- a/desktop-client/src/main.rs +++ b/desktop-client/src/main.rs @@ -761,6 +761,15 @@ fn apply_quote_item_defaults(line: &mut QuoteItemForm, items: &[Item]) { } } +fn item_unit_label(item_id: &str, items: &[Item]) -> String { + items + .iter() + .find(|item| item.id == item_id) + .map(|item| item.unit.clone()) + .filter(|unit| !unit.trim().is_empty()) + .unwrap_or_else(|| "-".to_string()) +} + 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(); @@ -817,6 +826,9 @@ fn invoice_items_editor( ui.label("Menge"); ui.text_edit_singleline(&mut line.quantity); ui.end_row(); + ui.label("Einheit"); + ui.label(item_unit_label(&line.item_id, items)); + ui.end_row(); ui.label("Preis"); ui.text_edit_singleline(&mut line.unit_price); ui.end_row(); @@ -873,6 +885,9 @@ fn incoming_invoice_items_editor( ui.label("Menge"); ui.text_edit_singleline(&mut line.quantity); ui.end_row(); + ui.label("Einheit"); + ui.label(item_unit_label(line.item_id.as_deref().unwrap_or_default(), items)); + ui.end_row(); ui.label("Preis"); ui.text_edit_singleline(&mut line.unit_price); ui.end_row(); @@ -1617,7 +1632,7 @@ impl CompanyToolApp { }, AdminEvent::ItemDeleted(result) => { self.items_status = result - .map(|_| "Artikel deaktiviert.".to_string()) + .map(|_| "Artikel gelöscht.".to_string()) .unwrap_or_else(|message| message); self.load_items(); } @@ -1711,7 +1726,7 @@ impl CompanyToolApp { }, AdminEvent::ActivityDeleted(result) => { self.activities_status = result - .map(|_| "Aktivität deaktiviert.".to_string()) + .map(|_| "Aktivität gelöscht.".to_string()) .unwrap_or_else(|message| message); self.load_activities(); } @@ -4305,7 +4320,7 @@ impl CompanyToolApp { if ui .add_enabled( self.selected_item_id.is_some(), - egui::Button::new("Deaktivieren"), + egui::Button::new("Löschen"), ) .clicked() { @@ -4869,6 +4884,9 @@ impl CompanyToolApp { ui.label("Menge"); ui.text_edit_singleline(&mut line.quantity); ui.end_row(); + ui.label("Einheit"); + ui.label(item_unit_label(&line.item_id, &items)); + ui.end_row(); ui.label("Preis"); ui.text_edit_singleline(&mut line.unit_price); ui.end_row(); @@ -5245,9 +5263,6 @@ impl CompanyToolApp { self.activity_form.activity_number.as_deref().unwrap_or(""), )); }); - form_row(ui, "Typ", |ui| { - ui.text_edit_singleline(&mut self.activity_form.activity_type); - }); form_row(ui, "Status", |ui| { egui::ComboBox::from_id_salt("activity_status") .selected_text(match self.activity_form.status.as_str() { @@ -5270,6 +5285,34 @@ impl CompanyToolApp { form_row(ui, "Priorität", |ui| { ui.text_edit_singleline(&mut self.activity_form.priority); }); + form_row(ui, "Preiskategorie", |ui| { + egui::ComboBox::from_id_salt("activity_price_category") + .selected_text(match self.activity_form.price_category.as_str() { + "tag" => "Tag", + "pauschal" => "Pauschal", + _ => "h", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.activity_form.price_category, + "h".to_string(), + "h", + ); + ui.selectable_value( + &mut self.activity_form.price_category, + "tag".to_string(), + "Tag", + ); + ui.selectable_value( + &mut self.activity_form.price_category, + "pauschal".to_string(), + "Pauschal", + ); + }); + }); + form_row(ui, "Verkaufspreis", |ui| { + ui.text_edit_singleline(&mut self.activity_form.default_sales_price); + }); form_row(ui, "Beschreibung", |ui| { ui.text_edit_multiline(&mut self.activity_form.body); }); @@ -5280,7 +5323,7 @@ impl CompanyToolApp { if ui .add_enabled( self.selected_activity_id.is_some(), - egui::Button::new("Deaktivieren"), + egui::Button::new("Löschen"), ) .clicked() { @@ -6182,6 +6225,8 @@ struct Activity { title: String, body: String, status: String, + price_category: String, + default_sales_price: Option, priority: String, due_at: Option, } @@ -6192,6 +6237,8 @@ struct ActivityForm { title: String, body: String, status: String, + price_category: String, + default_sales_price: String, priority: String, due_at: Option, } @@ -6199,10 +6246,12 @@ impl Default for ActivityForm { fn default() -> Self { Self { activity_number: None, - activity_type: "task".to_string(), + activity_type: "work_step".to_string(), title: String::new(), body: String::new(), status: "active".to_string(), + price_category: "h".to_string(), + default_sales_price: "0".to_string(), priority: "normal".to_string(), due_at: None, } @@ -6216,6 +6265,8 @@ impl From<&Activity> for ActivityForm { title: value.title.clone(), body: value.body.clone(), status: value.status.clone(), + price_category: value.price_category.clone(), + default_sales_price: value.default_sales_price.clone().unwrap_or_default(), priority: value.priority.clone(), due_at: value.due_at.clone(), } diff --git a/web-frontend/src/styles.css b/web-frontend/src/styles.css index f2756d6..ea8ec0a 100644 --- a/web-frontend/src/styles.css +++ b/web-frontend/src/styles.css @@ -38,6 +38,14 @@ button.secondary { color: #26343b; } +button.add-button { + font-size: 20px; + line-height: 1; + min-height: 34px; + min-width: 38px; + padding: 4px 10px; +} + button:disabled { cursor: not-allowed; opacity: 0.58; @@ -422,7 +430,7 @@ code { border-radius: 8px; display: grid; gap: 12px; - grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px; + grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px; padding: 12px; } @@ -438,13 +446,13 @@ code { font-size: 12px; font-weight: 700; gap: 10px; - grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px; + grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px; padding: 0 12px; text-transform: uppercase; } .position-table-header.compact { - grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px; + grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px; } .position-summary { @@ -474,6 +482,38 @@ code { grid-column: 1 / -1; } +.position-add-row { + display: flex; + justify-content: stretch; + margin-top: 2px; +} + +.position-add-row button { + min-height: 34px; + width: 100%; +} + +.position-add-row .add-button { + width: 100%; +} + +.position-row-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.position-row-actions button { + min-height: 28px; + padding: 2px 7px; +} + +.position-row-actions .icon-button { + font-size: 16px; + line-height: 1; + min-width: 30px; +} + .form-panel { max-width: 720px; } @@ -541,9 +581,14 @@ code { } .section-title { + flex-wrap: wrap; margin-bottom: 14px; } +.section-title button { + flex-shrink: 0; +} + .section-title h2, .split-row h2 { font-size: 18px; @@ -555,6 +600,100 @@ form { gap: 14px; } +.accordion-section { + border: 1px solid #dbe3e6; + border-radius: 8px; + overflow: hidden; +} + +.accordion-section.active { + border-color: #b9d9d4; +} + +.accordion-header { + align-items: center; + background: #f6faf9; + border: 0; + border-radius: 0; + color: #26343b; + display: flex; + justify-content: space-between; + min-height: 44px; + padding: 10px 14px; + text-align: left; + width: 100%; +} + +.accordion-header:hover, +.accordion-section.active .accordion-header { + background: #def4f0; + color: #10545c; + filter: none; +} + +.accordion-header span { + font-size: 16px; + font-weight: 800; +} + +.accordion-header strong { + color: #65757b; + font-size: 12px; +} + +.accordion-body { + padding: 16px; +} + +.accordion-body.sub-panel { + border-top: 1px solid #dbe3e6; + margin-top: 0; +} + +.document-form { + min-height: 100%; + padding-bottom: 84px; + position: relative; +} + +.totals-panel { + align-items: center; + background: #eef8f5; + border: 1px solid #c8e1dc; + border-radius: 8px; + bottom: 0; + box-shadow: 0 -10px 24px rgb(28 43 48 / 8%); + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(150px, 1fr)); + margin-top: 6px; + padding: 12px 14px; + position: sticky; + z-index: 30; +} + +.totals-panel div { + display: grid; + gap: 3px; +} + +.totals-panel span { + color: #65757b; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.totals-panel strong { + color: #172026; + font-size: 18px; +} + +.totals-panel .grand-total strong { + color: #0b6f68; + font-size: 21px; +} + .form-grid { display: grid; gap: 14px; @@ -596,6 +735,15 @@ textarea { padding: 8px 10px; } +.readonly-notice { + background: #fff7e6; + border: 1px solid #efd49a; + border-radius: 8px; + color: #6b4a08; + font-weight: 750; + padding: 10px 12px; +} + .list-search { margin-bottom: 10px; } diff --git a/web-frontend/src/types.ts b/web-frontend/src/types.ts index cbc63bf..f3d9205 100644 --- a/web-frontend/src/types.ts +++ b/web-frontend/src/types.ts @@ -107,6 +107,8 @@ export type Activity = { title: string; body: string; status: string; + price_category: string; + default_sales_price?: string | null; priority: string; due_at?: string | null; }; diff --git a/web-frontend/src/views/ActivitiesPage.vue b/web-frontend/src/views/ActivitiesPage.vue index e739e64..dea5b26 100644 --- a/web-frontend/src/views/ActivitiesPage.vue +++ b/web-frontend/src/views/ActivitiesPage.vue @@ -3,25 +3,33 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { apiDelete, apiGet, apiPost, apiPut } from "../api"; import FormStatus from "../components/FormStatus.vue"; import PageHeader from "../components/PageHeader.vue"; +import { formatDecimal, formatDecimalInput, normalizeDecimal } from "../format"; import { matchesObjectSearch, reserveNextNumber } from "../number-ranges"; import { liveUpdateState } from "../realtime"; import type { Activity } from "../types"; -const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "active", priority: "normal", due_at: null as string | null }); +const emptyForm = () => ({ activity_number: null as string | null, activity_type: "work_step", title: "", body: "", status: "active", price_category: "h", default_sales_price: null as string | null, priority: "normal", due_at: null as string | null }); const records = ref([]); const selectedId = ref(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref(""); const filteredRecords = computed(() => records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value)) ); async function load() { const r = await apiGet("/api/v1/activities"); if (r.ok) records.value = r.data; else { status.value = r.message; kind.value = "error"; } } async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.activity_number = await reserveNextNumber("activities"); } -function select(record: Activity) { selectedId.value = record.id; Object.assign(form, record); } -async function save() { const r = selectedId.value ? await apiPut(`/api/v1/activities/${selectedId.value}`, form) : await apiPost("/api/v1/activities", form); status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await load(); } } -async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { form.status = "inactive"; await load(); } } +function select(record: Activity) { selectedId.value = record.id; Object.assign(form, { ...record, default_sales_price: formatDecimal(record.default_sales_price) }); } +async function save() { + const payload = { ...form, default_sales_price: normalizeDecimal(form.default_sales_price) }; + const r = selectedId.value ? await apiPut(`/api/v1/activities/${selectedId.value}`, payload) : await apiPost("/api/v1/activities", payload); + status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; + if (r.ok) { selectedId.value = r.data.id; Object.assign(form, { ...r.data, default_sales_price: formatDecimal(r.data.default_sales_price) }); await load(); } + return r.ok; +} +async function deactivate() { if (!selectedId.value) return; form.status = "inactive"; if (await save()) status.value = "Aktivität deaktiviert."; } +async function remove() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität gelöscht." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = null; Object.assign(form, emptyForm()); await load(); } } onMounted(load); watch(() => liveUpdateState.revision, load);