From 050f93e1fdc7960a408096993ff3de6b1f8ffa0b Mon Sep 17 00:00:00 2001 From: Priec Date: Mon, 3 Aug 2026 15:48:21 +0200 Subject: [PATCH] currency per column in table definition --- common/proto/analytics.proto | 3 +- common/proto/table_definition.proto | 16 ++-- common/proto/table_script.proto | 8 +- common/src/proto/descriptor.bin | Bin 133762 -> 133474 bytes common/src/proto/komp_ac.analytics.rs | 5 +- common/src/proto/komp_ac.table_definition.rs | 18 ++--- common/src/proto/komp_ac.table_script.rs | 8 +- invoice-print-setup.md | 16 ++-- server | 2 +- web/src/pages/add_table/draft.rs | 76 ++++++++----------- web/src/pages/add_table/state.rs | 11 ++- web/src/pages/add_table/ui.rs | 3 +- web/src/pages/analytics/loader.rs | 10 ++- web/src/pages/analytics/state.rs | 1 - web/templates/pages/add_table/builder.html | 14 ++-- web/templates/pages/analytics/catalog.html | 1 - 16 files changed, 87 insertions(+), 105 deletions(-) diff --git a/common/proto/analytics.proto b/common/proto/analytics.proto index 898e7f96..aabd4389 100644 --- a/common/proto/analytics.proto +++ b/common/proto/analytics.proto @@ -30,7 +30,6 @@ message AnalyticsTable { string name = 2; repeated AnalyticsCatalogColumn columns = 3; repeated AnalyticsTableLink links = 4; - string base_currency = 5; } message AnalyticsCatalogColumn { @@ -39,6 +38,8 @@ message AnalyticsCatalogColumn { string field_type = 2; bool is_system = 3; string rounding = 4; + // Canonical ISO-4217 code for MONEY; empty for every other type. + string currency = 5; } message AnalyticsTableLink { diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index b3572b34..38197ffd 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -83,9 +83,6 @@ message PostTableDefinitionRequest { // like "public", "information_schema", or ones starting with "pg_". string profile_name = 5; - // ISO-4217 base currency used by every MONEY column in this table. - string base_currency = 6; - // Columns whose values identify a row to users in pickers, in the order // they are shown. Each must name one of the user-defined columns above. // Empty means the row is identified by its id alone. @@ -94,7 +91,7 @@ message PostTableDefinitionRequest { // ISO-4217 currency the profile keeps its accounting in. A profile is one // accounting entity and keeps one set of books, so this is required only when // the request creates the profile, and is ignored afterwards. It is unrelated - // to base_currency: tables may hold money in any currency, and amounts convert + // to individual MONEY-column currencies: tables may hold money in any currency, and amounts convert // to this one when they reach the ledger. string accounting_currency = 8; } @@ -116,7 +113,6 @@ message CreateInvoiceTemplateTableRequest { string profile_name = 1; string table_name = 2; string typst_source = 3; - string base_currency = 4; repeated string row_display_columns = 5; } @@ -150,9 +146,6 @@ message AddTableColumnsRequest { // Optional indexes for the new columns only. repeated string indexes = 4; - // Required when adding the first MONEY column to a table. - string base_currency = 5; - } enum MoneyRounding { @@ -176,7 +169,7 @@ message ColumnDefinition { // RAW_DATETIME (timezone-free civil datetime) // PHONE (international or national phone number; generates extension/type/country/calling-code companions) // TIME (timezone-free time of day) - // MONEY (= unconstrained NUMERIC; currency comes from the table) + // MONEY (= unconstrained NUMERIC; currency is declared below) // ACCOUNTING (creates schema-managed name, account, debit, and credit fields; // account always selects a row from the profile's managed accounts table; // name is limited to 10 characters and one stored row contributes @@ -197,6 +190,10 @@ message ColumnDefinition { // When true, this numeric column is server-owned and projected from the // profile quantity ledger. bool quantity_ledger = 4; + + // Canonical uppercase ISO-4217 currency code. Required for MONEY and forbidden + // for every other field type. + string currency = 5; } // Response after table creation (success + DDL preview). @@ -299,7 +296,6 @@ message TableDetail { int64 id = 2; repeated ColumnDefinition columns = 3; repeated ScriptInfo scripts = 4; - string base_currency = 5; repeated string row_display_columns = 6; map column_behaviors = 7; string table_kind = 8; diff --git a/common/proto/table_script.proto b/common/proto/table_script.proto index 0f72a3bd..a5ae6163 100644 --- a/common/proto/table_script.proto +++ b/common/proto/table_script.proto @@ -210,8 +210,8 @@ message HydratedColumnValue { string value = 4; // Logical database type used to create a typed client ScriptValue. string field_type = 5; - // Related table's base currency for MONEY values; otherwise empty. - string base_currency = 6; + // Related MONEY column's currency; otherwise empty. + string currency = 6; } // One declared related-collection aggregate input for the client Steel context. @@ -229,8 +229,8 @@ message HydratedAggregateValue { string value = 5; // Logical source-column type; empty for row-only aggregates. string field_type = 6; - // Aggregate table's base currency for MONEY values; otherwise empty. - string base_currency = 7; + // Aggregated MONEY column's currency; otherwise empty. + string currency = 7; } // Complete external dependency snapshot for client-side Steel execution. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 339888cf74b3ebbb87b0e6bd546c5d6f70d2125b..a869dc61b88f216c1c3a27c1b54caf4bd4ca86fe 100644 GIT binary patch delta 7631 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 delta 7947 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 diff --git a/common/src/proto/komp_ac.analytics.rs b/common/src/proto/komp_ac.analytics.rs index 8d6a2731..8d66dd7a 100644 --- a/common/src/proto/komp_ac.analytics.rs +++ b/common/src/proto/komp_ac.analytics.rs @@ -22,8 +22,6 @@ pub struct AnalyticsTable { pub columns: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "4")] pub links: ::prost::alloc::vec::Vec, - #[prost(string, tag = "5")] - pub base_currency: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct AnalyticsCatalogColumn { @@ -36,6 +34,9 @@ pub struct AnalyticsCatalogColumn { pub is_system: bool, #[prost(string, tag = "4")] pub rounding: ::prost::alloc::string::String, + /// Canonical ISO-4217 code for MONEY; empty for every other type. + #[prost(string, tag = "5")] + pub currency: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct AnalyticsTableLink { diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index cd323526..a0900e5a 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -42,9 +42,6 @@ pub struct PostTableDefinitionRequest { /// like "public", "information_schema", or ones starting with "pg\_". #[prost(string, tag = "5")] pub profile_name: ::prost::alloc::string::String, - /// ISO-4217 base currency used by every MONEY column in this table. - #[prost(string, tag = "6")] - pub base_currency: ::prost::alloc::string::String, /// Columns whose values identify a row to users in pickers, in the order /// they are shown. Each must name one of the user-defined columns above. /// Empty means the row is identified by its id alone. @@ -53,7 +50,7 @@ pub struct PostTableDefinitionRequest { /// ISO-4217 currency the profile keeps its accounting in. A profile is one /// accounting entity and keeps one set of books, so this is required only when /// the request creates the profile, and is ignored afterwards. It is unrelated - /// to base_currency: tables may hold money in any currency, and amounts convert + /// to individual MONEY-column currencies: tables may hold money in any currency, and amounts convert /// to this one when they reach the ledger. #[prost(string, tag = "8")] pub accounting_currency: ::prost::alloc::string::String, @@ -80,8 +77,6 @@ pub struct CreateInvoiceTemplateTableRequest { pub table_name: ::prost::alloc::string::String, #[prost(string, tag = "3")] pub typst_source: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub base_currency: ::prost::alloc::string::String, #[prost(string, repeated, tag = "5")] pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } @@ -125,9 +120,6 @@ pub struct AddTableColumnsRequest { /// Optional indexes for the new columns only. #[prost(string, repeated, tag = "4")] pub indexes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Required when adding the first MONEY column to a table. - #[prost(string, tag = "5")] - pub base_currency: ::prost::alloc::string::String, } /// Describes one user-defined column for a table. #[derive(serde::Serialize, serde::Deserialize)] @@ -147,7 +139,7 @@ pub struct ColumnDefinition { /// RAW_DATETIME (timezone-free civil datetime) /// PHONE (international or national phone number; generates extension/type/country/calling-code companions) /// TIME (timezone-free time of day) - /// MONEY (= unconstrained NUMERIC; currency comes from the table) + /// MONEY (= unconstrained NUMERIC; currency is declared below) /// ACCOUNTING (creates schema-managed name, account, debit, and credit fields; /// account always selects a row from the profile's managed accounts table; /// name is limited to 10 characters and one stored row contributes @@ -169,6 +161,10 @@ pub struct ColumnDefinition { /// profile quantity ledger. #[prost(bool, tag = "4")] pub quantity_ledger: bool, + /// Canonical uppercase ISO-4217 currency code. Required for MONEY and forbidden + /// for every other field type. + #[prost(string, tag = "5")] + pub currency: ::prost::alloc::string::String, } /// Response after table creation (success + DDL preview). #[derive(serde::Serialize, serde::Deserialize)] @@ -310,8 +306,6 @@ pub struct TableDetail { pub columns: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "4")] pub scripts: ::prost::alloc::vec::Vec, - #[prost(string, tag = "5")] - pub base_currency: ::prost::alloc::string::String, #[prost(string, repeated, tag = "6")] pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(map = "string, message", tag = "7")] diff --git a/common/src/proto/komp_ac.table_script.rs b/common/src/proto/komp_ac.table_script.rs index ef724177..6eef1846 100644 --- a/common/src/proto/komp_ac.table_script.rs +++ b/common/src/proto/komp_ac.table_script.rs @@ -168,9 +168,9 @@ pub struct HydratedColumnValue { /// Logical database type used to create a typed client ScriptValue. #[prost(string, tag = "5")] pub field_type: ::prost::alloc::string::String, - /// Related table's base currency for MONEY values; otherwise empty. + /// Related MONEY column's currency; otherwise empty. #[prost(string, tag = "6")] - pub base_currency: ::prost::alloc::string::String, + pub currency: ::prost::alloc::string::String, } /// One declared related-collection aggregate input for the client Steel context. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -194,9 +194,9 @@ pub struct HydratedAggregateValue { /// Logical source-column type; empty for row-only aggregates. #[prost(string, tag = "6")] pub field_type: ::prost::alloc::string::String, - /// Aggregate table's base currency for MONEY values; otherwise empty. + /// Aggregated MONEY column's currency; otherwise empty. #[prost(string, tag = "7")] - pub base_currency: ::prost::alloc::string::String, + pub currency: ::prost::alloc::string::String, } /// Complete external dependency snapshot for client-side Steel execution. #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/invoice-print-setup.md b/invoice-print-setup.md index 14b254e6..373ae277 100644 --- a/invoice-print-setup.md +++ b/invoice-print-setup.md @@ -146,7 +146,6 @@ grpcurl -plaintext -H "$AUTH_HEADER" \ "profile_name":"fakturacia_2026", "table_name":"faktura", "row_display_column":"cislo", - "base_currency":"EUR", "links":[ {"linked_table_name":"dodavatel","required":true}, {"linked_table_name":"odberatel","required":true} @@ -158,9 +157,9 @@ grpcurl -plaintext -H "$AUTH_HEADER" \ {"name":"datum_splatnosti","field_type":"DATE"}, {"name":"variabilny_symbol","field_type":"TEXT"}, {"name":"sposob_uhrady","field_type":"TEXT"}, - {"name":"zaklad_dane","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, - {"name":"dph_celkom","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, - {"name":"celkom_spolu","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, + {"name":"zaklad_dane","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, + {"name":"dph_celkom","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, + {"name":"celkom_spolu","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, {"name":"poznamka","field_type":"TEXT"} ] }' \ @@ -176,17 +175,16 @@ grpcurl -plaintext -H "$AUTH_HEADER" \ "profile_name":"fakturacia_2026", "table_name":"polozka", "row_display_column":"nazov", - "base_currency":"EUR", "links":[{"linked_table_name":"faktura","required":true}], "columns":[ {"name":"nazov","field_type":"TEXT"}, {"name":"mnozstvo","field_type":"DECIMAL(12,3)"}, {"name":"mj","field_type":"TEXT"}, - {"name":"cena","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP"}, + {"name":"cena","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP"}, {"name":"dph","field_type":"DECIMAL(5,2)"}, - {"name":"bez","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, - {"name":"dph_suma","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, - {"name":"spolu","field_type":"MONEY","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true} + {"name":"bez","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, + {"name":"dph_suma","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true}, + {"name":"spolu","field_type":"MONEY","currency":"EUR","rounding":"MONEY_ROUNDING_HALF_UP","recompute_on_dependency_change":true} ] }' \ "$GRPC_ADDR" komp_ac.table_definition.TableDefinition/PostTableDefinition diff --git a/server b/server index 453d16c4..c637ba78 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 453d16c4308393f1d612998c6ed70e0a4a5a814a +Subproject commit c637ba7883e613ffb346ed6967a8add06139a676 diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index dd260613..93b420e1 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -140,6 +140,7 @@ pub(crate) struct ColumnDefinition { pub indexed: bool, pub quantity_ledger: bool, pub money_mode: MoneyMode, + pub currency: String, } impl ColumnDefinition { @@ -147,9 +148,9 @@ impl ColumnDefinition { pub(crate) fn option_label(&self) -> String { let is_money = self.data_type.eq_ignore_ascii_case("money"); match (self.indexed, is_money) { - (true, true) => format!("indexed, {}", self.money_mode.label()), + (true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()), (true, false) => "indexed".to_string(), - (false, true) => self.money_mode.label().to_string(), + (false, true) => format!("{}, {}", self.currency, self.money_mode.label()), (false, false) => String::new(), } } @@ -181,7 +182,6 @@ pub(crate) struct TableDraft { pub accounting_currency: String, pub table_name: String, - pub base_currency: String, // The column-input panel: one pending column being described. pub column_name_input: String, @@ -191,6 +191,7 @@ pub(crate) struct TableDraft { pub column_indexing_input: String, pub column_quantity_ledger_input: String, pub column_rounding_input: String, + pub column_currency_input: String, pub columns: Vec, pub links: Vec, @@ -208,10 +209,10 @@ impl TableDraft { pub(crate) fn new() -> Self { Self { accounting_currency: "EUR".to_string(), - base_currency: "EUR".to_string(), column_indexing_input: "no".to_string(), column_quantity_ledger_input: "no".to_string(), column_rounding_input: "none".to_string(), + column_currency_input: "EUR".to_string(), ..Self::default() } } @@ -276,10 +277,6 @@ impl TableDraft { self.is_money_column_input() } - pub(crate) fn show_base_currency(&self) -> bool { - self.is_money_column_input() || self.money_column_count() > 0 - } - // ---- mutations ------------------------------------------------------- /// Appends the pending column, then clears the input panel. @@ -320,6 +317,11 @@ impl TableDraft { let is_money = column_type.eq_ignore_ascii_case("money") || column_type.eq_ignore_ascii_case("accounting"); + let currency = if is_money { + normalize_currency_input(&self.column_currency_input)? + } else { + String::new() + }; self.columns.push(ColumnDefinition { name: column_name.clone(), data_type: column_type, @@ -333,6 +335,7 @@ impl TableDraft { } else { MoneyMode::Exact }, + currency, }); self.clear_column_inputs(); @@ -347,6 +350,7 @@ impl TableDraft { self.column_indexing_input = "no".to_string(); self.column_quantity_ledger_input = "no".to_string(); self.column_rounding_input = "none".to_string(); + self.column_currency_input = "EUR".to_string(); } /// Removes one column, and drops it from the display columns with it. @@ -440,16 +444,6 @@ impl TableDraft { .any(|name| name == &self.table_name) } - pub(crate) fn money_column_count(&self) -> usize { - self.columns - .iter() - .filter(|column| { - column.data_type.eq_ignore_ascii_case("money") - || column.data_type.eq_ignore_ascii_case("accounting") - }) - .count() - } - pub(crate) fn selected_index_names(&self) -> Vec { self.columns .iter() @@ -561,9 +555,6 @@ impl TableDraft { return Err(format!("Column `{}`: {error}", column.name)); } } - if let Some(error) = validate_base_currency(self) { - return Err(error); - } Ok(()) } @@ -585,6 +576,7 @@ impl TableDraft { MoneyMode::Exact => MoneyRounding::None.into(), }, quantity_ledger: column.quantity_ledger, + currency: column.currency.clone(), }) .collect(), indexes: self.selected_index_names(), @@ -597,11 +589,6 @@ impl TableDraft { required: link.mode.is_required(), }) .collect(), - base_currency: if self.money_column_count() == 0 { - String::new() - } else { - self.base_currency.trim().to_ascii_uppercase() - }, accounting_currency: if self.creating_new_profile { self.accounting_currency.trim().to_ascii_uppercase() } else { @@ -612,15 +599,12 @@ impl TableDraft { } } -pub(crate) fn validate_base_currency(draft: &TableDraft) -> Option { - if draft.money_column_count() == 0 { - return None; +fn normalize_currency_input(value: &str) -> Result { + let currency = value.trim().to_ascii_uppercase(); + if rusty_money::iso::find(¤cy).is_none() { + return Err("Currency must be a three-letter ISO-4217 code".to_string()); } - let currency = draft.base_currency.trim(); - if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) { - return Some("Base currency must be a three-letter ISO-4217 code".to_string()); - } - None + Ok(currency) } pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option { @@ -720,6 +704,11 @@ mod tests { indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, + currency: if matches!(data_type, "money" | "accounting") { + "EUR".to_string() + } else { + String::new() + }, }); draft } @@ -795,14 +784,16 @@ mod tests { } #[test] - fn money_columns_require_a_valid_base_currency() { - let mut draft = draft_with_column("total", "money"); - draft.base_currency = "EU".to_string(); - assert!(draft.validate().is_err()); + fn money_columns_require_a_valid_currency() { + let mut draft = TableDraft::new(); + draft.column_name_input = "total".to_string(); + draft.column_type_input = "money".to_string(); + draft.column_currency_input = "EU".to_string(); + assert!(draft.add_column_from_inputs().is_err()); - draft.base_currency = "eur".to_string(); - assert!(draft.validate().is_ok()); - assert_eq!(draft.into_request().unwrap().base_currency, "EUR"); + draft.column_currency_input = "eur".to_string(); + draft.add_column_from_inputs().unwrap(); + assert_eq!(draft.columns[0].currency, "EUR"); } #[test] @@ -826,8 +817,6 @@ mod tests { assert_eq!(request.accounting_currency, ""); assert_eq!(request.profile_name, "billing"); - // No money column, so no base currency either. - assert_eq!(request.base_currency, ""); } #[test] @@ -886,6 +875,7 @@ mod tests { indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, + currency: String::new(), }); draft.toggle_row_display_candidate(2); // issued_on diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index eff4e24a..94a53241 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -28,8 +28,6 @@ pub(crate) struct BuilderForm { pub accounting_currency: String, #[serde(default)] pub table_name: String, - #[serde(default)] - pub base_currency: String, // The pending column being described in the input panel. #[serde(default)] @@ -46,6 +44,8 @@ pub(crate) struct BuilderForm { pub column_quantity_ledger_input: String, #[serde(default)] pub column_rounding_input: String, + #[serde(default)] + pub column_currency_input: String, // One entry per already-added column, in order. #[serde(default)] @@ -58,6 +58,8 @@ pub(crate) struct BuilderForm { pub column_quantity_ledger: Vec, #[serde(default)] pub column_rounding: Vec, + #[serde(default)] + pub column_currencies: Vec, // One entry per link target offered by the profile, in order. #[serde(default)] @@ -92,6 +94,7 @@ impl BuilderForm { self.column_indexed.len(), self.column_quantity_ledger.len(), self.column_rounding.len(), + self.column_currencies.len(), ] .into_iter() .min() @@ -108,6 +111,7 @@ impl BuilderForm { } else { MoneyMode::Exact }, + currency: self.column_currencies[index].clone(), }) .collect::>(); @@ -138,7 +142,6 @@ impl BuilderForm { creating_new_profile, accounting_currency: self.accounting_currency.clone(), table_name: self.table_name.clone(), - base_currency: self.base_currency.clone(), column_name_input: self.column_name_input.clone(), column_type_input: self.column_type_input.clone(), temporal_type_input: self.temporal_type_input.clone(), @@ -146,6 +149,7 @@ impl BuilderForm { column_indexing_input: self.column_indexing_input.clone(), column_quantity_ledger_input: self.column_quantity_ledger_input.clone(), column_rounding_input: self.column_rounding_input.clone(), + column_currency_input: self.column_currency_input.clone(), columns, links, row_display_columns, @@ -231,6 +235,7 @@ mod tests { column_indexed: vec!["yes".into(), "no".into()], column_quantity_ledger: vec!["no".into(), "no".into()], column_rounding: vec!["exact".into(), "half-up".into()], + column_currencies: vec![String::new(), "EUR".into()], link_tables: vec!["customer".into(), "project".into()], link_modes: vec!["required".into(), "none".into()], row_display_columns: vec!["number".into()], diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index 2d1c1fba..8a9764e5 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -70,6 +70,7 @@ mod tests { indexed: true, quantity_ledger: false, money_mode: MoneyMode::Exact, + currency: String::new(), }); draft.set_available_relation_tables(vec!["customer".to_string()]); draft.cycle_link_mode(0); @@ -126,7 +127,7 @@ mod tests { state.draft.column_type_input = "gtin".to_string(); assert!(render_builder(&state).contains(r#"name="gtin_type_input""#)); - // Money reveals rounding, and the base currency becomes editable. + // Money reveals its currency and rounding inputs. state.draft.column_type_input = "money".to_string(); let html = render_builder(&state); assert!(html.contains(r#"name="column_rounding_input""#)); diff --git a/web/src/pages/analytics/loader.rs b/web/src/pages/analytics/loader.rs index 5f5255a9..84ce9e22 100644 --- a/web/src/pages/analytics/loader.rs +++ b/web/src/pages/analytics/loader.rs @@ -144,7 +144,6 @@ fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> CatalogView { fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView { CatalogTableView { name: table.name.clone(), - base_currency: table.base_currency.clone(), starter_query: format!("SELECT *\nFROM {}\nLIMIT 100;", quote_identifier(&table.name)), columns: table .columns @@ -157,6 +156,9 @@ fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView { if !column.rounding.is_empty() { details.push_str(&format!(", rounding {}", column.rounding)); } + if !column.currency.is_empty() { + details.push_str(&format!(", currency {}", column.currency)); + } CatalogColumnView { name: column.name.clone(), insert_text: quote_identifier(&column.name), @@ -193,9 +195,6 @@ AVAILABLE ANALYTICS SCHEMA\n", for table in &catalog.tables { text.push_str(&format!("\nTABLE {}\n", quote_identifier(&table.name))); - if !table.base_currency.is_empty() { - text.push_str(&format!(" Base currency: {}\n", table.base_currency)); - } text.push_str(" Columns:\n"); for column in &table.columns { text.push_str(&format!( @@ -209,6 +208,9 @@ AVAILABLE ANALYTICS SCHEMA\n", if !column.rounding.is_empty() { text.push_str(&format!(" [rounding: {}]", column.rounding)); } + if !column.currency.is_empty() { + text.push_str(&format!(" [currency: {}]", column.currency)); + } text.push('\n'); } if !table.links.is_empty() { diff --git a/web/src/pages/analytics/state.rs b/web/src/pages/analytics/state.rs index c4b5c03d..0906cad2 100644 --- a/web/src/pages/analytics/state.rs +++ b/web/src/pages/analytics/state.rs @@ -48,7 +48,6 @@ pub(crate) struct CatalogView { pub(crate) struct CatalogTableView { pub name: String, - pub base_currency: String, pub starter_query: String, pub columns: Vec, pub links: Vec, diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 2762eadc..d6123d19 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -48,15 +48,6 @@ hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "refresh"}'> - {% if page.draft.show_base_currency() %} - - {% else %} - - {% endif %} @@ -100,6 +91,10 @@ {% endif %} {% if page.draft.show_rounding() %} +