conversions web + put table definition

This commit is contained in:
Filipriec
2026-08-24 09:35:19 +02:00
parent 2e95201a71
commit 5d6f53c96e
25 changed files with 1542 additions and 96 deletions

2
server

Submodule server updated: fe58121374...e454005545

View File

@@ -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é

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}");

View File

@@ -8,9 +8,7 @@
use axum::http::HeaderMap;
use crate::{
AppState,
auth::GetAuthorizationRequest,
ecb::GetEcbPipelineStatusRequest,
AppState, auth::GetAuthorizationRequest, ecb::GetEcbPipelineStatusRequest,
services::authenticated_request,
};

View File

@@ -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<AppState>, 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<AppState>,
headers: HeaderMap,
) -> Response {
pub(crate) async fn status_fragment(State(state): State<AppState>, 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) => {
(
LoadError::Backend(message) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_error(Locale::from_headers(headers), &message)),
)
.into_response()
}
.into_response(),
}
}

View File

@@ -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<AppState> {
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))

View File

@@ -48,7 +48,10 @@ fn coarse(locale: Locale, duration: SignedDuration) -> String {
}
fn elapsed_between(locale: Locale, from: &str, to: &str) -> Option<String> {
let (from, to) = (from.parse::<Timestamp>().ok()?, to.parse::<Timestamp>().ok()?);
let (from, to) = (
from.parse::<Timestamp>().ok()?,
to.parse::<Timestamp>().ok()?,
);
Some(coarse(locale, from.duration_until(to)))
}

View File

@@ -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(),

View File

@@ -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<ExchangeRateWorkspaceState, LoadError> {
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::<Vec<_>>();
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,
})
}

View File

@@ -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<AppState>,
headers: HeaderMap,
Query(query): Query<WorkspaceQuery>,
) -> 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<AppState>,
headers: HeaderMap,
Form(form): Form<ConfigurationForm>,
) -> 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<AppState>,
headers: HeaderMap,
Form(form): Form<PreviewForm>,
) -> 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<AppState>,
headers: HeaderMap,
Form(form): Form<EvidenceForm>,
) -> 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::<i64>() {
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<super::workspace_state::ExchangeRateWorkspaceState, LoadError>,
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(),
}
}

View File

@@ -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<PreviewDirectConversionRequest, &'static str> {
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<CurrencySourceView>,
}
#[derive(Clone, Debug)]
pub(crate) struct CurrencySourceView {
pub source_id: String,
pub coverage_complete: bool,
pub verified_from_date: Option<String>,
pub verified_through_date: Option<String>,
}
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<CurrencySettingsView>,
}
#[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<PreviewView>,
pub evidence_form: EvidenceForm,
pub evidence: Vec<EvidenceView>,
pub evidence_has_exchange: Option<bool>,
pub evidence_has_more: bool,
pub status: Option<String>,
pub error: Option<String>,
}
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<String>,
pub selected_profile: String,
pub providers: Vec<String>,
pub settings: Option<ProfileSettingsView>,
pub can_configure: bool,
pub configuration: ConfigurationForm,
pub preview_form: PreviewForm,
pub preview: Option<PreviewView>,
pub evidence_form: EvidenceForm,
pub evidence: Vec<EvidenceView>,
pub evidence_has_exchange: Option<bool>,
pub evidence_has_more: bool,
pub provider_health: EcbPageState,
pub status: Option<String>,
pub error: Option<String>,
}
impl ExchangeRateWorkspaceState {
pub(crate) fn is_selected_profile(&self, profile: &str) -> bool {
self.selected_profile == profile
}
}

View File

@@ -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}"
);
}
}

View File

@@ -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),
_ => {}
}

View File

@@ -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)
}

View File

@@ -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 <span class=\"count\">3</span>"), "{html}");
assert!(html.contains(r#"class="generated-row""#), "{html}");
assert!(html.contains(r#"<code>number</code>"#), "{html}");
assert!(html.contains(r#"<code>total</code>"#), "{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&#38;table=invoice">Cancel"#), "{html}");
}
#[test]
fn a_populated_table_offers_append_only_columns() {
let mut state = page();

View File

@@ -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; }

View File

@@ -18,7 +18,7 @@
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">{{ nav.tr("admin-add-rule") }}</a>{% endif %}
{% if page.can_import %}<a href="/admin/import">{{ nav.tr("admin-import") }}</a>{% endif %}
{% if page.can_export %}<a href="/admin/export">{{ nav.tr("admin-export") }}</a>{% endif %}
{% if page.can_exchange_rates %}<a href="/admin/ecb">{{ nav.tr("admin-exchange-rates") }}</a>{% endif %}
{% if page.can_exchange_rates %}<a href="/admin/exchange-rates">{{ nav.tr("admin-exchange-rates") }}</a>{% endif %}
</div>
</section>
<div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div>

View File

@@ -0,0 +1,58 @@
<section class="panel">
<h2>
{{ nav.tr("ecb-import-attempts") }} <span class="count">{{ page.batches.len() }}</span>
{%- if page.failure_count() > 0 %}<span class="tag tag-bad">{{ page.failed_count_label() }}</span>{% endif -%}
</h2>
{% if page.batches.is_empty() %}
<p class="hint">{{ nav.tr("ecb-no-attempts-hint") }}</p>
{% else %}
<div class="table-scroll">
<table class="builder-table">
<thead>
<tr>
<th class="mark">#</th><th>{{ nav.tr("ecb-th-result") }}</th><th>{{ nav.tr("ecb-th-started") }}</th><th>{{ nav.tr("ecb-th-finished") }}</th>
<th>{{ nav.tr("ecb-th-took") }}</th><th>{{ nav.tr("ecb-th-window") }}</th><th class="numeric">{{ nav.tr("ecb-th-observations") }}</th>
<th>{{ nav.tr("ecb-th-verified-through") }}</th>
</tr>
</thead>
<tbody>
{% for batch in page.batches %}
<tr{% if batch.failed() %} class="attempt-failed"{% endif %}>
<td class="mark">{{ batch.batch_id }}</td>
<td>
{%- if batch.succeeded() %}<span class="tag tag-ok">{{ nav.tr("ecb-batch-succeeded") }}</span>
{%- else if batch.running() %}<span class="tag">{{ nav.tr("ecb-batch-running") }}</span>
{%- else %}<span class="tag tag-bad">{{ nav.tr("ecb-batch-failed") }}</span>{% endif -%}
</td>
<td class="moment">{{ batch.started() }}</td>
<td class="moment">
{%- if let Some(finished) = batch.finished() %}{{ finished }}
{%- else %}<span class="hint">{{ nav.tr("ecb-still-running") }}</span>{% endif -%}
</td>
<td class="numeric">
{%- if let Some(took) = batch.took(nav.locale) %}{{ took }}
{%- else %}<span class="hint"></span>{% endif -%}
</td>
<td>
{{ batch.window() }}
<div class="hint endpoint" title="{{ batch.endpoint }}"><code>{{ batch.endpoint }}</code></div>
</td>
<td class="numeric">{{ batch.observations(nav.locale) }}</td>
<td class="moment">
{%- if let Some(date) = batch.verified_through_date %}{{ date }}
{%- else %}<span class="hint"></span>{% endif -%}
</td>
</tr>
{% if let Some(message) = batch.error_message %}
<tr class="attempt-error">
<td class="mark"></td>
<td colspan="7">{{ message }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">{{ nav.tr("ecb-audit-hint") }}</p>
{% endif %}
</section>

View File

@@ -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 %}
<main>
<section class="heading">
<div>
<p class="eyebrow">{{ nav.tr("rates-eyebrow") }}</p>
<h1>{{ nav.tr("rates-title") }}</h1>
<p>{{ nav.tr("rates-description") }}</p>
</div>
<div class="actions"><a href="/admin">{{ nav.tr("ui-back-to-admin-panel") }}</a></div>
</section>
{%- 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 class="tabs" aria-label="{{ nav.tr("rates-sections") }}">
<a class="tab" href="#configuration"><span>{{ nav.tr("rates-tab-configuration") }}</span><small>{{ nav.tr("rates-tab-configuration-sub") }}</small></a>
<a class="tab" href="#preview"><span>{{ nav.tr("rates-tab-preview") }}</span><small>{{ nav.tr("rates-tab-preview-sub") }}</small></a>
<a class="tab" href="#evidence"><span>{{ nav.tr("rates-tab-evidence") }}</span><small>{{ nav.tr("rates-tab-evidence-sub") }}</small></a>
<a class="tab" href="#providers"><span>{{ nav.tr("rates-tab-providers") }}</span><small>{{ nav.tr("rates-tab-providers-sub") }}</small></a>
</nav>
<section class="panel" id="configuration">
<h2>{{ nav.tr("rates-profile-configuration") }}</h2>
{% if page.profiles.is_empty() %}
<p class="hint">{{ nav.tr("rates-no-profiles") }}</p>
{% else %}
<form method="get" action="/admin/exchange-rates" class="form-grid">
<label>{{ nav.tr("rates-profile") }}
<select name="profile" onchange="this.form.submit()">
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.is_selected_profile(profile) %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
</select>
</label>
</form>
{% if let Some(settings) = page.settings %}
<dl class="status-grid">
<div><dt>{{ nav.tr("rates-accounting-currency") }}</dt><dd><strong>{{ settings.accounting_currency }}</strong> · {{ nav.tr("rates-immutable") }}</dd></div>
<div><dt>{{ nav.tr("rates-configured-currencies") }}</dt><dd>{{ settings.foreign_currencies.len() }}</dd></div>
<div><dt>{{ nav.tr("rates-registered-providers") }}</dt><dd>{% if page.providers.is_empty() %}—{% else %}{{ page.providers|join(", ") }}{% endif %}</dd></div>
</dl>
{% if settings.foreign_currencies.is_empty() %}
<p class="hint">{{ nav.tr("rates-no-foreign-currencies") }}</p>
{% else %}
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>{{ nav.tr("rates-foreign-currency") }}</th><th>{{ nav.tr("rates-default-provider") }}</th><th>{{ nav.tr("rates-providers") }}</th><th>{{ nav.tr("rates-coverage") }}</th><th></th></tr></thead>
<tbody>
{% for currency in settings.foreign_currencies %}
<tr>
<td><strong>{{ currency.currency }}</strong></td>
<td><span class="tag">{{ currency.default_source_id }}</span></td>
<td>{% for source in currency.sources %}<span class="tag">{{ source.source_id }}</span> {% endfor %}</td>
<td>
{% for source in currency.sources %}
<div>
<span class="tag {{ source.coverage_class() }}">{% if source.coverage_complete %}{{ nav.tr("rates-ready") }}{% else %}{{ nav.tr("rates-importing") }}{% endif %}</span>
<span class="hint">{{ source.source_id }}{% if let Some(from) = source.verified_from_date %} · {{ from }}{% endif %}{% if let Some(through) = source.verified_through_date %} → {{ through }}{% endif %}</span>
</div>
{% endfor %}
</td>
<td>
{% if page.can_configure %}
{% for source in currency.sources %}
{% if source.source_id != currency.default_source_id %}
<form method="post" action="/admin/exchange-rates/configuration">
<input type="hidden" name="profile" value="{{ page.selected_profile }}">
<input type="hidden" name="currency" value="{{ currency.currency }}">
<input type="hidden" name="source_id" value="{{ source.source_id }}">
<input type="hidden" name="make_default" value="true">
<button type="submit" class="secondary">{{ nav.tr("rates-make-default") }}</button>
</form>
{% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if page.can_configure %}
<h3>{{ nav.tr("rates-add-binding") }}</h3>
<p class="hint">{{ nav.tr("rates-append-only-hint") }}</p>
<form method="post" action="/admin/exchange-rates/configuration">
<input type="hidden" name="profile" value="{{ page.selected_profile }}">
<div class="form-grid">
<label>{{ nav.tr("rates-foreign-currency") }}
<input name="currency" value="{{ page.configuration.currency }}" list="currency-codes" maxlength="3" placeholder="CZK" required>
</label>
<label>{{ nav.tr("rates-provider") }}
<select name="source_id" required>
<option value="">{{ nav.tr("rates-choose-provider") }}</option>
{% for provider in page.providers %}<option value="{{ provider }}" {% if page.configuration.source_id == *provider %}selected{% endif %}>{{ provider }}</option>{% endfor %}
</select>
</label>
<label class="check"><input type="checkbox" name="make_default" value="true" {% if page.configuration.make_default %}checked{% endif %}>{{ nav.tr("rates-make-default-now") }}</label>
</div>
<div class="form-actions"><button type="submit">{{ nav.tr("rates-add-binding") }}</button></div>
</form>
{% endif %}
{% endif %}
{% endif %}
</section>
<section class="panel" id="preview" x-data="{ method: '{{ page.preview_form.method }}' }">
<h2>{{ nav.tr("rates-conversion-preview") }}</h2>
<p class="hint">{{ nav.tr("rates-preview-hint") }}</p>
<form method="post" action="/admin/exchange-rates/preview">
<input type="hidden" name="profile" value="{{ page.selected_profile }}">
<div class="form-grid">
<label>{{ nav.tr("rates-amount") }}<input name="original_amount" value="{{ page.preview_form.original_amount }}" inputmode="decimal" placeholder="2415.30" required></label>
<label>{{ nav.tr("rates-currency") }}<input name="original_currency" value="{{ page.preview_form.original_currency }}" list="currency-codes" maxlength="3" placeholder="CZK" required></label>
<label>{{ nav.tr("rates-basis-date") }}<input type="date" name="conversion_basis_date" value="{{ page.preview_form.conversion_basis_date }}" required></label>
<label>{{ nav.tr("rates-rate-method") }}
<select name="method" x-model="method">
<option value="automatic-official" {% if page.preview_form.method == "automatic-official" %}selected{% endif %}>{{ nav.tr("rates-method-automatic") }}</option>
<option value="official-specific" {% if page.preview_form.method == "official-specific" %}selected{% endif %}>{{ nav.tr("rates-method-specific") }}</option>
<option value="saved-custom" {% if page.preview_form.method == "saved-custom" %}selected{% endif %}>{{ nav.tr("rates-method-saved") }}</option>
<option value="manual" {% if page.preview_form.method == "manual" %}selected{% endif %}>{{ nav.tr("rates-method-manual") }}</option>
</select>
</label>
<label x-show="method !== 'official-specific' && method !== 'manual'">{{ nav.tr("rates-date-context") }}
<select name="context">
<option value="ordinary" {% if page.preview_form.context == "ordinary" %}selected{% endif %}>{{ nav.tr("rates-context-ordinary") }}</option>
<option value="statement" {% if page.preview_form.context == "statement" %}selected{% endif %}>{{ nav.tr("rates-context-statement") }}</option>
</select>
</label>
<label x-show="method === 'official-specific' || method === 'manual'">{{ nav.tr("rates-specific-date") }}<input type="date" name="specific_rate_date" value="{{ page.preview_form.specific_rate_date }}"></label>
<label x-show="method === 'manual'">{{ nav.tr("rates-manual-foreign-units") }}<input name="manual_foreign_units" value="{{ page.preview_form.manual_foreign_units }}" inputmode="decimal" placeholder="24.153"></label>
<label>{{ nav.tr("rates-provider-override") }}
<select name="rate_source_id"><option value="">{{ nav.tr("rates-use-default-provider") }}</option>{% for provider in page.providers %}<option value="{{ provider }}" {% if page.preview_form.rate_source_id == *provider %}selected{% endif %}>{{ provider }}</option>{% endfor %}</select>
</label>
<label>{{ nav.tr("rates-reason") }}<input name="reason" value="{{ page.preview_form.reason }}" maxlength="1000"><small>{{ nav.tr("rates-reason-hint") }}</small></label>
</div>
<div class="form-actions"><button type="submit">{{ nav.tr("rates-preview-action") }}</button></div>
</form>
{% if let Some(preview) = page.preview %}
<h3>{{ nav.tr("rates-preview-result") }}</h3>
<p><strong>{{ preview.response.original_amount }} {{ preview.response.original_currency }} = {{ preview.response.converted_amount }} {{ preview.response.accounting_currency }}</strong></p>
<dl class="status-grid">
<div><dt>{{ nav.tr("rates-provider") }}</dt><dd>{{ preview.response.source_id }}</dd></div>
<div><dt>{{ nav.tr("rates-rate-method") }}</dt><dd>{{ preview.applied_method }}</dd></div>
<div><dt>{{ nav.tr("rates-date-rule") }}</dt><dd>{{ preview.applied_date_rule }}</dd></div>
<div><dt>{{ nav.tr("rates-rate-date") }}</dt><dd>{{ preview.response.rate_date.as_deref().unwrap_or("—") }}</dd></div>
</dl>
<details><summary>{{ nav.tr("rates-audit-details") }}</summary>
<dl class="status-grid">
<div><dt>{{ nav.tr("rates-exact-quote") }}</dt><dd>{{ preview.quote() }}</dd></div>
<div><dt>{{ nav.tr("rates-determination") }}</dt><dd>{{ preview.response.determination_method }}</dd></div>
<div><dt>{{ nav.tr("rates-rounding") }}</dt><dd>{{ preview.response.rounding_method }}</dd></div>
<div><dt>{{ nav.tr("rates-reason") }}</dt><dd>{{ preview.response.selection_reason }}</dd></div>
<div><dt>{{ nav.tr("rates-observation") }}</dt><dd>{{ preview.observation_id() }}</dd></div>
<div><dt>{{ nav.tr("rates-import-batch") }}</dt><dd>{{ preview.import_batch_id() }}</dd></div>
</dl>
</details>
{% endif %}
</section>
<section class="panel" id="evidence">
<h2>{{ nav.tr("rates-evidence-lookup") }}</h2>
<p class="hint">{{ nav.tr("rates-evidence-hint") }}</p>
<form method="post" action="/admin/exchange-rates/evidence">
<input type="hidden" name="profile" value="{{ page.selected_profile }}">
<div class="form-grid">
<label>{{ nav.tr("rates-table") }}<input name="table_name" value="{{ page.evidence_form.table_name }}" required></label>
<label>{{ nav.tr("rates-record-id") }}<input name="record_id" value="{{ page.evidence_form.record_id }}" inputmode="numeric" required></label>
<label>{{ nav.tr("rates-column") }}<input name="column_name" value="{{ page.evidence_form.column_name }}" required></label>
</div>
<div class="form-actions"><button type="submit">{{ nav.tr("rates-find-evidence") }}</button></div>
</form>
{% if let Some(has_exchange) = page.evidence_has_exchange %}
{% if !has_exchange %}<p class="hint">{{ nav.tr("rates-no-exchange") }}</p>
{% else if page.evidence.is_empty() %}<p class="hint">{{ nav.tr("rates-no-evidence") }}</p>
{% else %}
<div class="table-scroll"><table class="builder-table">
<thead><tr><th>#</th><th>{{ nav.tr("rates-revision") }}</th><th>{{ nav.tr("rates-conversion") }}</th><th>{{ nav.tr("rates-provider") }}</th><th>{{ nav.tr("rates-rate-date") }}</th><th>{{ nav.tr("rates-created") }}</th><th>{{ nav.tr("rates-reason") }}</th></tr></thead>
<tbody>{% for item in page.evidence %}<tr>
<td>{{ item.evidence.evidence_id }}</td><td>{{ item.evidence.row_revision }}</td>
<td>{{ item.evidence.original_amount }} {{ item.evidence.original_currency }} → {{ item.evidence.converted_amount }} {{ item.evidence.accounting_currency }}</td>
<td>{{ item.evidence.source_id }}</td><td>{{ item.evidence.rate_date.as_deref().unwrap_or("—") }}</td><td>{{ item.evidence.evidence_created_at }}</td><td>{{ item.evidence.selection_reason }}</td>
</tr>{% endfor %}</tbody>
</table></div>
{% if page.evidence_has_more %}<p class="hint">{{ nav.tr("rates-more-evidence") }}</p>{% endif %}
{% endif %}
{% endif %}
</section>
<section id="providers">
<div class="heading"><div><p class="eyebrow">{{ nav.tr("rates-provider-operations") }}</p><h2>{{ nav.tr("rates-provider-health") }}</h2><p>{{ nav.tr("rates-provider-health-hint") }}</p></div></div>
{{ provider_status|safe }}
{{ provider_attempts|safe }}
</section>
<datalist id="currency-codes">{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}</datalist>
</main>
{% endblock %}

View File

@@ -2,7 +2,11 @@
{% include "pages/admin/table_definition/feedback.html" %}
{% if page.table_is_writable() %}
<section class="panel">
<form id="column-form"
hx-post="/admin/tables/columns/add{{ page.selection.query() }}"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<section class="builder-section">
{% if page.can_adjust_definition() %}
<h2>{{ nav.tr("td-adjust-table-of") }} <code>{{ page.selection.table }}</code></h2>
<p class="hint">{{ nav.tr("td-adjust-table-hint") }}</p>
@@ -10,40 +14,12 @@
<h2>{{ nav.tr("td-add-columns-to") }} <code>{{ page.selection.table }}</code></h2>
<p class="hint">{{ nav.tr("td-append-hint") }}</p>
{% endif %}
<form id="column-form"
hx-post="/admin/tables/columns/add{{ page.selection.query() }}"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
</section>
{% if page.can_adjust_definition() %}{% if let Some(detail) = page.detail %}
<input type="hidden" name="expected_row_version" value="{{ detail.row_version }}">
<h3>{{ nav.tr("td-existing-columns") }}</h3>
<table class="builder-table">
<thead><tr><th>{{ nav.tr("td-remove-column") }}</th><th>{{ nav.tr("td-col-name") }}</th><th>{{ nav.tr("td-col-type") }}</th><th>{{ nav.tr("td-col-notes") }}</th></tr></thead>
<tbody>
{% for column in detail.columns %}
<tr>
<td>
{% if column.is_removal_choice() %}
<input type="checkbox" name="remove_column_ids" value="{{ column.column_id }}"
aria-label="{{ nav.tr_args("td-remove-column-aria", [("column", column.name.clone())]) }}"
{% if page.column_is_selected_for_removal(column.column_id) %}checked{% endif %}>
{% else %}<span class="hint"></span>{% endif %}
</td>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}</td>
<td>
{% if column.generated %}<span class="tag">{{ nav.tr("column-flag-generated") }}</span>{% endif %}
{% if column.read_only %}<span class="tag">{{ nav.tr("column-flag-read-only") }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}{% endif %}
<h3>{{ nav.tr("td-new-columns") }}</h3>
<div id="column-panel">
{% include "pages/admin/table_definition/column_panel.html" %}
</div>
</form>
</section>
{% endif %}

View File

@@ -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 %}
<section class="builder-section">
<h2>{{ nav.tr("builder-add-column-heading") }}</h2>
<div class="form-grid">
<label>{% if page.columns.show_link_target() %}{{ nav.tr("td-link-alias") }}{% else %}{{ nav.tr("td-column-name") }}{% endif %}
<input name="column_name_input" value="{{ page.columns.name_input }}" placeholder="issued_on">
@@ -160,15 +162,73 @@
hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "add-column"}'>{{ nav.tr("td-stage-column") }}</button>
</section>
{% if page.columns.added.is_empty() %}
{% let existing_count = page.existing_column_count() %}
<section class="builder-section">
<h2>{{ nav.tr("builder-columns-heading") }} <span class="count">{{ existing_count + page.columns.added.len() }}</span></h2>
{% if existing_count == 0 && page.columns.added.is_empty() %}
<p class="hint">{{ nav.tr("td-no-staged-columns") }} <em>{{ nav.tr("td-stage-column") }}</em>.</p>
{% else %}
{% if !page.columns.added.is_empty() %}<p class="hint">{{ nav.tr("builder-columns-order-hint") }}</p>{% endif %}
<table class="builder-table">
<thead><tr><th>{{ nav.tr("td-col-name") }}</th><th>{{ nav.tr("td-col-type") }}</th><th>{{ nav.tr("td-col-indexed") }}</th><th>{{ nav.tr("td-col-options") }}</th><th></th></tr></thead>
<thead><tr><th>{{ nav.tr("builder-th-order") }}</th><th>{{ nav.tr("builder-th-name") }}</th><th>{{ nav.tr("builder-th-type") }}</th><th>{{ nav.tr("builder-th-indexed") }}</th><th>{{ nav.tr("builder-th-options") }}</th><th></th></tr></thead>
<tbody>
{#
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 %}
<tr class="generated-row">
<td class="order"><span class="hint"></span></td>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}</td>
<td><span class="hint"></span></td>
<td>
{% if column.generated %}<span class="tag">{{ nav.tr("column-flag-generated") }}</span>{% endif %}
{% if column.read_only %}<span class="tag">{{ nav.tr("column-flag-read-only") }}</span>{% endif %}
{% if !column.currency.is_empty() %}<span class="tag">{{ column.currency }}{% if column.rounded %}, {{ nav.tr("td-rounding-half-up") }}{% endif %}</span>{% endif %}
{% if column.quantity_ledger %}<span class="tag">{{ nav.tr("column-flag-quantity-ledger") }}</span>{% endif %}
</td>
<td>
{#
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() %}
<label class="danger"
onclick="if(!confirm('{{ nav.tr_args("td-remove-column-confirm", [("column", column.name.clone())]) }}')){event.preventDefault();}">
<input type="checkbox" name="remove_column_ids" value="{{ column.column_id }}"
aria-label="{{ nav.tr_args("td-remove-column-aria", [("column", column.name.clone())]) }}"
{% if page.column_is_selected_for_removal(column.column_id) %}checked{% endif %}>
{{ nav.tr("common-remove") }}
</label>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
{% for column in page.columns.added %}
<tr>
<td class="order">
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("builder-move-up", [("name", column.name.clone())]) }}"
{% if loop.first %}disabled{% else %}hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "move-column-up", "index": "{{ loop.index0 }}"}'{% endif %}>&uarr;</button>
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("builder-move-down", [("name", column.name.clone())]) }}"
{% if loop.last %}disabled{% else %}hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "move-column-down", "index": "{{ loop.index0 }}"}'{% endif %}>&darr;</button>
</td>
<td><code>{{ column.name }}</code></td>
<td>{{ column.data_type }}</td>
<td>
@@ -214,7 +274,9 @@
<input type="hidden" name="column_rounding" value="{{ column.money_mode.label() }}">
<input type="hidden" name="column_currencies" value="{{ column.currency }}">
{% endfor %}
</section>
<div class="form-actions">
<a href="/admin{{ page.selection.query() }}">{{ nav.tr("common-cancel") }}</a>
<button type="submit">{% if page.can_adjust_definition() %}{{ nav.tr("td-save-definition") }}{% else %}{{ nav.tr("td-add-columns-title") }}{% endif %}</button>
</div>

View File

@@ -25,7 +25,7 @@
{% if nav.can_admin %}<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>{{ nav.tr("nav-admin") }}</a></li>{% endif %}
{% if nav.can_permissions %}<li><a href="/permissions" class="{% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>{{ nav.tr("nav-permissions") }}</a></li>{% endif %}
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>{{ nav.tr("nav-analytics") }}</a></li>
{% if nav.can_exchange_rates %}<li><a href="/admin/ecb" class="{% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_exchange_rates %}<li><a href="/admin/exchange-rates" class="{% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">{{ nav.tr("nav-import") }}</a></li>{% endif %}
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">{{ nav.tr("nav-export") }}</a></li>{% endif %}
{% if nav.authenticated %}
@@ -51,7 +51,7 @@
{% if nav.can_admin %}<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>{{ nav.tr("nav-admin") }}</a></li>{% endif %}
{% if nav.can_permissions %}<li class="py-4"><a href="/permissions" class="w-full text-lg {% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>{{ nav.tr("nav-permissions") }}</a></li>{% endif %}
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>{{ nav.tr("nav-analytics") }}</a></li>
{% if nav.can_exchange_rates %}<li class="py-4"><a href="/admin/ecb" class="w-full text-lg {% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_exchange_rates %}<li class="py-4"><a href="/admin/exchange-rates" class="w-full text-lg {% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>{{ nav.tr("nav-rates") }}</a></li>{% endif %}
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">{{ nav.tr("nav-import") }}</a></li>{% endif %}
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">{{ nav.tr("nav-export") }}</a></li>{% endif %}
{% if nav.authenticated %}