conversions web + put table definition
This commit is contained in:
2
server
2
server
Submodule server updated: fe58121374...e454005545
@@ -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é
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
ecb::GetEcbPipelineStatusRequest,
|
||||
AppState, auth::GetAuthorizationRequest, ecb::GetEcbPipelineStatusRequest,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
141
web/src/pages/admin/ecb/workspace_loader.rs
Normal file
141
web/src/pages/admin/ecb/workspace_loader.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
250
web/src/pages/admin/ecb/workspace_logic.rs
Normal file
250
web/src/pages/admin/ecb/workspace_logic.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
253
web/src/pages/admin/ecb/workspace_state.rs
Normal file
253
web/src/pages/admin/ecb/workspace_state.rs
Normal 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
|
||||
}
|
||||
}
|
||||
139
web/src/pages/admin/ecb/workspace_ui.rs
Normal file
139
web/src/pages/admin/ecb/workspace_ui.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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&table=invoice">Cancel"#), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_populated_table_offers_append_only_columns() {
|
||||
let mut state = page();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
web/templates/pages/admin/ecb/attempts.html
Normal file
58
web/templates/pages/admin/ecb/attempts.html
Normal 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>
|
||||
208
web/templates/pages/admin/ecb/workspace.html
Normal file
208
web/templates/pages/admin/ecb/workspace.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}>↑</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 %}>↓</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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user