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

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

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();