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:
@@ -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')
|
||||||
@@ -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,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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user