feat: timer intervallo misura su ricetta + auto-logout per inattività

- Recipe: nuovo campo measurement_interval_minutes (migration 003) con UI nel recipe editor
- Auto-logout: impostazione di sistema configurabile da admin/settings, timer inattività JS
  con warning modale 60s prima del logout, tracking eventi utente throttled
- Navbar: aggiunto link "Impostazioni" per admin (desktop + mobile)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-23 14:36:02 +00:00
parent b9c767fb0c
commit 0748ce9b1e
13 changed files with 387 additions and 4 deletions
@@ -0,0 +1,24 @@
"""add measurement_interval_minutes to recipes
Revision ID: 003_measurement_interval
Revises: 002_add_stations
Create Date: 2026-05-23
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '003_measurement_interval'
down_revision: Union[str, None] = '002_add_stations'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('recipes', sa.Column('measurement_interval_minutes', sa.SmallInteger(), nullable=True))
def downgrade() -> None:
op.drop_column('recipes', 'measurement_interval_minutes')
+3
View File
@@ -14,6 +14,7 @@ class RecipeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500) image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
# Optional task-level fields for the initial technical drawing # Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
@@ -25,6 +26,7 @@ class RecipeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500) image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
change_notes: Optional[str] = None change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version # Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
@@ -55,6 +57,7 @@ class RecipeResponse(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = None image_path: Optional[str] = None
measurement_interval_minutes: Optional[int] = None
created_by: int created_by: int
created_at: datetime created_at: datetime
active: bool active: bool
+2 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.backend.database import Base from src.backend.database import Base
@@ -24,6 +24,7 @@ class Recipe(Base):
DateTime, nullable=False, server_default=func.now() DateTime, nullable=False, server_default=func.now()
) )
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
# Relationships # Relationships
versions: Mapped[list["RecipeVersion"]] = relationship( versions: Mapped[list["RecipeVersion"]] = relationship(
+5
View File
@@ -134,6 +134,7 @@ async def create_recipe(
name=data.name, name=data.name,
description=data.description, description=data.description,
image_path=data.image_path, image_path=data.image_path,
measurement_interval_minutes=data.measurement_interval_minutes,
created_by=user.id, created_by=user.id,
) )
db.add(recipe) db.add(recipe)
@@ -273,6 +274,8 @@ async def create_new_version(
update_fields["description"] = data.description update_fields["description"] = data.description
if data.image_path is not None: if data.image_path is not None:
update_fields["image_path"] = data.image_path update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
if update_fields: if update_fields:
await db.execute( await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
@@ -369,6 +372,8 @@ async def update_current_version(
update_fields["description"] = data.description update_fields["description"] = data.description
if data.image_path is not None: if data.image_path is not None:
update_fields["image_path"] = data.image_path update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
if update_fields: if update_fields:
await db.execute( await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
+1
View File
@@ -92,6 +92,7 @@ def create_app() -> Flask:
"current_language": get_locale(), "current_language": get_locale(),
"languages": Config.LANGUAGES, "languages": Config.LANGUAGES,
"company_logo": session.get("company_logo"), "company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
} }
return app return app
@@ -43,6 +43,28 @@ def user_list():
return render_template("admin/users.html", users=users) return render_template("admin/users.html", users=users)
@admin_bp.route("/settings")
@login_required
@admin_required
def settings_page():
"""System settings page."""
resp = api_client.get("/api/settings")
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
return render_template("admin/settings.html", settings=settings)
@admin_bp.route("/api/settings", methods=["PUT"])
@login_required
@admin_required
def api_update_settings():
"""Proxy: Update system settings."""
data = request.get_json(silent=True) or {}
resp = api_client.put("/api/settings", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/stations") @admin_bp.route("/stations")
@login_required @login_required
@admin_required @admin_required
+7 -1
View File
@@ -76,12 +76,18 @@ def login():
if user.get("theme_pref"): if user.get("theme_pref"):
session["theme"] = user["theme_pref"] session["theme"] = user["theme_pref"]
# Carica logo aziendale dalle impostazioni di sistema # Carica impostazioni di sistema
settings_resp = api_client.get("/api/settings") settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"): if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path") logo_path = settings_resp.get("company_logo_path")
if logo_path: if logo_path:
session["company_logo"] = logo_path session["company_logo"] = logo_path
auto_logout = settings_resp.get("auto_logout_minutes")
if auto_logout:
try:
session["auto_logout_minutes"] = int(auto_logout)
except (ValueError, TypeError):
pass
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success") flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="systemSettings()">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
</div>
<!-- Settings Card -->
<div class="tmf-card">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Sessione e Sicurezza') }}
</div>
<div class="tmf-card-body space-y-5">
<!-- Auto-logout -->
<div>
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
<div class="flex items-center gap-3">
<input id="auto-logout"
type="number"
x-model.number="autoLogoutMinutes"
class="tmf-input w-32"
min="1"
max="480"
placeholder="—">
<button @click="autoLogoutMinutes = null"
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
x-show="autoLogoutMinutes">
{{ _('Disabilita') }}
</button>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
</p>
</div>
</div>
<div class="tmf-card-footer flex items-center justify-between">
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Salvato') }}
</span>
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
<div class="flex-1"></div>
<button @click="save()"
:disabled="saving"
class="btn btn-primary gap-1.5">
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Salva impostazioni') }}
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function systemSettings() {
return {
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
saving: false,
saved: false,
errorMessage: '',
async save() {
this.saving = true;
this.saved = false;
this.errorMessage = '';
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
var payload = {
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
};
try {
var resp = await fetch('/admin/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify(payload)
});
var data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || data.error;
} else {
this.saved = true;
setTimeout(() => { this.saved = false; }, 3000);
}
} catch (err) {
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
}
this.saving = false;
}
};
}
</script>
{% endblock %}
@@ -115,6 +115,74 @@
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
{% if current_user and auto_logout_minutes %}
<script>
function inactivityTimer() {
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
var warningMs = 60 * 1000;
return {
lastActivity: Date.now(),
showWarning: false,
remainingSeconds: 60,
_interval: null,
init() {
var self = this;
this._interval = setInterval(function() { self._check(); }, 5000);
var lastUpdate = 0;
var handler = function() {
var now = Date.now();
if (now - lastUpdate > 3000) {
lastUpdate = now;
self.lastActivity = now;
self.showWarning = false;
}
};
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
window.addEventListener(evt, handler, { passive: true });
});
},
resetTimer() {
this.lastActivity = Date.now();
this.showWarning = false;
},
_check() {
var elapsed = Date.now() - this.lastActivity;
var remaining = timeoutMs - elapsed;
if (remaining <= 0) {
clearInterval(this._interval);
window.location.href = '{{ url_for("auth.logout") }}';
} else if (remaining <= warningMs) {
this.showWarning = true;
this.remainingSeconds = Math.ceil(remaining / 1000);
}
}
};
}
</script>
<div x-data="inactivityTimer()" class="contents">
<template x-if="showWarning">
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-5">
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
</p>
<button @click="resetTimer()" class="btn btn-primary w-full">
{{ _('Continua a lavorare') }}
</button>
</div>
</div>
</template>
</div>
{% endif %}
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined --> <!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body> </body>
@@ -102,6 +102,20 @@
</svg> </svg>
<span>{{ _('Stazioni') }}</span> <span>{{ _('Stazioni') }}</span>
</a> </a>
<a href="{{ url_for('admin.settings_page') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.settings_page' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>{{ _('Impostazioni') }}</span>
</a>
{% endif %} {% endif %}
</div> </div>
@@ -299,6 +313,17 @@
</svg> </svg>
{{ _('Stazioni') }} {{ _('Stazioni') }}
</a> </a>
<a href="{{ url_for('admin.settings_page') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ _('Impostazioni') }}
</a>
{% endif %} {% endif %}
</div> </div>
@@ -231,6 +231,23 @@
rows="3" rows="3"
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea> placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
</div> </div>
<!-- Intervallo di misura -->
<div>
<label class="tmf-label" for="recipe-interval">
{{ _('Intervallo misura (minuti)') }}
</label>
<input id="recipe-interval"
type="number"
x-model.number="measurementIntervalMinutes"
class="tmf-input"
min="1"
max="1440"
placeholder="{{ _('Es. 30') }}">
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -466,6 +483,9 @@ function recipeEditor() {
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }}, versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }}, currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ----
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
// ---- File upload (preview image) ---- // ---- File upload (preview image) ----
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }}, currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
uploadingFile: false, uploadingFile: false,
@@ -487,6 +507,11 @@ function recipeEditor() {
description: this.description.trim(), description: this.description.trim(),
}; };
// Measurement interval
if (this.measurementIntervalMinutes) {
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
}
// Include image_path for preview thumbnail // Include image_path for preview thumbnail
if (this.currentFilePath) { if (this.currentFilePath) {
payload.image_path = this.currentFilePath; payload.image_path = this.currentFilePath;
@@ -672,6 +672,51 @@ msgstr "Users"
msgid "Stazioni" msgid "Stazioni"
msgstr "Stations" msgstr "Stations"
msgid "Impostazioni"
msgstr "Settings"
msgid "Impostazioni di Sistema"
msgstr "System Settings"
msgid "Configura i parametri generali del sistema"
msgstr "Configure general system parameters"
msgid "Sessione e Sicurezza"
msgstr "Session & Security"
msgid "Auto-logout per inattività (minuti)"
msgstr "Auto-logout for inactivity (minutes)"
msgid "Disabilita"
msgstr "Disable"
msgid "L'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare."
msgstr "The operator will be automatically logged out after the specified inactivity period. Leave empty to disable."
msgid "Salva impostazioni"
msgstr "Save settings"
msgid "Salvato"
msgstr "Saved"
msgid "Sessione in scadenza"
msgstr "Session expiring"
msgid "Sarai disconnesso tra"
msgstr "You will be logged out in"
msgid "secondi per inattività."
msgstr "seconds due to inactivity."
msgid "Continua a lavorare"
msgstr "Continue working"
msgid "Intervallo misura (minuti)"
msgstr "Measurement interval (minutes)"
msgid "Timer cicalino per ricordare la misurazione periodica"
msgstr "Buzzer timer to remind periodic measurement"
#: templates/components/next_measurement.html:19 #: templates/components/next_measurement.html:19
msgid "Prossima misura" msgid "Prossima misura"
msgstr "Next measurement" msgstr "Next measurement"
@@ -699,9 +699,53 @@ msgid "Utenti"
msgstr "Utenti" msgstr "Utenti"
#: templates/components/navbar.html:103 templates/components/navbar.html:300 #: templates/components/navbar.html:103 templates/components/navbar.html:300
#, fuzzy
msgid "Stazioni" msgid "Stazioni"
msgstr "Azioni" msgstr "Stazioni"
msgid "Impostazioni"
msgstr "Impostazioni"
msgid "Impostazioni di Sistema"
msgstr "Impostazioni di Sistema"
msgid "Configura i parametri generali del sistema"
msgstr "Configura i parametri generali del sistema"
msgid "Sessione e Sicurezza"
msgstr "Sessione e Sicurezza"
msgid "Auto-logout per inattività (minuti)"
msgstr "Auto-logout per inattività (minuti)"
msgid "Disabilita"
msgstr "Disabilita"
msgid "L'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare."
msgstr "L'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare."
msgid "Salva impostazioni"
msgstr "Salva impostazioni"
msgid "Salvato"
msgstr "Salvato"
msgid "Sessione in scadenza"
msgstr "Sessione in scadenza"
msgid "Sarai disconnesso tra"
msgstr "Sarai disconnesso tra"
msgid "secondi per inattività."
msgstr "secondi per inattività."
msgid "Continua a lavorare"
msgstr "Continua a lavorare"
msgid "Intervallo misura (minuti)"
msgstr "Intervallo misura (minuti)"
msgid "Timer cicalino per ricordare la misurazione periodica"
msgstr "Timer cicalino per ricordare la misurazione periodica"
#: templates/components/next_measurement.html:19 #: templates/components/next_measurement.html:19
msgid "Prossima misura" msgid "Prossima misura"