diff --git a/server b/server index fe581213..e4540055 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit fe58121374af694f5d1cea0a6d1e82bad8511660 +Subproject commit e454005545d3722d72b50f3a160ae345c44777b9 diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index d29b367d..ae1ae317 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -167,10 +167,9 @@ td-add-columns-to = Přidat sloupce do td-append-hint = Sloupce se přidávají na konec. Nic, co už existuje, se nemění a nové sloupce lze indexovat hned při přidání. td-adjust-table-of = Upravit tabulku td-adjust-table-hint = Tato tabulka neobsahuje data, takže můžete přidat nové sloupce nebo označit existující k odebrání. Změna se uloží atomicky. -td-existing-columns = Existující sloupce -td-new-columns = Nové sloupce k přidání td-remove-column = Odebrat td-remove-column-aria = Odebrat { $column } +td-remove-column-confirm = Odebrat sloupec „{ $column }“? Tuto změnu nelze vrátit zpět po uložení. td-save-definition = Uložit definici tabulky td-rename-column = Aliasy sloupců td-rename-hint = Přejmenovává se jen zobrazený název, ne fyzický sloupec pod ním, takže uložená data a skripty zůstávají nedotčené. @@ -922,3 +921,95 @@ validation-err-range-start = Začátek rozsahu vzoru nemůže přesáhnout jeho validation-err-bad-position = Neplatná pozice vzoru '{ $value }'. validation-err-bad-constraint = Neznámé omezení znaků '{ $value }'. validation-err-permission = Vyžaduje se oprávnění ke správě validací. + +# Pracoviště správy konverzních kurzů +rates-title = Konverzní kurzy +rates-eyebrow = Pracoviště správce +rates-description = Nastavte měny profilů, ověřte konverze, zkontrolujte auditní důkazy a sledujte poskytovatele. +rates-updated = Konverzní kurzy byly aktualizovány +rates-could-not-continue = Operaci se nepodařilo dokončit +rates-sections = Části pracoviště konverzních kurzů +rates-tab-configuration = Konfigurace +rates-tab-configuration-sub = Měny a poskytovatelé +rates-tab-preview = Náhled +rates-tab-preview-sub = Zkouška konverze +rates-tab-evidence = Důkazy +rates-tab-evidence-sub = Kontrola uloženého výsledku +rates-tab-providers = Poskytovatelé +rates-tab-providers-sub = Pokrytí a importy +rates-profile-configuration = Konfigurace profilu +rates-no-profiles = Nejsou dostupné žádné účetní profily. +rates-profile = Profil +rates-accounting-currency = Účetní měna +rates-immutable = po vytvoření profilu se nemění +rates-configured-currencies = Cizí měny +rates-registered-providers = Dostupní poskytovatelé +rates-no-foreign-currencies = Pro tento profil zatím není nastavena žádná cizí měna. +rates-foreign-currency = Cizí měna +rates-default-provider = Výchozí poskytovatel +rates-providers = Poskytovatelé +rates-coverage = Ověřené pokrytí +rates-ready = Připraveno +rates-importing = Neúplné pokrytí +rates-make-default = Nastavit jako výchozí +rates-add-binding = Přidat poskytovatele měny +rates-append-only-hint = Konfigurace se pouze rozšiřuje: přidejte dalšího poskytovatele nebo nastavte stávajícího jako výchozího. Existující vazby se nenahrazují. +rates-provider = Poskytovatel +rates-choose-provider = Vyberte poskytovatele +rates-make-default-now = Použít jako výchozího poskytovatele této měny +rates-conversion-preview = Náhled konverze +rates-preview-hint = Ověří stejná pravidla výběru jako účtování bez zápisu účetních údajů. +rates-amount = Původní částka +rates-currency = Původní měna +rates-basis-date = Rozhodné datum konverze +rates-rate-method = Metoda kurzu +rates-method-automatic = Automatický oficiální kurz +rates-method-specific = Oficiální kurz z konkrétního dne zveřejnění +rates-method-saved = Uložený vlastní kurz +rates-method-manual = Jednorázový ruční kurz +rates-date-context = Kontext data +rates-context-ordinary = Běžná transakce — předchozí zveřejnění +rates-context-statement = Výpis — v den nebo před datem +rates-specific-date = Datum zveřejnění +rates-manual-foreign-units = Jednotky cizí měny na účetní jednotku +rates-provider-override = Jiný poskytovatel +rates-use-default-provider = Použít nastaveného výchozího +rates-reason = Důvod +rates-reason-hint = Backend jej vyžaduje, když výjimečný výběr potřebuje odůvodnění. +rates-preview-action = Zobrazit náhled konverze +rates-preview-result = Výsledek náhledu +rates-date-rule = Pravidlo data +rates-rate-date = Použité datum kurzu +rates-audit-details = Auditní podrobnosti +rates-exact-quote = Přesná kotace +rates-determination = Způsob určení +rates-rounding = Způsob zaokrouhlení +rates-observation = ID oficiálního pozorování +rates-import-batch = ID dávky importu +rates-evidence-lookup = Vyhledání důkazů +rates-evidence-hint = Vyhledejte přesnou historii konverzí jedné buňky tabulky a jejích revizí. +rates-table = Tabulka +rates-record-id = ID záznamu +rates-column = Sloupec +rates-find-evidence = Najít důkazy +rates-no-exchange = Tato buňka neobsahuje konverzi směnného kurzu. +rates-no-evidence = Buňka podporuje konverzi, ale nebyl zaznamenán žádný důkaz. +rates-revision = Revize řádku +rates-conversion = Konverze +rates-created = Zaznamenáno +rates-more-evidence = Existují další důkazy; upřesněte vyhledávání nebo pokračujte kurzorem API. +rates-provider-operations = Provoz +rates-provider-health = Stav poskytovatele +rates-provider-health-hint = Ověřené pokrytí, hranice účtování a nedávné pokusy o import oficiálního poskytovatele. +rates-unavailable-title = Konverzní kurzy nejsou dostupné +rates-err-profile-missing = Vybraný profil již neexistuje. Zobrazuje se první dostupný profil. +rates-err-preview-method = Vyberte podporovanou metodu kurzu. +rates-err-record-id = ID záznamu musí být kladné celé číslo. +rates-configuration-added = Poskytovatel { $source } byl přidán pro měnu { $currency }. +rates-source-official = Oficiální +rates-source-saved = Uložený vlastní +rates-source-manual = Jednorázový ruční +rates-rule-previous = Předchozí zveřejnění +rates-rule-on-or-before = V den nebo před datem +rates-rule-specific = Konkrétní datum zveřejnění +rates-unknown = Neznámé diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 134515b3..17288d01 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -169,10 +169,9 @@ td-add-columns-to = Add columns to td-append-hint = Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added. td-adjust-table-of = Adjust table td-adjust-table-hint = This table has no data, so you can add new columns or select existing columns to remove. The change is saved atomically. -td-existing-columns = Existing columns -td-new-columns = New columns to add td-remove-column = Remove td-remove-column-aria = Remove { $column } +td-remove-column-confirm = Remove column "{ $column }"? This can't be undone once you save. td-save-definition = Save table definition td-rename-column = Column aliases td-rename-hint = Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. @@ -906,3 +905,95 @@ validation-err-range-start = Pattern range start cannot exceed its end. validation-err-bad-position = Invalid pattern position '{ $value }'. validation-err-bad-constraint = Unknown character constraint '{ $value }'. validation-err-permission = Validation-management permission is required. + +# Exchange-rate administration workspace +rates-title = Conversion rates +rates-eyebrow = Admin workspace +rates-description = Configure profile currencies, verify conversions, inspect audit evidence, and monitor providers. +rates-updated = Conversion rates updated +rates-could-not-continue = The operation could not be completed +rates-sections = Conversion-rate workspace sections +rates-tab-configuration = Configuration +rates-tab-configuration-sub = Currencies and providers +rates-tab-preview = Preview +rates-tab-preview-sub = Test a conversion +rates-tab-evidence = Evidence +rates-tab-evidence-sub = Inspect a stored result +rates-tab-providers = Providers +rates-tab-providers-sub = Coverage and imports +rates-profile-configuration = Profile configuration +rates-no-profiles = No accounting profiles are available. +rates-profile = Profile +rates-accounting-currency = Accounting currency +rates-immutable = fixed after profile creation +rates-configured-currencies = Foreign currencies +rates-registered-providers = Available providers +rates-no-foreign-currencies = No foreign currency has been configured for this profile yet. +rates-foreign-currency = Foreign currency +rates-default-provider = Default provider +rates-providers = Providers +rates-coverage = Verified coverage +rates-ready = Ready +rates-importing = Coverage incomplete +rates-make-default = Make default +rates-add-binding = Add currency provider +rates-append-only-hint = Configuration is append-only: add another provider or make an existing provider the default. Existing bindings are not replaced. +rates-provider = Provider +rates-choose-provider = Choose a provider +rates-make-default-now = Use as the default provider for this currency +rates-conversion-preview = Conversion preview +rates-preview-hint = Exercise the same selection rules as posting without writing accounting data. +rates-amount = Original amount +rates-currency = Original currency +rates-basis-date = Conversion basis date +rates-rate-method = Rate method +rates-method-automatic = Automatic official rate +rates-method-specific = Official rate from a specific publication date +rates-method-saved = Saved custom rate +rates-method-manual = One-off manual rate +rates-date-context = Date context +rates-context-ordinary = Ordinary transaction — previous publication +rates-context-statement = Statement — on or before date +rates-specific-date = Publication date +rates-manual-foreign-units = Foreign units per accounting unit +rates-provider-override = Provider override +rates-use-default-provider = Use the configured default +rates-reason = Reason +rates-reason-hint = Required by the backend when an exceptional selection needs justification. +rates-preview-action = Preview conversion +rates-preview-result = Preview result +rates-date-rule = Date rule +rates-rate-date = Applied rate date +rates-audit-details = Audit details +rates-exact-quote = Exact quote +rates-determination = Determination method +rates-rounding = Rounding method +rates-observation = Official observation ID +rates-import-batch = Import batch ID +rates-evidence-lookup = Evidence lookup +rates-evidence-hint = Look up the exact conversion history for one table cell and its revisions. +rates-table = Table +rates-record-id = Record ID +rates-column = Column +rates-find-evidence = Find evidence +rates-no-exchange = This cell does not contain an exchange-rate conversion. +rates-no-evidence = The cell is exchange-enabled, but no conversion evidence was recorded. +rates-revision = Row revision +rates-conversion = Conversion +rates-created = Recorded at +rates-more-evidence = More evidence exists; narrow the lookup or use the API cursor to continue. +rates-provider-operations = Operations +rates-provider-health = Provider health +rates-provider-health-hint = Verified coverage, posting horizons, and recent import attempts for the official provider. +rates-unavailable-title = Conversion rates are unavailable +rates-err-profile-missing = The selected profile no longer exists. The first available profile is shown instead. +rates-err-preview-method = Choose a supported rate method. +rates-err-record-id = Record ID must be a positive whole number. +rates-configuration-added = Added provider { $source } for { $currency }. +rates-source-official = Official +rates-source-saved = Saved custom +rates-source-manual = One-off manual +rates-rule-previous = Previous publication +rates-rule-on-or-before = On or before date +rates-rule-specific = Specific publication date +rates-unknown = Unknown diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index 7f7a4859..f95ef795 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -167,10 +167,9 @@ td-add-columns-to = Pridať stĺpce do td-append-hint = Stĺpce sa pridávajú na koniec. Nič, čo už existuje, sa nemení a nové stĺpce možno indexovať hneď pri pridaní. td-adjust-table-of = Upraviť tabuľku td-adjust-table-hint = Táto tabuľka neobsahuje údaje, takže môžete pridať nové stĺpce alebo označiť existujúce na odstránenie. Zmena sa uloží atómovo. -td-existing-columns = Existujúce stĺpce -td-new-columns = Nové stĺpce na pridanie td-remove-column = Odstrániť td-remove-column-aria = Odstrániť { $column } +td-remove-column-confirm = Odstrániť stĺpec „{ $column }“? Túto zmenu nemožno po uložení vrátiť späť. td-save-definition = Uložiť definíciu tabuľky td-rename-column = Aliasy stĺpcov td-rename-hint = Premenúva sa len zobrazený názov, nie fyzický stĺpec pod ním, takže uložené údaje a skripty zostávajú nedotknuté. @@ -920,3 +919,95 @@ validation-err-range-start = Začiatok rozsahu vzoru nemôže presiahnuť jeho k validation-err-bad-position = Neplatná pozícia vzoru '{ $value }'. validation-err-bad-constraint = Neznáme obmedzenie znakov '{ $value }'. validation-err-permission = Vyžaduje sa oprávnenie na správu validácií. + +# Pracovisko správy konverzných kurzov +rates-title = Konverzné kurzy +rates-eyebrow = Pracovisko správcu +rates-description = Nastavte meny profilov, overte konverzie, skontrolujte auditné dôkazy a sledujte poskytovateľov. +rates-updated = Konverzné kurzy boli aktualizované +rates-could-not-continue = Operáciu sa nepodarilo dokončiť +rates-sections = Časti pracoviska konverzných kurzov +rates-tab-configuration = Konfigurácia +rates-tab-configuration-sub = Meny a poskytovatelia +rates-tab-preview = Náhľad +rates-tab-preview-sub = Skúška konverzie +rates-tab-evidence = Dôkazy +rates-tab-evidence-sub = Kontrola uloženého výsledku +rates-tab-providers = Poskytovatelia +rates-tab-providers-sub = Pokrytie a importy +rates-profile-configuration = Konfigurácia profilu +rates-no-profiles = Nie sú dostupné žiadne účtovné profily. +rates-profile = Profil +rates-accounting-currency = Účtovná mena +rates-immutable = po vytvorení profilu sa nemení +rates-configured-currencies = Cudzie meny +rates-registered-providers = Dostupní poskytovatelia +rates-no-foreign-currencies = Pre tento profil zatiaľ nie je nastavená žiadna cudzia mena. +rates-foreign-currency = Cudzia mena +rates-default-provider = Predvolený poskytovateľ +rates-providers = Poskytovatelia +rates-coverage = Overené pokrytie +rates-ready = Pripravené +rates-importing = Neúplné pokrytie +rates-make-default = Nastaviť ako predvolené +rates-add-binding = Pridať poskytovateľa meny +rates-append-only-hint = Konfigurácia sa iba rozširuje: pridajte ďalšieho poskytovateľa alebo nastavte existujúceho ako predvoleného. Existujúce väzby sa nenahrádzajú. +rates-provider = Poskytovateľ +rates-choose-provider = Vyberte poskytovateľa +rates-make-default-now = Použiť ako predvoleného poskytovateľa tejto meny +rates-conversion-preview = Náhľad konverzie +rates-preview-hint = Overí rovnaké pravidlá výberu ako účtovanie bez zápisu účtovných údajov. +rates-amount = Pôvodná suma +rates-currency = Pôvodná mena +rates-basis-date = Rozhodujúci dátum konverzie +rates-rate-method = Metóda kurzu +rates-method-automatic = Automatický oficiálny kurz +rates-method-specific = Oficiálny kurz z konkrétneho dňa zverejnenia +rates-method-saved = Uložený vlastný kurz +rates-method-manual = Jednorazový manuálny kurz +rates-date-context = Kontext dátumu +rates-context-ordinary = Bežná transakcia — predchádzajúce zverejnenie +rates-context-statement = Výpis — v deň alebo pred dátumom +rates-specific-date = Dátum zverejnenia +rates-manual-foreign-units = Jednotky cudzej meny na účtovnú jednotku +rates-provider-override = Iný poskytovateľ +rates-use-default-provider = Použiť nastaveného predvoleného +rates-reason = Dôvod +rates-reason-hint = Backend ho vyžaduje, keď výnimočný výber potrebuje odôvodnenie. +rates-preview-action = Zobraziť náhľad konverzie +rates-preview-result = Výsledok náhľadu +rates-date-rule = Pravidlo dátumu +rates-rate-date = Použitý dátum kurzu +rates-audit-details = Auditné podrobnosti +rates-exact-quote = Presná kotácia +rates-determination = Spôsob určenia +rates-rounding = Spôsob zaokrúhlenia +rates-observation = ID oficiálneho pozorovania +rates-import-batch = ID dávky importu +rates-evidence-lookup = Vyhľadanie dôkazov +rates-evidence-hint = Vyhľadajte presnú históriu konverzií jednej bunky tabuľky a jej revízií. +rates-table = Tabuľka +rates-record-id = ID záznamu +rates-column = Stĺpec +rates-find-evidence = Nájsť dôkazy +rates-no-exchange = Táto bunka neobsahuje konverziu výmenného kurzu. +rates-no-evidence = Bunka podporuje konverziu, ale nebol zaznamenaný žiadny dôkaz. +rates-revision = Revízia riadka +rates-conversion = Konverzia +rates-created = Zaznamenané +rates-more-evidence = Existujú ďalšie dôkazy; spresnite vyhľadávanie alebo pokračujte kurzorom API. +rates-provider-operations = Prevádzka +rates-provider-health = Stav poskytovateľa +rates-provider-health-hint = Overené pokrytie, hranice účtovania a nedávne pokusy o import oficiálneho poskytovateľa. +rates-unavailable-title = Konverzné kurzy nie sú dostupné +rates-err-profile-missing = Vybraný profil už neexistuje. Zobrazuje sa prvý dostupný profil. +rates-err-preview-method = Vyberte podporovanú metódu kurzu. +rates-err-record-id = ID záznamu musí byť kladné celé číslo. +rates-configuration-added = Poskytovateľ { $source } bol pridaný pre menu { $currency }. +rates-source-official = Oficiálny +rates-source-saved = Uložený vlastný +rates-source-manual = Jednorazový manuálny +rates-rule-previous = Predchádzajúce zverejnenie +rates-rule-on-or-before = V deň alebo pred dátumom +rates-rule-specific = Konkrétny dátum zverejnenia +rates-unknown = Neznáme diff --git a/web/src/lib.rs b/web/src/lib.rs index 62baea50..7f97f68b 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -367,12 +367,11 @@ mod tests { } } - /// The exchange-rate page is read-only and behind a session like every - /// other page, so an anonymous visitor is sent to the login page rather - /// than to the backend. + /// Exchange-rate administration is behind a session; the former ECB page + /// remains as a stable redirect into the provider section. #[tokio::test] async fn the_exchange_rate_pages_are_mounted_and_need_a_session() { - for path in ["/admin/ecb", "/admin/ecb/status"] { + for path in ["/admin/exchange-rates", "/admin/ecb/status"] { let (status, _) = get(path).await; assert_eq!( status, @@ -380,6 +379,9 @@ mod tests { "{path} did not send an anonymous visitor to the login page" ); } + + let (status, _) = get("/admin/ecb").await; + assert_eq!(status, axum::http::StatusCode::PERMANENT_REDIRECT); } /// Permissions is a nav section of its own, so its three pages are mounted diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 3c8b4fce..10aa50f3 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -90,6 +90,7 @@ mod tests { "/admin/validation/sets/new", "/admin/import", "/admin/export", + "/admin/exchange-rates", "/logout", ] { assert!(html.contains(route), "missing admin action route {route}"); diff --git a/web/src/pages/admin/ecb/loader.rs b/web/src/pages/admin/ecb/loader.rs index d0fee580..a5b6ed9b 100644 --- a/web/src/pages/admin/ecb/loader.rs +++ b/web/src/pages/admin/ecb/loader.rs @@ -8,9 +8,7 @@ use axum::http::HeaderMap; use crate::{ - AppState, - auth::GetAuthorizationRequest, - ecb::GetEcbPipelineStatusRequest, + AppState, auth::GetAuthorizationRequest, ecb::GetEcbPipelineStatusRequest, services::authenticated_request, }; diff --git a/web/src/pages/admin/ecb/logic.rs b/web/src/pages/admin/ecb/logic.rs index d94bb831..833b10e6 100644 --- a/web/src/pages/admin/ecb/logic.rs +++ b/web/src/pages/admin/ecb/logic.rs @@ -8,7 +8,10 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, }; -use crate::{AppState, {i18n::Locale, tr}}; +use crate::{ + AppState, + {i18n::Locale, tr}, +}; use super::{loader::load_ecb_page, state::LoadError, ui}; @@ -25,10 +28,7 @@ pub(crate) async fn page(State(state): State, headers: HeaderMap) -> R /// Polled only while a batch is running; the card stops asking for itself once /// the batch it was watching has finished, because the fragment it swaps in /// carries no trigger. -pub(crate) async fn status_fragment( - State(state): State, - headers: HeaderMap, -) -> Response { +pub(crate) async fn status_fragment(State(state): State, headers: HeaderMap) -> Response { match load_ecb_page(state, &headers).await { Ok(page) => Html(ui::render_status(&page)).into_response(), Err(error) => error_response(&headers, error), @@ -42,19 +42,14 @@ fn error_response(headers: &HeaderMap, error: LoadError) -> Response { StatusCode::FORBIDDEN, Html(ui::render_error( Locale::from_headers(headers), - &tr!( - Locale::from_headers(headers), - "ecb-forbidden" - ), + &tr!(Locale::from_headers(headers), "ecb-forbidden"), )), ) .into_response(), - LoadError::Backend(message) => { - ( - StatusCode::BAD_GATEWAY, - Html(ui::render_error(Locale::from_headers(headers), &message)), - ) - .into_response() - } + LoadError::Backend(message) => ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response(), } } diff --git a/web/src/pages/admin/ecb/mod.rs b/web/src/pages/admin/ecb/mod.rs index 5ac8fab9..c2eb048e 100644 --- a/web/src/pages/admin/ecb/mod.rs +++ b/web/src/pages/admin/ecb/mod.rs @@ -1,27 +1,39 @@ -//! Health of the ECB reference-rate pipeline. -//! -//! Read-only, and deliberately not profile-scoped: one importer feeds every -//! profile, so the question "are the rates current" has one answer for the -//! whole deployment. That is also why it is its own page rather than a panel -//! on a profile's — it is not about a profile at all. -//! -//! The page matters because coverage is a precondition, not a detail: a -//! conversion whose publication date falls beyond verified coverage is refused -//! outright by the backend. When posting starts failing for that reason, this -//! is the screen that says so. +//! Profile configuration, conversion audit tools, and provider health for +//! exchange-rate administration. mod loader; mod logic; mod state; mod ui; +mod workspace_loader; +mod workspace_logic; +mod workspace_state; +mod workspace_ui; -use axum::{Router, routing::get}; +use axum::{ + Router, + routing::{get, post}, +}; use crate::AppState; pub(crate) fn router() -> Router { Router::new() - .route("/admin/ecb", get(logic::page)) + .route("/admin/exchange-rates", get(workspace_logic::page)) + .route( + "/admin/exchange-rates/configuration", + post(workspace_logic::add_configuration), + ) + .route( + "/admin/exchange-rates/preview", + post(workspace_logic::preview), + ) + .route( + "/admin/exchange-rates/evidence", + post(workspace_logic::evidence), + ) + .route("/admin/ecb", get(workspace_logic::legacy_ecb)) + .route("/admin/ecb/provider", get(logic::page)) // The status card on its own, for the poll that keeps it live while a // batch is running. .route("/admin/ecb/status", get(logic::status_fragment)) diff --git a/web/src/pages/admin/ecb/state.rs b/web/src/pages/admin/ecb/state.rs index ca80049b..8cf92464 100644 --- a/web/src/pages/admin/ecb/state.rs +++ b/web/src/pages/admin/ecb/state.rs @@ -48,7 +48,10 @@ fn coarse(locale: Locale, duration: SignedDuration) -> String { } fn elapsed_between(locale: Locale, from: &str, to: &str) -> Option { - let (from, to) = (from.parse::().ok()?, to.parse::().ok()?); + let (from, to) = ( + from.parse::().ok()?, + to.parse::().ok()?, + ); Some(coarse(locale, from.duration_until(to))) } diff --git a/web/src/pages/admin/ecb/ui.rs b/web/src/pages/admin/ecb/ui.rs index 3dd6b5ff..f92126d2 100644 --- a/web/src/pages/admin/ecb/ui.rs +++ b/web/src/pages/admin/ecb/ui.rs @@ -1,7 +1,7 @@ use askama::Template; -use crate::{i18n::Locale, tr}; use crate::ui::{ErrorPage, Nav, render}; +use crate::{i18n::Locale, tr}; use super::state::EcbPageState; @@ -21,6 +21,14 @@ struct StatusFragment<'a> { page: &'a EcbPageState, } +/// Provider import attempts embedded in the administration workspace. +#[derive(Template)] +#[template(path = "pages/admin/ecb/attempts.html")] +struct AttemptsFragment<'a> { + nav: Nav, + page: &'a EcbPageState, +} + pub(crate) fn render_page(page: &EcbPageState) -> String { render(&EcbPage { nav: page.nav.clone(), @@ -35,6 +43,13 @@ pub(crate) fn render_status(page: &EcbPageState) -> String { }) } +pub(crate) fn render_attempts(page: &EcbPageState) -> String { + render(&AttemptsFragment { + nav: page.nav.clone(), + page, + }) +} + pub(crate) fn render_error(locale: Locale, message: &str) -> String { render(&ErrorPage { nav: Nav::default(), diff --git a/web/src/pages/admin/ecb/workspace_loader.rs b/web/src/pages/admin/ecb/workspace_loader.rs new file mode 100644 index 00000000..cccb5b6f --- /dev/null +++ b/web/src/pages/admin/ecb/workspace_loader.rs @@ -0,0 +1,141 @@ +//! Loads profile configuration and provider health for the exchange-rate workspace. + +use axum::http::HeaderMap; + +use crate::{ + AppState, + auth::GetAuthorizationRequest, + definitions::{common::Empty, exchange_rates::GetProfileExchangeRateSettingsRequest}, + services::authenticated_request, +}; + +use super::{ + loader::load_ecb_page, + state::LoadError, + workspace_state::{ + CurrencySettingsView, CurrencySourceView, ExchangeRateWorkspaceState, ProfileSettingsView, + WorkspaceInputs, + }, +}; + +pub(crate) async fn load_workspace( + state: AppState, + headers: &HeaderMap, + mut inputs: WorkspaceInputs, +) -> Result { + let authorization_request = authenticated_request(headers, GetAuthorizationRequest {}) + .map_err(|_| LoadError::Unauthenticated)?; + let mut auth = state.auth.clone(); + let authorization = auth + .get_authorization(authorization_request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + tonic::Code::PermissionDenied => LoadError::Forbidden, + _ => LoadError::Backend(error.message().to_string()), + })? + .into_inner(); + if !crate::authz::can_read_exchange_rates(&authorization) { + return Err(LoadError::Forbidden); + } + + let mut definitions = state.definitions.clone(); + let tree = definitions + .get_profile_tree( + authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + let profiles = tree + .profiles + .into_iter() + .map(|profile| profile.name) + .collect::>(); + if inputs.selected_profile.is_empty() { + inputs.selected_profile = profiles.first().cloned().unwrap_or_default(); + } + if !inputs.selected_profile.is_empty() + && !profiles + .iter() + .any(|profile| profile == &inputs.selected_profile) + { + inputs.error.get_or_insert(crate::tr!( + crate::i18n::Locale::from_headers(headers), + "rates-err-profile-missing" + )); + inputs.selected_profile = profiles.first().cloned().unwrap_or_default(); + } + + let mut exchange_rates = state.exchange_rates.clone(); + let providers = exchange_rates + .list_rate_sources( + authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner() + .sources + .into_iter() + .map(|source| source.id) + .collect(); + + let settings = if inputs.selected_profile.is_empty() { + None + } else { + let response = exchange_rates + .get_profile_exchange_rate_settings( + authenticated_request( + headers, + GetProfileExchangeRateSettingsRequest { + profile_name: inputs.selected_profile.clone(), + }, + ) + .map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + Some(ProfileSettingsView { + accounting_currency: response.accounting_currency, + foreign_currencies: response + .foreign_currencies + .into_iter() + .map(|currency| CurrencySettingsView { + currency: currency.currency, + default_source_id: currency.default_source_id, + sources: currency + .sources + .into_iter() + .map(|source| CurrencySourceView { + source_id: source.source_id, + coverage_complete: source.coverage_complete, + verified_from_date: source.verified_from_date, + verified_through_date: source.verified_through_date, + }) + .collect(), + }) + .collect(), + }) + }; + + let provider_health = load_ecb_page(state, headers).await?; + Ok(ExchangeRateWorkspaceState { + nav: crate::ui::Nav::from_authorization(headers, "ecb", &authorization), + profiles, + selected_profile: inputs.selected_profile, + providers, + settings, + can_configure: crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE), + configuration: inputs.configuration, + preview_form: inputs.preview_form, + preview: inputs.preview, + evidence_form: inputs.evidence_form, + evidence: inputs.evidence, + evidence_has_exchange: inputs.evidence_has_exchange, + evidence_has_more: inputs.evidence_has_more, + provider_health, + status: inputs.status, + error: inputs.error, + }) +} diff --git a/web/src/pages/admin/ecb/workspace_logic.rs b/web/src/pages/admin/ecb/workspace_logic.rs new file mode 100644 index 00000000..1d021cba --- /dev/null +++ b/web/src/pages/admin/ecb/workspace_logic.rs @@ -0,0 +1,250 @@ +//! HTTP handlers for the provider-neutral exchange-rate administration workspace. + +use axum::{ + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::Form; + +use crate::{ + AppState, + definitions::exchange_rates::{ + AddProfileCurrencySourceRequest, ExchangeRateDateRule, ExchangeRateSource, + ListConversionEvidenceRequest, + }, + services::{authenticated_request, reject_cross_site}, +}; + +use super::{ + state::LoadError, + workspace_loader::load_workspace, + workspace_state::{ + ConfigurationForm, EvidenceForm, EvidenceView, PreviewForm, PreviewView, WorkspaceInputs, + WorkspaceQuery, + }, + workspace_ui, +}; + +pub(crate) async fn legacy_ecb() -> Redirect { + Redirect::permanent("/admin/exchange-rates#providers") +} + +pub(crate) async fn page( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + render_loaded( + &headers, + load_workspace(state, &headers, WorkspaceInputs::for_profile(query.profile)).await, + StatusCode::OK, + ) +} + +pub(crate) async fn add_configuration( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let mut inputs = WorkspaceInputs::for_profile(form.profile.clone()); + inputs.configuration = form.clone(); + let added_source = form.source_id.trim().to_string(); + let request = AddProfileCurrencySourceRequest { + profile_name: form.profile, + currency: form.currency.trim().to_ascii_uppercase(), + source_id: form.source_id.trim().to_string(), + make_default: form.make_default, + }; + let Ok(request) = authenticated_request(&headers, request) else { + return Redirect::to("/login").into_response(); + }; + let mut exchange_rates = state.exchange_rates.clone(); + match exchange_rates.add_profile_currency_source(request).await { + Ok(currency) => { + let currency = currency.into_inner(); + inputs.configuration = ConfigurationForm::default(); + inputs.status = Some(crate::tr!( + crate::i18n::Locale::from_headers(&headers), + "rates-configuration-added", + "currency" => currency.currency, + "source" => added_source, + )); + render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::OK, + ) + } + Err(error) => { + inputs.error = Some(error.message().to_string()); + render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::UNPROCESSABLE_ENTITY, + ) + } + } +} + +pub(crate) async fn preview( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let mut inputs = WorkspaceInputs::for_profile(form.profile.clone()); + inputs.preview_form = form.clone(); + let request = match form.request() { + Ok(request) => request, + Err(key) => { + inputs.error = Some(crate::tr!(crate::i18n::Locale::from_headers(&headers), key)); + return render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::UNPROCESSABLE_ENTITY, + ); + } + }; + let Ok(request) = authenticated_request(&headers, request) else { + return Redirect::to("/login").into_response(); + }; + let mut exchange_rates = state.exchange_rates.clone(); + match exchange_rates.preview_direct_conversion(request).await { + Ok(response) => { + let response = response.into_inner(); + let locale = crate::i18n::Locale::from_headers(&headers); + inputs.preview = Some(PreviewView { + applied_method: crate::tr!(locale, rate_method_key(response.applied_source)), + applied_date_rule: crate::tr!(locale, date_rule_key(response.applied_date_rule)), + response, + }); + render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::OK, + ) + } + Err(error) => { + inputs.error = Some(error.message().to_string()); + render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::UNPROCESSABLE_ENTITY, + ) + } + } +} + +pub(crate) async fn evidence( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let mut inputs = WorkspaceInputs::for_profile(form.profile.clone()); + inputs.evidence_form = form.clone(); + let record_id = match form.record_id.trim().parse::() { + Ok(id) if id > 0 => id, + _ => { + inputs.error = Some(crate::tr!( + crate::i18n::Locale::from_headers(&headers), + "rates-err-record-id" + )); + return render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::UNPROCESSABLE_ENTITY, + ); + } + }; + let request = ListConversionEvidenceRequest { + profile_name: form.profile, + table_name: form.table_name.trim().to_string(), + record_id, + column_name: form.column_name.trim().to_string(), + limit: 50, + before_evidence_id: None, + }; + let Ok(request) = authenticated_request(&headers, request) else { + return Redirect::to("/login").into_response(); + }; + let mut exchange_rates = state.exchange_rates.clone(); + match exchange_rates.list_conversion_evidence(request).await { + Ok(response) => { + let response = response.into_inner(); + inputs.evidence_has_exchange = Some(response.has_exchange); + inputs.evidence_has_more = response.has_more; + inputs.evidence = response + .evidence + .into_iter() + .map(|evidence| EvidenceView { evidence }) + .collect(); + render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::OK, + ) + } + Err(error) => { + inputs.error = Some(error.message().to_string()); + render_loaded( + &headers, + load_workspace(state, &headers, inputs).await, + StatusCode::UNPROCESSABLE_ENTITY, + ) + } + } +} + +fn rate_method_key(value: i32) -> &'static str { + match ExchangeRateSource::try_from(value).ok() { + Some(ExchangeRateSource::Official) => "rates-source-official", + Some(ExchangeRateSource::SavedCustom) => "rates-source-saved", + Some(ExchangeRateSource::Manual) => "rates-source-manual", + _ => "rates-unknown", + } +} + +fn date_rule_key(value: i32) -> &'static str { + match ExchangeRateDateRule::try_from(value).ok() { + Some(ExchangeRateDateRule::PreviousPublication) => "rates-rule-previous", + Some(ExchangeRateDateRule::OnOrBeforeDate) => "rates-rule-on-or-before", + Some(ExchangeRateDateRule::SpecificPublicationDate) => "rates-rule-specific", + _ => "rates-unknown", + } +} + +fn render_loaded( + headers: &HeaderMap, + result: Result, + success_status: StatusCode, +) -> Response { + match result { + Ok(page) => (success_status, Html(workspace_ui::render_page(&page))).into_response(), + Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(LoadError::Forbidden) => ( + StatusCode::FORBIDDEN, + Html(workspace_ui::render_error( + crate::i18n::Locale::from_headers(headers), + &crate::tr!(crate::i18n::Locale::from_headers(headers), "ecb-forbidden"), + )), + ) + .into_response(), + Err(LoadError::Backend(message)) => ( + StatusCode::BAD_GATEWAY, + Html(workspace_ui::render_error( + crate::i18n::Locale::from_headers(headers), + &message, + )), + ) + .into_response(), + } +} diff --git a/web/src/pages/admin/ecb/workspace_state.rs b/web/src/pages/admin/ecb/workspace_state.rs new file mode 100644 index 00000000..61d5772c --- /dev/null +++ b/web/src/pages/admin/ecb/workspace_state.rs @@ -0,0 +1,253 @@ +//! State and form wire formats for the provider-neutral exchange-rate workspace. + +use crate::definitions::exchange_rates::{ + ConversionEvidence, ExchangeRateDateRule, ExchangeRateSelection, ExchangeRateSource, + PreviewDirectConversionRequest, PreviewDirectConversionResponse, +}; + +use super::state::EcbPageState; + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct WorkspaceQuery { + #[serde(default)] + pub profile: String, +} + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct ConfigurationForm { + #[serde(default)] + pub profile: String, + #[serde(default)] + pub currency: String, + #[serde(default)] + pub source_id: String, + #[serde(default)] + pub make_default: bool, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub(crate) struct PreviewForm { + #[serde(default)] + pub profile: String, + #[serde(default)] + pub original_amount: String, + #[serde(default)] + pub original_currency: String, + #[serde(default)] + pub conversion_basis_date: String, + #[serde(default = "automatic_official")] + pub method: String, + #[serde(default = "ordinary_context")] + pub context: String, + #[serde(default)] + pub specific_rate_date: String, + #[serde(default)] + pub manual_foreign_units: String, + #[serde(default)] + pub rate_source_id: String, + #[serde(default)] + pub reason: String, +} + +fn automatic_official() -> String { + "automatic-official".to_string() +} + +fn ordinary_context() -> String { + "ordinary".to_string() +} + +impl Default for PreviewForm { + fn default() -> Self { + Self { + profile: String::new(), + original_amount: String::new(), + original_currency: String::new(), + conversion_basis_date: String::new(), + method: automatic_official(), + context: ordinary_context(), + specific_rate_date: String::new(), + manual_foreign_units: String::new(), + rate_source_id: String::new(), + reason: String::new(), + } + } +} + +impl PreviewForm { + pub(crate) fn request(&self) -> Result { + let date_rule = match self.method.as_str() { + "official-specific" | "manual" => ExchangeRateDateRule::SpecificPublicationDate, + _ if self.context == "statement" => ExchangeRateDateRule::OnOrBeforeDate, + _ => ExchangeRateDateRule::PreviousPublication, + }; + let source = match self.method.as_str() { + "automatic-official" | "official-specific" => ExchangeRateSource::Official, + "saved-custom" => ExchangeRateSource::SavedCustom, + "manual" => ExchangeRateSource::Manual, + _ => return Err("rates-err-preview-method"), + }; + let use_default_selection = self.method == "automatic-official" + && self.context != "statement" + && self.rate_source_id.trim().is_empty(); + let selection = (!use_default_selection).then(|| ExchangeRateSelection { + date_rule: date_rule as i32, + source: source as i32, + specific_rate_date: (!self.specific_rate_date.trim().is_empty()) + .then(|| self.specific_rate_date.trim().to_string()), + manual_foreign_units: (!self.manual_foreign_units.trim().is_empty()) + .then(|| self.manual_foreign_units.trim().to_string()), + reason: self.reason.trim().to_string(), + rate_source_id: (!self.rate_source_id.trim().is_empty()) + .then(|| self.rate_source_id.trim().to_string()), + }); + Ok(PreviewDirectConversionRequest { + original_amount: self.original_amount.trim().to_string(), + original_currency: self.original_currency.trim().to_ascii_uppercase(), + conversion_basis_date: self.conversion_basis_date.trim().to_string(), + profile_name: self.profile.clone(), + exchange_rate_selection: selection, + }) + } +} + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct EvidenceForm { + #[serde(default)] + pub profile: String, + #[serde(default)] + pub table_name: String, + #[serde(default)] + pub record_id: String, + #[serde(default)] + pub column_name: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct CurrencySettingsView { + pub currency: String, + pub default_source_id: String, + pub sources: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct CurrencySourceView { + pub source_id: String, + pub coverage_complete: bool, + pub verified_from_date: Option, + pub verified_through_date: Option, +} + +impl CurrencySourceView { + pub(crate) fn coverage_class(&self) -> &'static str { + if self.coverage_complete { + "tag-ok" + } else { + "tag-bad" + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ProfileSettingsView { + pub accounting_currency: String, + pub foreign_currencies: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct PreviewView { + pub response: PreviewDirectConversionResponse, + pub applied_method: String, + pub applied_date_rule: String, +} + +impl PreviewView { + pub(crate) fn quote(&self) -> String { + match ( + self.response.accounting_units.as_deref(), + self.response.foreign_units.as_deref(), + self.response.foreign_currency.as_deref(), + ) { + (Some(accounting_units), Some(foreign_units), Some(foreign_currency)) => format!( + "{} {} = {} {}", + accounting_units, + self.response.accounting_currency, + foreign_units, + foreign_currency, + ), + _ => "—".to_string(), + } + } + + pub(crate) fn observation_id(&self) -> String { + self.response + .official_observation_id + .map_or_else(|| "—".to_string(), |id| id.to_string()) + } + + pub(crate) fn import_batch_id(&self) -> String { + self.response + .import_batch_id + .map_or_else(|| "—".to_string(), |id| id.to_string()) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct EvidenceView { + pub evidence: ConversionEvidence, +} + +pub(crate) struct WorkspaceInputs { + pub selected_profile: String, + pub configuration: ConfigurationForm, + pub preview_form: PreviewForm, + pub preview: Option, + pub evidence_form: EvidenceForm, + pub evidence: Vec, + pub evidence_has_exchange: Option, + pub evidence_has_more: bool, + pub status: Option, + pub error: Option, +} + +impl WorkspaceInputs { + pub(crate) fn for_profile(profile: String) -> Self { + Self { + selected_profile: profile, + configuration: ConfigurationForm::default(), + preview_form: PreviewForm::default(), + preview: None, + evidence_form: EvidenceForm::default(), + evidence: Vec::new(), + evidence_has_exchange: None, + evidence_has_more: false, + status: None, + error: None, + } + } +} + +pub(crate) struct ExchangeRateWorkspaceState { + pub nav: crate::ui::Nav, + pub profiles: Vec, + pub selected_profile: String, + pub providers: Vec, + pub settings: Option, + pub can_configure: bool, + pub configuration: ConfigurationForm, + pub preview_form: PreviewForm, + pub preview: Option, + pub evidence_form: EvidenceForm, + pub evidence: Vec, + pub evidence_has_exchange: Option, + pub evidence_has_more: bool, + pub provider_health: EcbPageState, + pub status: Option, + pub error: Option, +} + +impl ExchangeRateWorkspaceState { + pub(crate) fn is_selected_profile(&self, profile: &str) -> bool { + self.selected_profile == profile + } +} diff --git a/web/src/pages/admin/ecb/workspace_ui.rs b/web/src/pages/admin/ecb/workspace_ui.rs new file mode 100644 index 00000000..ac03ddc5 --- /dev/null +++ b/web/src/pages/admin/ecb/workspace_ui.rs @@ -0,0 +1,139 @@ +//! Rendering for the exchange-rate administration workspace. + +use askama::Template; + +use crate::{ + i18n::Locale, + schema::CURRENCY_CODES, + ui::{ErrorPage, Nav, render}, +}; + +use super::workspace_state::ExchangeRateWorkspaceState; + +#[derive(Template)] +#[template(path = "pages/admin/ecb/workspace.html")] +struct WorkspacePage<'a> { + nav: Nav, + page: &'a ExchangeRateWorkspaceState, + currency_codes: &'static [&'static str], + provider_status: String, + provider_attempts: String, +} + +pub(crate) fn render_page(page: &ExchangeRateWorkspaceState) -> String { + render(&WorkspacePage { + nav: page.nav.clone(), + page, + currency_codes: CURRENCY_CODES, + provider_status: super::ui::render_status(&page.provider_health), + provider_attempts: super::ui::render_attempts(&page.provider_health), + }) +} + +pub(crate) fn render_error(locale: Locale, message: &str) -> String { + render(&ErrorPage { + nav: Nav::default(), + heading: &crate::tr!(locale, "rates-unavailable-title"), + message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pages::admin::ecb::{ + state::{EcbPageState, ImportBatchView}, + workspace_state::{ + ConfigurationForm, CurrencySettingsView, CurrencySourceView, EvidenceForm, + ExchangeRateWorkspaceState, PreviewForm, ProfileSettingsView, + }, + }; + + fn health() -> EcbPageState { + EcbPageState { + nav: Nav::default(), + verified_through_date: Some("2026-08-23".to_string()), + latest_verifiable_date: "2026-08-23".to_string(), + healthy: true, + days_behind: 0, + import_running: false, + next_import_at: "2026-08-24T15:00:00Z".to_string(), + batches: vec![ImportBatchView { + batch_id: 4, + status: "succeeded".to_string(), + requested_from: "2026-08-23".to_string(), + requested_through: "2026-08-23".to_string(), + endpoint: "https://example.test".to_string(), + started_at: "2026-08-23T15:00:00Z".to_string(), + completed_at: Some("2026-08-23T15:00:01Z".to_string()), + verified_through_date: Some("2026-08-23".to_string()), + observation_count: Some(2), + inserted_observation_count: Some(2), + error_message: None, + }], + covered_currencies: vec!["CZK".to_string()], + transactions_postable_through: Some("2026-08-24".to_string()), + statements_postable_through: Some("2026-08-23".to_string()), + } + } + + fn page() -> ExchangeRateWorkspaceState { + ExchangeRateWorkspaceState { + nav: Nav::default(), + profiles: vec!["books".to_string()], + selected_profile: "books".to_string(), + providers: vec!["ecb".to_string()], + settings: Some(ProfileSettingsView { + accounting_currency: "EUR".to_string(), + foreign_currencies: vec![CurrencySettingsView { + currency: "CZK".to_string(), + default_source_id: "ecb".to_string(), + sources: vec![CurrencySourceView { + source_id: "ecb".to_string(), + coverage_complete: true, + verified_from_date: Some("2020-01-01".to_string()), + verified_through_date: Some("2026-08-23".to_string()), + }], + }], + }), + can_configure: true, + configuration: ConfigurationForm::default(), + preview_form: PreviewForm::default(), + preview: None, + evidence_form: EvidenceForm::default(), + evidence: Vec::new(), + evidence_has_exchange: None, + evidence_has_more: false, + provider_health: health(), + status: None, + error: None, + } + } + + #[test] + fn workspace_contains_every_admin_operation() { + let html = render_page(&page()); + + assert!(!html.contains("Template error"), "{html}"); + assert!(html.contains("Profile configuration"), "{html}"); + assert!(html.contains("Conversion preview"), "{html}"); + assert!(html.contains("Evidence lookup"), "{html}"); + assert!(html.contains("Provider health"), "{html}"); + assert!(html.contains("CZK"), "{html}"); + assert!(html.contains("ecb"), "{html}"); + assert!(html.contains("Ready"), "{html}"); + } + + #[test] + fn read_only_admin_sees_configuration_without_write_controls() { + let mut page = page(); + page.can_configure = false; + let html = render_page(&page); + + assert!(html.contains("CZK"), "{html}"); + assert!( + !html.contains(r#"action="/admin/exchange-rates/configuration""#), + "{html}" + ); + } +} diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 014b80f2..b8c8eb2f 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -203,6 +203,8 @@ pub(crate) async fn update_columns( } } "toggle-index" => inputs.columns.toggle_indexed(index), + "move-column-up" => inputs.status = inputs.columns.move_column(locale, index, -1), + "move-column-down" => inputs.status = inputs.columns.move_column(locale, index, 1), _ => {} } diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 3266c37a..2734597f 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -384,6 +384,12 @@ impl TableDefinitionPageState { self.table_is_writable() && self.detail.as_ref().is_some_and(|detail| !detail.has_data) } + /// How many columns the table already has, for the unified "Columns" + /// count the append panel shows alongside the create builder's own. + pub(crate) fn existing_column_count(&self) -> usize { + self.detail.as_ref().map_or(0, |detail| detail.columns.len()) + } + pub(crate) fn column_is_selected_for_removal(&self, column_id: &i64) -> bool { self.remove_column_ids.contains(column_id) } diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index 42e2f364..9706321a 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -465,6 +465,51 @@ mod tests { assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}"); } + /// The edit page reuses the create builder's own section/heading/count + /// styling, so the two read as the same component prefilled with the + /// existing table rather than a differently-shaped screen. + #[test] + fn the_add_columns_page_looks_like_the_builder() { + let mut state = page(); + state.columns.added.push(crate::schema::ColumnDefinition { + name: "total".to_string(), + data_type: "money".to_string(), + indexed: false, + quantity_ledger: false, + required: false, + money_mode: crate::schema::MoneyMode::Exact, + currency: String::new(), + }); + state.columns.added.push(crate::schema::ColumnDefinition { + name: "issued_on".to_string(), + data_type: "date".to_string(), + indexed: false, + quantity_ledger: false, + required: false, + money_mode: crate::schema::MoneyMode::Exact, + currency: String::new(), + }); + + let html = render_add_columns_page(&state); + + assert!(!html.contains(r#"class="panel""#), "{html}"); + assert!(html.contains(r#"class="builder-section""#), "{html}"); + assert!(html.contains("Add a column"), "{html}"); + // One unified table: the existing column and the staged ones all + // under the same "Columns" heading, with a count spanning both. + assert!(html.contains("Columns 3"), "{html}"); + assert!(html.contains(r#"class="generated-row""#), "{html}"); + assert!(html.contains(r#"number"#), "{html}"); + assert!(html.contains(r#"total"#), "{html}"); + // The existing row is locked: nothing to reorder, and its remove + // button reads the same as the staged row's but warns before it + // toggles, since there is no undoing it once saved. + assert!(html.contains(r#"class="danger""#), "{html}"); + assert!(html.contains("confirm("), "{html}"); + assert!(html.contains(r#""action": "move-column-up""#), "{html}"); + assert!(html.contains(r#"href="/admin?profile=billing&table=invoice">Cancel"#), "{html}"); + } + #[test] fn a_populated_table_offers_append_only_columns() { let mut state = page(); diff --git a/web/static/app.css b/web/static/app.css index 82ad5b20..3a353d1e 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -282,6 +282,10 @@ .builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid var(--border-soft); } .builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; } + /* The append-columns panel swaps `#column-panel` in on its own, so its + first section is not the first section on the page — it still needs the + divider that :first-of-type would otherwise strip. */ + #column-panel .builder-section:first-of-type { margin-top: 26px; padding-top: 20px; border-top: 1px solid var(--border-soft); } .builder-section h2 { margin: 0; font-size: 15px; color: var(--text); } .builder-section > .hint { margin: 8px 0 0; font-size: 13px; } .builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: var(--text-muted); background: var(--secondary-bg); } @@ -329,8 +333,11 @@ button.toggle:hover { background: var(--hover-bg); } button.toggle.mode-optional { color: var(--primary-strong); border-color: var(--select-border); background: var(--info-bg); } button.toggle.mode-required { color: var(--warning); border-color: var(--warning-border); background: var(--warning-bg); } - button.danger { padding: 4px 10px; border: 1px solid var(--danger-border); border-radius: 5px; font-size: 12px; color: var(--danger); background: var(--danger-bg); cursor: pointer; } - button.danger:hover { background: var(--danger-hover-bg); } + button.danger, label.danger { padding: 4px 10px; border: 1px solid var(--danger-border); border-radius: 5px; font-size: 12px; color: var(--danger); background: var(--danger-bg); cursor: pointer; } + button.danger:hover, label.danger:hover { background: var(--danger-hover-bg); } + label.danger { position: relative; display: inline-flex; align-items: center; } + label.danger input { position: absolute; opacity: 0; pointer-events: none; } + label.danger:has(input:checked) { background: var(--danger); color: var(--on-danger); } .tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: var(--text-muted); background: var(--secondary-bg); } .tag + .tag { margin-left: 4px; } diff --git a/web/templates/pages/admin/admin/admin.html b/web/templates/pages/admin/admin/admin.html index 29c8a04d..7f58260e 100644 --- a/web/templates/pages/admin/admin/admin.html +++ b/web/templates/pages/admin/admin/admin.html @@ -18,7 +18,7 @@ {% if page.can_manage_validations %}{{ nav.tr("admin-add-rule") }}{% endif %} {% if page.can_import %}{{ nav.tr("admin-import") }}{% endif %} {% if page.can_export %}{{ nav.tr("admin-export") }}{% endif %} - {% if page.can_exchange_rates %}{{ nav.tr("admin-exchange-rates") }}{% endif %} + {% if page.can_exchange_rates %}{{ nav.tr("admin-exchange-rates") }}{% endif %}
{% include "pages/admin/admin/workspace.html" %}
diff --git a/web/templates/pages/admin/ecb/attempts.html b/web/templates/pages/admin/ecb/attempts.html new file mode 100644 index 00000000..1276f0bf --- /dev/null +++ b/web/templates/pages/admin/ecb/attempts.html @@ -0,0 +1,58 @@ +
+

+ {{ nav.tr("ecb-import-attempts") }} {{ page.batches.len() }} + {%- if page.failure_count() > 0 %}{{ page.failed_count_label() }}{% endif -%} +

+ {% if page.batches.is_empty() %} +

{{ nav.tr("ecb-no-attempts-hint") }}

+ {% else %} +
+ + + + + + + + + + {% for batch in page.batches %} + + + + + + + + + + + {% if let Some(message) = batch.error_message %} + + + + + {% endif %} + {% endfor %} + +
#{{ nav.tr("ecb-th-result") }}{{ nav.tr("ecb-th-started") }}{{ nav.tr("ecb-th-finished") }}{{ nav.tr("ecb-th-took") }}{{ nav.tr("ecb-th-window") }}{{ nav.tr("ecb-th-observations") }}{{ nav.tr("ecb-th-verified-through") }}
{{ batch.batch_id }} + {%- if batch.succeeded() %}{{ nav.tr("ecb-batch-succeeded") }} + {%- else if batch.running() %}{{ nav.tr("ecb-batch-running") }} + {%- else %}{{ nav.tr("ecb-batch-failed") }}{% endif -%} + {{ batch.started() }} + {%- if let Some(finished) = batch.finished() %}{{ finished }} + {%- else %}{{ nav.tr("ecb-still-running") }}{% endif -%} + + {%- if let Some(took) = batch.took(nav.locale) %}{{ took }} + {%- else %}{% endif -%} + + {{ batch.window() }} +
{{ batch.endpoint }}
+
{{ batch.observations(nav.locale) }} + {%- if let Some(date) = batch.verified_through_date %}{{ date }} + {%- else %}{% endif -%} +
{{ message }}
+
+

{{ nav.tr("ecb-audit-hint") }}

+ {% endif %} +
diff --git a/web/templates/pages/admin/ecb/workspace.html b/web/templates/pages/admin/ecb/workspace.html new file mode 100644 index 00000000..05379b3c --- /dev/null +++ b/web/templates/pages/admin/ecb/workspace.html @@ -0,0 +1,208 @@ +{% extends "ui/base.html" %} +{% import "ui/alert.html" as alert %} +{% import "ui/toast.html" as toast %} + +{% block title %}{{ nav.tr("rates-title") }}{% endblock %} + +{% block content %} +
+
+
+

{{ nav.tr("rates-eyebrow") }}

+

{{ nav.tr("rates-title") }}

+

{{ nav.tr("rates-description") }}

+
+ +
+ + {%- if let Some(message) = page.status %}{% call toast::success(nav.tr("rates-updated"), message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("rates-could-not-continue"), message) %}{% endcall %}{% endif -%} + + + +
+

{{ nav.tr("rates-profile-configuration") }}

+ {% if page.profiles.is_empty() %} +

{{ nav.tr("rates-no-profiles") }}

+ {% else %} +
+ +
+ + {% if let Some(settings) = page.settings %} +
+
{{ nav.tr("rates-accounting-currency") }}
{{ settings.accounting_currency }} · {{ nav.tr("rates-immutable") }}
+
{{ nav.tr("rates-configured-currencies") }}
{{ settings.foreign_currencies.len() }}
+
{{ nav.tr("rates-registered-providers") }}
{% if page.providers.is_empty() %}—{% else %}{{ page.providers|join(", ") }}{% endif %}
+
+ + {% if settings.foreign_currencies.is_empty() %} +

{{ nav.tr("rates-no-foreign-currencies") }}

+ {% else %} +
+ + + + {% for currency in settings.foreign_currencies %} + + + + + + + + {% endfor %} + +
{{ nav.tr("rates-foreign-currency") }}{{ nav.tr("rates-default-provider") }}{{ nav.tr("rates-providers") }}{{ nav.tr("rates-coverage") }}
{{ currency.currency }}{{ currency.default_source_id }}{% for source in currency.sources %}{{ source.source_id }} {% endfor %} + {% for source in currency.sources %} +
+ {% if source.coverage_complete %}{{ nav.tr("rates-ready") }}{% else %}{{ nav.tr("rates-importing") }}{% endif %} + {{ source.source_id }}{% if let Some(from) = source.verified_from_date %} · {{ from }}{% endif %}{% if let Some(through) = source.verified_through_date %} → {{ through }}{% endif %} +
+ {% endfor %} +
+ {% if page.can_configure %} + {% for source in currency.sources %} + {% if source.source_id != currency.default_source_id %} +
+ + + + + +
+ {% endif %} + {% endfor %} + {% endif %} +
+
+ {% endif %} + + {% if page.can_configure %} +

{{ nav.tr("rates-add-binding") }}

+

{{ nav.tr("rates-append-only-hint") }}

+
+ +
+ + + +
+
+
+ {% endif %} + {% endif %} + {% endif %} +
+ +
+

{{ nav.tr("rates-conversion-preview") }}

+

{{ nav.tr("rates-preview-hint") }}

+
+ +
+ + + + + + + + + +
+
+
+ + {% if let Some(preview) = page.preview %} +

{{ nav.tr("rates-preview-result") }}

+

{{ preview.response.original_amount }} {{ preview.response.original_currency }} = {{ preview.response.converted_amount }} {{ preview.response.accounting_currency }}

+
+
{{ nav.tr("rates-provider") }}
{{ preview.response.source_id }}
+
{{ nav.tr("rates-rate-method") }}
{{ preview.applied_method }}
+
{{ nav.tr("rates-date-rule") }}
{{ preview.applied_date_rule }}
+
{{ nav.tr("rates-rate-date") }}
{{ preview.response.rate_date.as_deref().unwrap_or("—") }}
+
+
{{ nav.tr("rates-audit-details") }} +
+
{{ nav.tr("rates-exact-quote") }}
{{ preview.quote() }}
+
{{ nav.tr("rates-determination") }}
{{ preview.response.determination_method }}
+
{{ nav.tr("rates-rounding") }}
{{ preview.response.rounding_method }}
+
{{ nav.tr("rates-reason") }}
{{ preview.response.selection_reason }}
+
{{ nav.tr("rates-observation") }}
{{ preview.observation_id() }}
+
{{ nav.tr("rates-import-batch") }}
{{ preview.import_batch_id() }}
+
+
+ {% endif %} +
+ +
+

{{ nav.tr("rates-evidence-lookup") }}

+

{{ nav.tr("rates-evidence-hint") }}

+
+ +
+ + + +
+
+
+ {% if let Some(has_exchange) = page.evidence_has_exchange %} + {% if !has_exchange %}

{{ nav.tr("rates-no-exchange") }}

+ {% else if page.evidence.is_empty() %}

{{ nav.tr("rates-no-evidence") }}

+ {% else %} +
+ + {% for item in page.evidence %} + + + + {% endfor %} +
#{{ nav.tr("rates-revision") }}{{ nav.tr("rates-conversion") }}{{ nav.tr("rates-provider") }}{{ nav.tr("rates-rate-date") }}{{ nav.tr("rates-created") }}{{ nav.tr("rates-reason") }}
{{ item.evidence.evidence_id }}{{ item.evidence.row_revision }}{{ item.evidence.original_amount }} {{ item.evidence.original_currency }} → {{ item.evidence.converted_amount }} {{ item.evidence.accounting_currency }}{{ item.evidence.source_id }}{{ item.evidence.rate_date.as_deref().unwrap_or("—") }}{{ item.evidence.evidence_created_at }}{{ item.evidence.selection_reason }}
+ {% if page.evidence_has_more %}

{{ nav.tr("rates-more-evidence") }}

{% endif %} + {% endif %} + {% endif %} +
+ +
+

{{ nav.tr("rates-provider-operations") }}

{{ nav.tr("rates-provider-health") }}

{{ nav.tr("rates-provider-health-hint") }}

+ {{ provider_status|safe }} + {{ provider_attempts|safe }} +
+ + {% for code in currency_codes %}{% endfor %} +
+{% endblock %} diff --git a/web/templates/pages/admin/table_definition/add_columns_panel.html b/web/templates/pages/admin/table_definition/add_columns_panel.html index 3c7134f2..6ae70741 100644 --- a/web/templates/pages/admin/table_definition/add_columns_panel.html +++ b/web/templates/pages/admin/table_definition/add_columns_panel.html @@ -2,48 +2,24 @@ {% include "pages/admin/table_definition/feedback.html" %} {% if page.table_is_writable() %} -
- {% if page.can_adjust_definition() %} -

{{ nav.tr("td-adjust-table-of") }} {{ page.selection.table }}

-

{{ nav.tr("td-adjust-table-hint") }}

- {% else %} -

{{ nav.tr("td-add-columns-to") }} {{ page.selection.table }}

-

{{ nav.tr("td-append-hint") }}

- {% endif %} -
- {% if page.can_adjust_definition() %}{% if let Some(detail) = page.detail %} - -

{{ nav.tr("td-existing-columns") }}

- - - - {% for column in detail.columns %} - - - - - - - {% endfor %} - -
{{ nav.tr("td-remove-column") }}{{ nav.tr("td-col-name") }}{{ nav.tr("td-col-type") }}{{ nav.tr("td-col-notes") }}
- {% if column.is_removal_choice() %} - - {% else %}{% endif %} - {{ column.name }}{{ column.field_type }} - {% if column.generated %}{{ nav.tr("column-flag-generated") }}{% endif %} - {% if column.read_only %}{{ nav.tr("column-flag-read-only") }}{% endif %} -
- {% endif %}{% endif %} -

{{ nav.tr("td-new-columns") }}

-
- {% include "pages/admin/table_definition/column_panel.html" %} -
-
-
+
+
+ {% if page.can_adjust_definition() %} +

{{ nav.tr("td-adjust-table-of") }} {{ page.selection.table }}

+

{{ nav.tr("td-adjust-table-hint") }}

+ {% else %} +

{{ nav.tr("td-add-columns-to") }} {{ page.selection.table }}

+

{{ nav.tr("td-append-hint") }}

+ {% endif %} +
+ {% if page.can_adjust_definition() %}{% if let Some(detail) = page.detail %} + + {% endif %}{% endif %} +
+ {% include "pages/admin/table_definition/column_panel.html" %} +
+
{% endif %} diff --git a/web/templates/pages/admin/table_definition/column_panel.html b/web/templates/pages/admin/table_definition/column_panel.html index 05812957..154279d1 100644 --- a/web/templates/pages/admin/table_definition/column_panel.html +++ b/web/templates/pages/admin/table_definition/column_panel.html @@ -22,6 +22,8 @@ {%- if let Some(message) = page.error %}{% call panel_dialog::error(nav.locale, nav.tr("td-could-not-add-column"), message) %}{% endcall %}{% endif -%} {% endif %} +
+

{{ nav.tr("builder-add-column-heading") }}

-{% if page.columns.added.is_empty() %} +{% let existing_count = page.existing_column_count() %} +
+

{{ nav.tr("builder-columns-heading") }} {{ existing_count + page.columns.added.len() }}

+{% if existing_count == 0 && page.columns.added.is_empty() %}

{{ nav.tr("td-no-staged-columns") }} {{ nav.tr("td-stage-column") }}.

{% else %} + {% if !page.columns.added.is_empty() %}

{{ nav.tr("builder-columns-order-hint") }}

{% endif %} - + + {# + The table as it already exists, shown exactly the way the create + builder shows a generated companion: a locked row with nothing to + reorder. Unlike a freshly staged column, removing one is a checkbox + to tick rather than an action that fires immediately — the same + control as before, just carried into this column's own cell so the + row lines up with the staged ones below it. + #} + {% if let Some(detail) = page.detail %} + {% for column in detail.columns %} + + + + + + + + + {% endfor %} + {% endif %} {% for column in page.columns.added %} +
{{ nav.tr("td-col-name") }}{{ nav.tr("td-col-type") }}{{ nav.tr("td-col-indexed") }}{{ nav.tr("td-col-options") }}
{{ nav.tr("builder-th-order") }}{{ nav.tr("builder-th-name") }}{{ nav.tr("builder-th-type") }}{{ nav.tr("builder-th-indexed") }}{{ nav.tr("builder-th-options") }}
{{ column.name }}{{ column.field_type }} + {% if column.generated %}{{ nav.tr("column-flag-generated") }}{% endif %} + {% if column.read_only %}{{ nav.tr("column-flag-read-only") }}{% endif %} + {% if !column.currency.is_empty() %}{{ column.currency }}{% if column.rounded %}, {{ nav.tr("td-rounding-half-up") }}{% endif %}{% endif %} + {% if column.quantity_ledger %}{{ nav.tr("column-flag-quantity-ledger") }}{% endif %} + + {# + A normal button, styled and worded exactly like the create + builder's own remove button — but this one deletes a column + that already holds nothing but empty rows, so it carries a + confirm() the builder's has no need for. The checkbox itself + is what still travels with the save; only its default look + is hidden. + #} + {% if page.can_adjust_definition() && column.is_removal_choice() %} + + {% endif %} +
+ + + {{ column.name }} {{ column.data_type }} @@ -214,7 +274,9 @@ {% endfor %} +
+ {{ nav.tr("common-cancel") }}
diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index 92378065..ccca234f 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -25,7 +25,7 @@ {% if nav.can_admin %}
  • {{ nav.tr("nav-admin") }}
  • {% endif %} {% if nav.can_permissions %}
  • {{ nav.tr("nav-permissions") }}
  • {% endif %}
  • {{ nav.tr("nav-analytics") }}
  • - {% if nav.can_exchange_rates %}
  • {{ nav.tr("nav-rates") }}
  • {% endif %} + {% if nav.can_exchange_rates %}
  • {{ nav.tr("nav-rates") }}
  • {% endif %} {% if nav.can_import %}
  • {{ nav.tr("nav-import") }}
  • {% endif %} {% if nav.can_export %}
  • {{ nav.tr("nav-export") }}
  • {% endif %} {% if nav.authenticated %} @@ -51,7 +51,7 @@ {% if nav.can_admin %}
  • {{ nav.tr("nav-admin") }}
  • {% endif %} {% if nav.can_permissions %}
  • {{ nav.tr("nav-permissions") }}
  • {% endif %}
  • {{ nav.tr("nav-analytics") }}
  • - {% if nav.can_exchange_rates %}
  • {{ nav.tr("nav-rates") }}
  • {% endif %} + {% if nav.can_exchange_rates %}
  • {{ nav.tr("nav-rates") }}
  • {% endif %} {% if nav.can_import %}
  • {{ nav.tr("nav-import") }}
  • {% endif %} {% if nav.can_export %}
  • {{ nav.tr("nav-export") }}
  • {% endif %} {% if nav.authenticated %}