Feature/i18n #5

Merged
pitfriedrich merged 2 commits from feature/i18n into main 2026-07-19 19:17:09 +00:00
10 changed files with 435 additions and 65 deletions
-1
View File
@@ -16,7 +16,6 @@ jobs:
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Build and Test
run: |
+1
View File
@@ -0,0 +1 @@
key de en es status
1 key de en es status
+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Commands
```bash
./mvnw spring-boot:run # run app (default goal) — http://localhost:8080, ~30s first start
./mvnw compile # compile only
./mvnw package # production build → target/*.jar
java -jar target/*.jar # run production jar
```
No test sources exist yet (`src/test` is empty) — `spring-boot-starter-test` and `browserless-test-spring` are on the classpath but unused.
**Java 25 toolchain required** (`pom.xml` sets `java.version=25`). If the default JDK on PATH is older (check `java -version`), point `JAVA_HOME` at a Java 25 install for the Maven build, e.g.:
```bash
JAVA_HOME="/path/to/jdk-25" ./mvnw compile
```
Port 8080 conflicts: a prior `spring-boot:run` left running in the background is the usual cause (Vaadin dev mode keeps a second process/thread alive under a different PID than the launching Maven process — killing the Maven process alone may not free the port). Find and stop the actual listener before restarting.
## Architecture
Vaadin Flow (server-side Java UI, no hand-written HTML/JS for views) on Spring Boot 4.1, using the **Aura** theme (not Lumo — no `@Theme` annotation; Aura is wired via `@StyleSheet(Aura.STYLESHEET)` in `Application.java`).
**Charts are ApexCharts, not Vaadin Charts.** The bridge lives in `components/`:
- `ApexChart` (abstract, `@Tag("apex-chart")`) — owns the JS module (`frontend/components/apex-chart.ts`, a Lit element rendering into light DOM), serializes an options `Map` to JSON via Jackson and calls `renderChart` client-side, and exposes point-click events back to the server via `@ClientCallable`.
- `AxisChart` (abstract) — builds `series`/`xaxis.categories` options for line/bar.
- `LineChart`, `BarChart` extend `AxisChart`; `PieChart` extends `ApexChart` directly (`series`/`labels` instead of axis-based).
- `DialectTheme` — single source of chart styling (categorical color palette, grid/legend/stroke option fragments). Every chart's `setData(...)` starts from `DialectTheme.baseOptions(chartType)` and merges in its data. Add new chart types here, not by duplicating option maps.
Since `apex-chart.ts` renders into light DOM (`createRenderRoot()` returns `this`), global CSS can reach into the chart markup — but series/legend/grid colors are driven entirely by the options JSON, not CSS, because ApexCharts renders its own SVG/canvas.
**Views** (`views/`): `MainLayout` (`@Layout`, applies to all routes) is the `AppLayout` shell — navbar + `SideNav` drawer, with one `SideNavItem` per route. Routes: `DashboardView` (`@Route("")`) wraps each chart in a `Card` (`components/Card.java`); `FormView` (`@Route("formular")`) demonstrates form controls bound via `Binder`; `TableView` (`@Route("tabelle")`) demonstrates a `Grid` over dummy data with a live text filter (`GridListDataView.addFilter`, `TextField` in `ValueChangeMode.EAGER`). New views should reuse `Card` to wrap their content rather than adding components directly, and get a matching `SideNavItem` in `MainLayout`.
**Styling**: `src/main/resources/META-INF/resources/styles.css` is the one project-level stylesheet (loaded via `@StyleSheet("styles.css")` in `Application.java`). It defines `--dialect-*` design tokens (Dialect design system: primary orange `#E86C00`, cool-gray background, card radius/shadow) and aliases them onto Aura's own CSS custom properties (`--aura-accent-color-*`, `--aura-background-color-*`, `--aura-orange`, `--aura-yellow`) rather than fighting the theme. Aura tokens use OKLCH + relative-color syntax and accept plain hex overrides. When restyling, prefer extending this alias layer over hardcoding new colors in components.
UI copy/data (chart labels, notifications) is in German.
@@ -6,38 +6,42 @@ import com.example.components.LineChart;
import com.example.components.PieChart;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import java.util.List;
@Route("")
@PageTitle("Dashboard")
public class DashboardView extends VerticalLayout {
public class DashboardView extends VerticalLayout implements HasDynamicTitle {
public DashboardView() {
addClassName("dialect-content");
List<String> months = List.of(
getTranslation("month.jan"), getTranslation("month.feb"),
getTranslation("month.mar"), getTranslation("month.apr"),
getTranslation("month.may"), getTranslation("month.jun"));
LineChart lineChart = new LineChart();
lineChart.setWidthFull();
lineChart.setHeight("400px");
lineChart.setData("Umsatz 2026",
lineChart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0),
List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun"));
months);
lineChart.addPointClickListener(e -> {
Notification.show("Serie %d, Punkt %d"
.formatted(e.getSeriesIndex(), e.getDataPointIndex()));
Notification.show(getTranslation("chart.pointClick",
e.getSeriesIndex(), e.getDataPointIndex()));
});
BarChart barChart = new BarChart();
barChart.setWidthFull();
barChart.setHeight("400px");
barChart.setData("Umsatz 2026",
barChart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0),
List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun"));
months);
barChart.addPointClickListener(e -> {
Notification.show("Serie %d, Punkt %d"
.formatted(e.getSeriesIndex(), e.getDataPointIndex()));
Notification.show(getTranslation("chart.pointClick",
e.getSeriesIndex(), e.getDataPointIndex()));
});
PieChart pieChart = new PieChart();
@@ -45,13 +49,19 @@ public class DashboardView extends VerticalLayout {
pieChart.setHeight("400px");
pieChart.setData(
List.of(30.0, 40.0, 35.0, 50.0),
List.of("Nord", "Süd", "Ost", "West"));
List.of(getTranslation("region.north"), getTranslation("region.south"),
getTranslation("region.east"), getTranslation("region.west")));
pieChart.addPointClickListener(e -> {
Notification.show("Slice %d".formatted(e.getDataPointIndex()));
Notification.show(getTranslation("chart.sliceClick", e.getDataPointIndex()));
});
add(new Card("Umsatz-Entwicklung", lineChart),
new Card("Umsatz nach Monat", barChart),
new Card("Umsatz nach Region", pieChart));
add(new Card(getTranslation("card.revenueTrend"), lineChart),
new Card(getTranslation("card.revenueByMonth"), barChart),
new Card(getTranslation("card.revenueByRegion"), pieChart));
}
@Override
public String getPageTitle() {
return getTranslation("page.dashboard");
}
}
+41 -33
View File
@@ -20,7 +20,7 @@ import com.vaadin.flow.component.timepicker.TimePicker;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.validator.EmailValidator;
import com.vaadin.flow.data.validator.IntegerRangeValidator;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import java.text.DateFormatSymbols;
@@ -33,30 +33,34 @@ import java.util.Locale;
* to {@link Registration} via {@link Binder}.
*/
@Route("formular")
@PageTitle("Formular")
public class FormView extends VerticalLayout {
public class FormView extends VerticalLayout implements HasDynamicTitle {
private final Binder<Registration> binder = new Binder<>(Registration.class);
public FormView() {
addClassName("dialect-content");
add(new Card("Registrierung", buildForm()));
add(new Card(getTranslation("card.registration"), buildForm()));
//<theme-editor-local-classname>
addClassName("form-view-vertical-layout-1");
}
@Override
public String getPageTitle() {
return getTranslation("page.form");
}
private Component buildForm() {
TextField vorname = new TextField("Vorname");
TextField vorname = new TextField(getTranslation("form.firstName"));
vorname.setAutofocus(true);
TextField nachname = new TextField("Nachname");
EmailField email = new EmailField("E-Mail");
PasswordField passwort = new PasswordField("Passwort");
TextField nachname = new TextField(getTranslation("form.lastName"));
EmailField email = new EmailField(getTranslation("form.email"));
PasswordField passwort = new PasswordField(getTranslation("form.password"));
//<theme-editor-local-classname>
passwort.addClassName("form-view-password-field-1");
IntegerField alter = new IntegerField("Alter");
IntegerField alter = new IntegerField(getTranslation("form.age"));
alter.setReadOnly(true);
BigDecimalField gehalt = new BigDecimalField("Gehalt (€)");
DatePicker geburtsdatum = new DatePicker("Geburtsdatum");
BigDecimalField gehalt = new BigDecimalField(getTranslation("form.salary"));
DatePicker geburtsdatum = new DatePicker(getTranslation("form.birthdate"));
geburtsdatum.setLocale(Locale.GERMANY);
DateFormatSymbols symbols = new DateFormatSymbols(Locale.GERMANY);
@@ -69,44 +73,48 @@ vorname.setAutofocus(true);
.setWeekdays(wochentage)
.setWeekdaysShort(wochentageKurz)
.setFirstDayOfWeek(1)
.setToday("Heute")
.setCancel("Abbrechen"));
TimePicker uhrzeit = new TimePicker("Bevorzugte Uhrzeit");
.setToday(getTranslation("form.today"))
.setCancel(getTranslation("form.cancel")));
TimePicker uhrzeit = new TimePicker(getTranslation("form.time"));
ComboBox<String> land = new ComboBox<>("Land");
land.setItems("Deutschland", "Österreich", "Schweiz", "Frankreich", "Niederlande");
ComboBox<String> land = new ComboBox<>(getTranslation("form.country"));
land.setItems(getTranslation("country.de"), getTranslation("country.at"),
getTranslation("country.ch"), getTranslation("country.fr"), getTranslation("country.nl"));
Select<String> abteilung = new Select<>();
abteilung.setLabel("Abteilung");
abteilung.setItems("Vertrieb", "Marketing", "Entwicklung", "Support", "Geschäftsführung");
abteilung.setLabel(getTranslation("form.department"));
abteilung.setItems(getTranslation("dept.sales"), getTranslation("dept.marketing"),
getTranslation("dept.dev"), getTranslation("dept.support"), getTranslation("dept.management"));
CheckboxGroup<String> interessen = new CheckboxGroup<>();
interessen.setLabel("Interessen");
interessen.setItems("Newsletter-Themen", "Webinare", "Produkt-Updates", "Events");
interessen.setLabel(getTranslation("form.interests"));
interessen.setItems(getTranslation("interest.newsletter"), getTranslation("interest.webinars"),
getTranslation("interest.updates"), getTranslation("interest.events"));
RadioButtonGroup<String> geschlecht = new RadioButtonGroup<>();
geschlecht.setLabel("Anrede");
geschlecht.setItems("Frau", "Herr", "Divers", "Keine Angabe");
geschlecht.setLabel(getTranslation("form.salutation"));
geschlecht.setItems(getTranslation("salutation.mrs"), getTranslation("salutation.mr"),
getTranslation("salutation.diverse"), getTranslation("salutation.none"));
Checkbox newsletter = new Checkbox("Newsletter abonnieren");
Checkbox newsletter = new Checkbox(getTranslation("form.newsletter"));
TextArea bemerkung = new TextArea("Bemerkung");
TextArea bemerkung = new TextArea(getTranslation("form.remark"));
bemerkung.setMaxLength(500);
binder.forField(vorname)
.asRequired("Vorname wird benötigt")
.asRequired(getTranslation("valid.firstNameRequired"))
.bind(Registration::getVorname, Registration::setVorname);
binder.forField(nachname)
.asRequired("Nachname wird benötigt")
.asRequired(getTranslation("valid.lastNameRequired"))
.bind(Registration::getNachname, Registration::setNachname);
binder.forField(email)
.asRequired("E-Mail wird benötigt")
.withValidator(new EmailValidator("Ungültige E-Mail-Adresse"))
.asRequired(getTranslation("valid.emailRequired"))
.withValidator(new EmailValidator(getTranslation("valid.emailInvalid")))
.bind(Registration::getEmail, Registration::setEmail);
binder.forField(passwort)
.bind(Registration::getPasswort, Registration::setPasswort);
binder.forField(alter)
.withValidator(new IntegerRangeValidator("Alter muss zwischen 0 und 120 liegen", 0, 120))
.withValidator(new IntegerRangeValidator(getTranslation("valid.ageRange"), 0, 120))
.bind(Registration::getAlter, Registration::setAlter);
binder.forField(gehalt)
.bind(Registration::getGehalt, Registration::setGehalt);
@@ -143,20 +151,20 @@ vorname.setAutofocus(true);
formLayout.setColspan(bemerkung, 2);
formLayout.add(interessen, geschlecht, newsletter, bemerkung);
Button speichern = new Button("Speichern", e -> {
Button speichern = new Button(getTranslation("form.save"), e -> {
Registration ziel = new Registration();
if (binder.writeBeanIfValid(ziel)) {
Notification erfolg = Notification.show(
"Gespeichert: %s %s".formatted(ziel.getVorname(), ziel.getNachname()));
getTranslation("notify.saved", ziel.getVorname(), ziel.getNachname()));
erfolg.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} else {
Notification fehler = Notification.show("Bitte Eingaben prüfen");
Notification fehler = Notification.show(getTranslation("notify.checkInput"));
fehler.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
speichern.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button zuruecksetzen = new Button("Zurücksetzen",
Button zuruecksetzen = new Button(getTranslation("form.reset"),
e -> binder.readBean(new Registration()));
HorizontalLayout buttons = new HorizontalLayout(speichern, zuruecksetzen);
@@ -1,15 +1,21 @@
package com.example.views;
import com.example.js.CommonJS;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.menubar.MenuBar;
import com.vaadin.flow.component.menubar.MenuBarVariant;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout;
import com.vaadin.flow.server.VaadinSession;
import java.util.Locale;
@Layout
public class MainLayout extends AppLayout {
@@ -19,32 +25,53 @@ public class MainLayout extends AppLayout {
public MainLayout() {
DrawerToggle toggle = new DrawerToggle();
Span title = new Span("Chart App");
Span title = new Span(getTranslation("app.title"));
title.addClassName("dialect-title");
Span spacer = new Span();
spacer.getStyle().set("flex-grow", "1");
MenuBar languageMenu = buildLanguageMenu();
themeToggle.addThemeVariants(ButtonVariant.TERTIARY, ButtonVariant.LUMO_ICON);
themeToggle.setAriaLabel("Farbschema wechseln");
themeToggle.setAriaLabel(getTranslation("action.themeToggle"));
themeToggle.addClickListener(e ->
getElement().executeJs(CommonJS.TOGGLE_THEME_JS).then(String.class, this::applyIconFor));
addToNavbar(toggle, title, spacer, themeToggle);
addToNavbar(toggle, title, spacer, languageMenu, themeToggle);
getElement().executeJs(CommonJS.INIT_THEME_JS).then(String.class, this::applyIconFor);
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class,
nav.addItem(new SideNavItem(getTranslation("nav.dashboard"), DashboardView.class,
VaadinIcon.DASHBOARD.create()));
nav.addItem(new SideNavItem("Formular", FormView.class,
nav.addItem(new SideNavItem(getTranslation("nav.form"), FormView.class,
VaadinIcon.FORM.create()));
nav.addItem(new SideNavItem("Tabelle", TableView.class,
nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class,
VaadinIcon.TABLE.create()));
addToDrawer(nav);
}
private MenuBar buildLanguageMenu() {
MenuBar menuBar = new MenuBar();
menuBar.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE);
var root = menuBar.addItem(VaadinIcon.GLOBE_WIRE.create());
root.setAriaLabel(getTranslation("action.language"));
root.getSubMenu().addItem(getTranslation("lang.de"), e -> switchLanguage(Locale.GERMAN));
root.getSubMenu().addItem(getTranslation("lang.en"), e -> switchLanguage(Locale.ENGLISH));
root.getSubMenu().addItem(getTranslation("lang.es"), e -> switchLanguage(Locale.of("es")));
return menuBar;
}
private void switchLanguage(Locale locale) {
VaadinSession.getCurrent().setLocale(locale);
UI.getCurrent().getPage().reload();
}
private void applyIconFor(String theme) {
themeToggle.setIcon("dark".equals(theme) ? VaadinIcon.SUN_O.create() : VaadinIcon.MOON.create());
}
+13 -9
View File
@@ -7,7 +7,7 @@ import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import java.text.NumberFormat;
@@ -19,8 +19,7 @@ import java.util.Locale;
* live text filter across all columns.
*/
@Route("tabelle")
@PageTitle("Tabelle")
public class TableView extends VerticalLayout {
public class TableView extends VerticalLayout implements HasDynamicTitle {
private record Mitarbeiter(String name, String abteilung, String stadt, double gehalt) {
}
@@ -45,7 +44,7 @@ public class TableView extends VerticalLayout {
addClassName("dialect-content");
TextField suche = new TextField();
suche.setPlaceholder("Suchen…");
suche.setPlaceholder(getTranslation("table.search"));
suche.setPrefixComponent(VaadinIcon.SEARCH.create());
suche.setClearButtonVisible(true);
suche.setValueChangeMode(ValueChangeMode.EAGER);
@@ -53,10 +52,10 @@ public class TableView extends VerticalLayout {
Grid<Mitarbeiter> grid = new Grid<>();
grid.setWidthFull();
grid.addColumn(Mitarbeiter::name).setHeader("Name").setSortable(true);
grid.addColumn(Mitarbeiter::abteilung).setHeader("Abteilung").setSortable(true);
grid.addColumn(Mitarbeiter::stadt).setHeader("Stadt").setSortable(true);
grid.addColumn(m -> GEHALT_FORMAT.format(m.gehalt())).setHeader("Gehalt (€)").setSortable(true);
grid.addColumn(Mitarbeiter::name).setHeader(getTranslation("col.name")).setSortable(true);
grid.addColumn(Mitarbeiter::abteilung).setHeader(getTranslation("col.department")).setSortable(true);
grid.addColumn(Mitarbeiter::stadt).setHeader(getTranslation("col.city")).setSortable(true);
grid.addColumn(m -> GEHALT_FORMAT.format(m.gehalt())).setHeader(getTranslation("col.salary")).setSortable(true);
GridListDataView<Mitarbeiter> dataView = grid.setItems(MITARBEITER);
suche.addValueChangeListener(e -> dataView.refreshAll());
@@ -66,7 +65,12 @@ public class TableView extends VerticalLayout {
content.setPadding(false);
content.setSpacing(true);
add(new Card("Mitarbeiter", content));
add(new Card(getTranslation("card.employees"), content));
}
@Override
public String getPageTitle() {
return getTranslation("page.table");
}
private static boolean matches(Mitarbeiter mitarbeiter, String filter) {
@@ -0,0 +1,94 @@
# German (source of truth / DefaultI18NProvider fallback)
app.title=Chart App
action.themeToggle=Farbschema wechseln
action.language=Sprache wechseln
lang.de=Deutsch
lang.en=English
lang.es=Español
nav.dashboard=Dashboard
nav.form=Formular
nav.table=Tabelle
page.dashboard=Dashboard
page.form=Formular
page.table=Tabelle
card.revenueTrend=Umsatz-Entwicklung
card.revenueByMonth=Umsatz nach Monat
card.revenueByRegion=Umsatz nach Region
card.registration=Registrierung
card.employees=Mitarbeiter
chart.revenueSeries=Umsatz 2026
chart.pointClick=Serie {0}, Punkt {1}
chart.sliceClick=Slice {0}
month.jan=Jan
month.feb=Feb
month.mar=Mär
month.apr=Apr
month.may=Mai
month.jun=Jun
region.north=Nord
region.south=Süd
region.east=Ost
region.west=West
form.firstName=Vorname
form.lastName=Nachname
form.email=E-Mail
form.password=Passwort
form.age=Alter
form.salary=Gehalt (€)
form.birthdate=Geburtsdatum
form.time=Bevorzugte Uhrzeit
form.country=Land
form.department=Abteilung
form.interests=Interessen
form.salutation=Anrede
form.newsletter=Newsletter abonnieren
form.remark=Bemerkung
form.save=Speichern
form.reset=Zurücksetzen
form.today=Heute
form.cancel=Abbrechen
country.de=Deutschland
country.at=Österreich
country.ch=Schweiz
country.fr=Frankreich
country.nl=Niederlande
dept.sales=Vertrieb
dept.marketing=Marketing
dept.dev=Entwicklung
dept.support=Support
dept.management=Geschäftsführung
interest.newsletter=Newsletter-Themen
interest.webinars=Webinare
interest.updates=Produkt-Updates
interest.events=Events
salutation.mrs=Frau
salutation.mr=Herr
salutation.diverse=Divers
salutation.none=Keine Angabe
valid.firstNameRequired=Vorname wird benötigt
valid.lastNameRequired=Nachname wird benötigt
valid.emailRequired=E-Mail wird benötigt
valid.emailInvalid=Ungültige E-Mail-Adresse
valid.ageRange=Alter muss zwischen 0 und 120 liegen
notify.saved=Gespeichert: {0} {1}
notify.checkInput=Bitte Eingaben prüfen
table.search=Suchen…
col.name=Name
col.department=Abteilung
col.city=Stadt
col.salary=Gehalt (€)
@@ -0,0 +1,94 @@
# English
app.title=Chart App
action.themeToggle=Switch color scheme
action.language=Switch language
lang.de=Deutsch
lang.en=English
lang.es=Español
nav.dashboard=Dashboard
nav.form=Form
nav.table=Table
page.dashboard=Dashboard
page.form=Form
page.table=Table
card.revenueTrend=Revenue Trend
card.revenueByMonth=Revenue by Month
card.revenueByRegion=Revenue by Region
card.registration=Registration
card.employees=Employees
chart.revenueSeries=Revenue 2026
chart.pointClick=Series {0}, point {1}
chart.sliceClick=Slice {0}
month.jan=Jan
month.feb=Feb
month.mar=Mar
month.apr=Apr
month.may=May
month.jun=Jun
region.north=North
region.south=South
region.east=East
region.west=West
form.firstName=First name
form.lastName=Last name
form.email=Email
form.password=Password
form.age=Age
form.salary=Salary (€)
form.birthdate=Date of birth
form.time=Preferred time
form.country=Country
form.department=Department
form.interests=Interests
form.salutation=Salutation
form.newsletter=Subscribe to newsletter
form.remark=Remark
form.save=Save
form.reset=Reset
form.today=Today
form.cancel=Cancel
country.de=Germany
country.at=Austria
country.ch=Switzerland
country.fr=France
country.nl=Netherlands
dept.sales=Sales
dept.marketing=Marketing
dept.dev=Development
dept.support=Support
dept.management=Management
interest.newsletter=Newsletter topics
interest.webinars=Webinars
interest.updates=Product updates
interest.events=Events
salutation.mrs=Ms.
salutation.mr=Mr.
salutation.diverse=Diverse
salutation.none=Not specified
valid.firstNameRequired=First name is required
valid.lastNameRequired=Last name is required
valid.emailRequired=Email is required
valid.emailInvalid=Invalid email address
valid.ageRange=Age must be between 0 and 120
notify.saved=Saved: {0} {1}
notify.checkInput=Please check your entries
table.search=Search…
col.name=Name
col.department=Department
col.city=City
col.salary=Salary (€)
@@ -0,0 +1,94 @@
# Spanish
app.title=Chart App
action.themeToggle=Cambiar esquema de color
action.language=Cambiar idioma
lang.de=Deutsch
lang.en=English
lang.es=Español
nav.dashboard=Panel
nav.form=Formulario
nav.table=Tabla
page.dashboard=Panel
page.form=Formulario
page.table=Tabla
card.revenueTrend=Evolución de ingresos
card.revenueByMonth=Ingresos por mes
card.revenueByRegion=Ingresos por región
card.registration=Registro
card.employees=Empleados
chart.revenueSeries=Ingresos 2026
chart.pointClick=Serie {0}, punto {1}
chart.sliceClick=Sector {0}
month.jan=Ene
month.feb=Feb
month.mar=Mar
month.apr=Abr
month.may=May
month.jun=Jun
region.north=Norte
region.south=Sur
region.east=Este
region.west=Oeste
form.firstName=Nombre
form.lastName=Apellido
form.email=Correo electrónico
form.password=Contraseña
form.age=Edad
form.salary=Salario (€)
form.birthdate=Fecha de nacimiento
form.time=Hora preferida
form.country=País
form.department=Departamento
form.interests=Intereses
form.salutation=Tratamiento
form.newsletter=Suscribirse al boletín
form.remark=Observación
form.save=Guardar
form.reset=Restablecer
form.today=Hoy
form.cancel=Cancelar
country.de=Alemania
country.at=Austria
country.ch=Suiza
country.fr=Francia
country.nl=Países Bajos
dept.sales=Ventas
dept.marketing=Marketing
dept.dev=Desarrollo
dept.support=Soporte
dept.management=Dirección
interest.newsletter=Temas del boletín
interest.webinars=Seminarios web
interest.updates=Actualizaciones de producto
interest.events=Eventos
salutation.mrs=Sra.
salutation.mr=Sr.
salutation.diverse=Diverso
salutation.none=Sin especificar
valid.firstNameRequired=El nombre es obligatorio
valid.lastNameRequired=El apellido es obligatorio
valid.emailRequired=El correo electrónico es obligatorio
valid.emailInvalid=Dirección de correo no válida
valid.ageRange=La edad debe estar entre 0 y 120
notify.saved=Guardado: {0} {1}
notify.checkInput=Por favor revise los datos ingresados
table.search=Buscar…
col.name=Nombre
col.department=Departamento
col.city=Ciudad
col.salary=Salario (€)