chore(v2): restructure monorepo to src/ layout with uv

Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tablet Touch Targets (44px minimum) */
@layer components {
.touch-target {
min-height: 44px;
min-width: 44px;
}
}
@media (pointer: coarse) {
button,
.btn,
[role="button"],
a.inline-flex {
min-height: 44px;
}
/* Flash message dismiss button */
.fixed button[x-data] {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -0,0 +1,55 @@
/**
* Numpad Component Styles
* Additional styles not covered by TailwindCSS utility classes
*/
/* Touch optimization - prevent text selection and improve tap responsiveness */
.numpad-container button {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Ensure touch-manipulation class works */
.touch-manipulation {
touch-action: manipulation;
}
/* Prevent tap highlight on mobile devices */
.numpad-container button:active {
-webkit-tap-highlight-color: transparent;
}
/* Improve button focus for accessibility */
.numpad-container button:focus-visible {
outline: 2px solid var(--color-primary, #0D47A1);
outline-offset: 2px;
}
/* Ensure consistent sizing on all devices */
.numpad-container {
-webkit-tap-highlight-color: transparent;
}
/* Value display - ensure proper spacing and alignment */
.value-display {
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
/* Prevent zoom on double-tap for iOS Safari */
@media (max-width: 768px) {
.numpad-container button {
touch-action: manipulation;
}
}
/* Dark mode adjustments for better contrast */
.dark .value-display {
background-color: var(--bg-card, rgb(51, 65, 85));
border-color: var(--border-color, rgb(71, 85, 105));
}
@@ -0,0 +1,422 @@
/* ============================================================
TieMeasureFlow - Theme System
CSS Custom Properties for Light / Dark mode
============================================================ */
/* ---- Light Theme (Default) ---- */
:root {
--color-primary: #2563EB;
--color-primary-dark: #1E40AF;
--color-primary-light: #3B82F6;
--color-secondary: #64748B;
--color-accent: #1E40AF;
--color-pass: #059669;
--color-warning: #D97706;
--color-fail: #DC2626;
--bg-primary: #F8FAFC;
--bg-secondary: #F1F5F9;
--bg-card: #FFFFFF;
--bg-card-hover: #F8FAFC;
--text-primary: #0F172A;
--text-secondary: #475569;
--text-muted: #94A3B8;
--border-color: #E2E8F0;
--border-focus: #2563EB;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--scrollbar-track: #F1F5F9;
--scrollbar-thumb: #CBD5E1;
--scrollbar-thumb-hover: #94A3B8;
}
/* ---- Dark Theme ---- */
.dark {
--color-primary: #60A5FA;
--color-primary-dark: #3B82F6;
--color-primary-light: #93C5FD;
--color-accent: #93C5FD;
--color-pass: #34D399;
--color-warning: #FBBF24;
--color-fail: #F87171;
--bg-primary: #0F172A;
--bg-secondary: #1E293B;
--bg-card: #334155;
--bg-card-hover: #3B4A63;
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--border-color: #475569;
--border-focus: #60A5FA;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--scrollbar-track: #1E293B;
--scrollbar-thumb: #475569;
--scrollbar-thumb-hover: #64748B;
}
/* ============================================================
Global Transitions
============================================================ */
*,
*::before,
*::after {
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 0s;
}
/* Enable smooth transitions only after page load (avoid flash) */
html.theme-transitions *,
html.theme-transitions *::before,
html.theme-transitions *::after {
transition-duration: 0.3s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
Focus Ring
============================================================ */
*:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: 4px;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--border-focus);
}
/* ============================================================
Custom Scrollbar
============================================================ */
/* Webkit (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* ============================================================
Utility: Card
============================================================ */
.tmf-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.tmf-card:hover {
box-shadow: var(--shadow-md);
}
.tmf-card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.tmf-card-body {
padding: 1.25rem;
}
.tmf-card-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 0 0 0.75rem 0.75rem;
}
/* ============================================================
Utility: Badges (Pass / Warning / Fail)
============================================================ */
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.badge-pass {
background: rgba(5, 150, 105, 0.1);
color: #059669;
border: 1px solid rgba(5, 150, 105, 0.25);
}
.dark .badge-pass {
background: rgba(5, 150, 105, 0.15);
color: #34D399;
border-color: rgba(5, 150, 105, 0.3);
}
.badge-warning {
background: rgba(217, 119, 6, 0.1);
color: #D97706;
border: 1px solid rgba(217, 119, 6, 0.25);
}
.dark .badge-warning {
background: rgba(217, 119, 6, 0.15);
color: #FBBF24;
border-color: rgba(217, 119, 6, 0.3);
}
.badge-fail {
background: rgba(220, 38, 38, 0.1);
color: #DC2626;
border: 1px solid rgba(220, 38, 38, 0.25);
}
.dark .badge-fail {
background: rgba(220, 38, 38, 0.15);
color: #F87171;
border-color: rgba(220, 38, 38, 0.3);
}
.badge-neutral {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
/* ============================================================
Utility: Buttons
============================================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: #FFFFFF;
border-color: var(--color-primary);
}
.dark .btn-primary {
background: #3B82F6;
color: #FFFFFF;
border-color: #3B82F6;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
.dark .btn-primary:hover:not(:disabled) {
background: #2563EB;
border-color: #2563EB;
}
.btn-secondary {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.btn-danger {
background: var(--color-fail);
color: #FFFFFF;
border-color: var(--color-fail);
}
.btn-danger:hover:not(:disabled) {
background: #B91C1C;
border-color: #B91C1C;
}
/* ============================================================
Utility: Form Inputs
============================================================ */
.tmf-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.tmf-input::placeholder {
color: var(--text-muted);
}
.tmf-input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.dark .tmf-input:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.tmf-label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
/* ============================================================
Utility: Measurement Value Display
============================================================ */
.measure-value {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-weight: 500;
letter-spacing: -0.025em;
font-variant-numeric: tabular-nums;
}
/* ============================================================
Utility: Table
============================================================ */
.tmf-table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
}
.tmf-table th {
padding: 0.625rem 1rem;
text-align: left;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tmf-table td {
padding: 0.625rem 1rem;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.tmf-table tbody tr:hover {
background: var(--bg-card-hover);
}
/* ============================================================
Alpine.js x-cloak (hide until Alpine loads)
============================================================ */
[x-cloak] {
display: none !important;
}
/* ============================================================
Tablet Touch Targets (44px minimum per WCAG 2.5.5)
============================================================ */
.touch-target {
min-height: 44px;
min-width: 44px;
}
@media (pointer: coarse) {
button,
.btn,
[role="button"],
a.inline-flex {
min-height: 44px;
}
/* Flash message dismiss button */
.fixed button[x-data] {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Stylized Caliper Icon for Favicon -->
<!-- Caliper outer frame -->
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
<!-- Sliding jaw -->
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<!-- Depth rod -->
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
<!-- Scale markings -->
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<!-- Flow arrow -->
<path d="M22 16 C25 16, 26.5 11, 29 11"
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M27 8.5 L29.5 11 L27 13.5"
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
<!-- Stylized Caliper Icon -->
<g transform="translate(2, 2)">
<!-- Caliper outer frame -->
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
<!-- Caliper sliding jaw -->
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
<!-- Caliper depth rod -->
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
<!-- Scale markings -->
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<!-- Flow arrow (smooth curve) -->
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</g>
<!-- Text: TieMeasureFlow -->
<g transform="translate(48, 0)">
<!-- "Tie" in bold primary -->
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Tie</text>
<!-- "Measure" in medium dark -->
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
letter-spacing="-0.5">Measure</text>
<!-- "Flow" in bold primary -->
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Flow</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,126 @@
/**
* TieMeasureFlow - Alpine.js Theme Initialization
*
* This script MUST load before Alpine.js (which uses defer).
* It reads the saved theme from localStorage or the system preference,
* applies the 'dark' class to <html> immediately (no flash), and
* registers an Alpine.store('theme') for reactive toggling.
*/
(function () {
'use strict';
// ---- Immediate theme application (before paint) ----
var STORAGE_KEY = 'tmf-theme';
/**
* Resolve the initial dark-mode state.
* Priority: localStorage > system preference > light (default).
*/
function getInitialDark() {
var stored = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch (_) {
// localStorage may be unavailable (private browsing, etc.)
}
if (stored === 'dark') return true;
if (stored === 'light') return false;
// Fall back to OS preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true;
}
return false;
}
var isDark = getInitialDark();
// Apply immediately to prevent flash of wrong theme
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// ---- Enable smooth transitions after first paint ----
// Use requestAnimationFrame to wait one frame, then enable transitions
// so the initial theme application does not animate.
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
document.documentElement.classList.add('theme-transitions');
});
});
}
// ---- Alpine.js Store Registration ----
// Alpine.store is registered via the alpine:init event, which fires
// when Alpine begins initialization but before it processes x-data.
document.addEventListener('alpine:init', function () {
Alpine.store('theme', {
dark: isDark,
toggle: function () {
this.dark = !this.dark;
this._apply();
},
set: function (dark) {
this.dark = dark;
this._apply();
},
_apply: function () {
if (this.dark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
try {
localStorage.setItem(STORAGE_KEY, this.dark ? 'dark' : 'light');
} catch (_) {
// Silently ignore storage errors
}
}
});
});
// ---- Listen for system preference changes ----
if (window.matchMedia) {
try {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', function (e) {
// Only auto-switch if the user hasn't manually set a preference
var stored = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch (_) {}
if (!stored) {
var newDark = e.matches;
if (typeof Alpine !== 'undefined' && Alpine.store('theme')) {
Alpine.store('theme').set(newDark);
} else {
// Alpine not loaded yet, apply directly
if (newDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}
});
} catch (_) {
// Older browsers may not support addEventListener on matchMedia
}
}
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,530 @@
/**
* Annotation Viewer - Display-only viewer for image annotations
*
* Alpine.js component for viewing measurement markers on technical drawings.
* Supports point markers and rectangle markers with highlighting.
*
* Annotation JSON Format:
* {
* "markers": [
* {
* "id": 1,
* "marker_number": 1,
* "subtask_id": 42,
* "x": 150,
* "y": 200,
* "type": "point",
* "label": "D1"
* },
* {
* "id": 2,
* "marker_number": 2,
* "subtask_id": 43,
* "x": 300,
* "y": 150,
* "width": 100,
* "height": 50,
* "type": "rectangle",
* "label": "L1"
* }
* ],
* "imageWidth": 800,
* "imageHeight": 600
* }
*
* Usage:
* <div x-data="annotationViewer()"
* x-init="imageUrl = '/path/to/image.jpg'; annotations = {...}; init()">
* <canvas x-ref="annotationCanvas" @click="handleClick($event)"></canvas>
* </div>
*/
function annotationViewer() {
return {
// Canvas elements
canvas: null,
ctx: null,
// Data
annotations: null, // { markers: [...], imageWidth, imageHeight }
imageUrl: null, // URL dell'immagine da visualizzare
activeMarker: null, // marker_number del marker correntemente selezionato
// State
imageLoaded: false,
scale: 1, // scala di ridimensionamento immagine
/**
* Inizializzazione component
*/
init() {
this.canvas = this.$refs.annotationCanvas;
if (!this.canvas) {
console.error('AnnotationViewer: canvas ref not found');
return;
}
this.ctx = this.canvas.getContext('2d');
// Load image if URL is provided
if (this.imageUrl) {
this.loadImage();
}
// Responsive resize
window.addEventListener('resize', () => {
if (this.imageLoaded) {
this.loadImage();
}
});
},
/**
* Carica e renderizza l'immagine (o la prima pagina PDF) con scaling
*/
loadImage() {
var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
this._loadPdf();
} else {
this._loadRasterImage();
}
},
/**
* Render first page of a PDF via PDF.js
*/
_loadPdf() {
const self = this;
pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
const pdfScale = 2; // render at 2x for clarity
const viewport = page.getViewport({ scale: pdfScale });
const offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
const offCtx = offCanvas.getContext('2d');
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
// Use rendered PDF page as if it were an image
self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height);
});
}).catch(function (err) {
console.error('AnnotationViewer: failed to load PDF:', err);
});
},
/**
* Load a raster image (PNG, JPG, etc.)
*/
_loadRasterImage() {
const self = this;
const img = new Image();
img.onload = function () {
self._drawImageOnCanvas(img, img.width, img.height);
};
img.onerror = function () {
console.error('AnnotationViewer: failed to load image:', self.imageUrl);
};
img.src = this.imageUrl;
},
/**
* Draw an image source (Image or Canvas) scaled to fit the container
*/
_drawImageOnCanvas(source, srcWidth, srcHeight) {
const container = this.canvas.parentElement;
if (!container) {
console.error('AnnotationViewer: parent container not found');
return;
}
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight || 500;
this.scale = Math.min(
containerWidth / srcWidth,
containerHeight / srcHeight,
1
);
this.canvas.width = srcWidth * this.scale;
this.canvas.height = srcHeight * this.scale;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = true;
this.drawAnnotations();
},
/**
* Disegna tutti i markers sulle annotazioni
*/
drawAnnotations() {
if (!this.annotations) return;
// Editor format (objects array from annotation-editor.js)
if (this.annotations.objects) {
var previewScale = this.scale;
var imgScale = this.annotations.imageScale;
// v2+: use imageScale for correct canvas→image→preview mapping
// v1 fallback: annoScale = previewWidth / editorWidth
var annoScale = imgScale
? (previewScale / imgScale)
: (this.canvas.width / (this.annotations.width || this.canvas.width));
_drawEditorAnnotations(this.ctx, this.annotations, annoScale);
return;
}
// Legacy format (markers array)
if (!this.annotations.markers) return;
for (const marker of this.annotations.markers) {
const isActive = this.activeMarker === marker.marker_number;
this.drawMarker(marker, isActive);
}
},
/**
* Disegna un singolo marker
* @param {Object} marker - marker data
* @param {boolean} isActive - se il marker è attualmente selezionato
*/
drawMarker(marker, isActive) {
const x = marker.x * this.scale;
const y = marker.y * this.scale;
const color = isActive ? '#2563EB' : '#64748B'; // primary : steel
const radius = isActive ? 16 : 12;
if (marker.type === 'point') {
// Cerchio numerato
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.globalAlpha = isActive ? 0.9 : 0.7;
this.ctx.fill();
this.ctx.globalAlpha = 1;
// Bordo bianco
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 2;
this.ctx.stroke();
// Numero
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = `bold ${isActive ? 14 : 11}px Inter, sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(marker.marker_number.toString(), x, y);
} else if (marker.type === 'rectangle') {
const w = (marker.width || 50) * this.scale;
const h = (marker.height || 30) * this.scale;
// Rettangolo
this.ctx.strokeStyle = color;
this.ctx.lineWidth = isActive ? 3 : 2;
this.ctx.setLineDash(isActive ? [] : [5, 3]);
this.ctx.strokeRect(x, y, w, h);
this.ctx.setLineDash([]);
// Label badge
const badgeWidth = 30;
const badgeHeight = 18;
this.ctx.fillStyle = color;
this.ctx.globalAlpha = 0.8;
this.ctx.fillRect(x, y - 20, badgeWidth, badgeHeight);
this.ctx.globalAlpha = 1;
// Numero nella badge
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = 'bold 11px Inter, sans-serif';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(marker.marker_number.toString(), x + badgeWidth / 2, y - 11);
}
},
/**
* Evidenzia un marker specifico
* @param {number} markerNumber - numero del marker da evidenziare
*/
setActiveMarker(markerNumber) {
this.activeMarker = markerNumber;
if (this.imageLoaded) {
// Ridisegna tutto
this.loadImage();
}
},
/**
* Gestisce click sul canvas per selezionare markers
* @param {MouseEvent} e - evento click
*/
handleClick(e) {
if (!this.annotations || !this.annotations.markers) return;
const rect = this.canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Check click su ogni marker (reverse order per priorità visuale)
for (let i = this.annotations.markers.length - 1; i >= 0; i--) {
const marker = this.annotations.markers[i];
const mx = marker.x * this.scale;
const my = marker.y * this.scale;
let hit = false;
if (marker.type === 'point') {
const dist = Math.sqrt((clickX - mx) ** 2 + (clickY - my) ** 2);
hit = dist < 20;
} else if (marker.type === 'rectangle') {
const w = (marker.width || 50) * this.scale;
const h = (marker.height || 30) * this.scale;
hit = clickX >= mx && clickX <= mx + w &&
clickY >= my && clickY <= my + h;
}
if (hit) {
// Dispatch event per parent component
this.$dispatch('marker-click', {
marker_number: marker.marker_number,
subtask_id: marker.subtask_id,
marker: marker
});
// Auto-evidenzia
this.setActiveMarker(marker.marker_number);
return;
}
}
},
/**
* Pulisce il canvas
*/
clear() {
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = false;
}
},
/**
* Aggiorna le annotazioni e ridisegna
* @param {Object} newAnnotations - nuovo oggetto annotations
*/
updateAnnotations(newAnnotations) {
this.annotations = newAnnotations;
if (this.imageLoaded) {
this.loadImage();
}
}
};
}
/**
* Standalone function to render an image with annotations overlay.
* For use in read-only previews (e.g. task cards) without Alpine.js.
*
* @param {HTMLCanvasElement} canvas - the canvas element to draw on
* @param {string} imageUrl - URL of the image to load
* @param {Object|string|null} annotationsJson - annotations data from the editor
* @param {Object} [options] - optional settings
* @param {number} [options.maxHeight=192] - max canvas height in pixels
*/
function renderAnnotationPreview(canvas, imageUrl, annotationsJson, options) {
if (!canvas || !imageUrl) return;
var opts = options || {};
var maxHeight = opts.maxHeight || 192;
var ctx = canvas.getContext('2d');
var annotations = null;
if (annotationsJson) {
try {
annotations = (typeof annotationsJson === 'string')
? JSON.parse(annotationsJson)
: annotationsJson;
} catch (e) {
console.error('renderAnnotationPreview: invalid annotations JSON');
}
}
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
pdfjsLib.getDocument(imageUrl).promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
var viewport = page.getViewport({ scale: 2 });
var offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
var offCtx = offCanvas.getContext('2d');
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
_drawPreview(canvas, ctx, offCanvas, viewport.width, viewport.height, annotations, maxHeight);
});
}).catch(function (err) {
console.error('renderAnnotationPreview: PDF load failed:', err);
});
} else {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
_drawPreview(canvas, ctx, img, img.width, img.height, annotations, maxHeight);
};
img.onerror = function () {
console.error('renderAnnotationPreview: image load failed:', imageUrl);
};
img.src = imageUrl;
}
}
/**
* Internal: draw image + annotations on canvas, scaled to fit.
*/
function _drawPreview(canvas, ctx, source, srcW, srcH, annotations, maxHeight) {
var container = canvas.parentElement;
var containerWidth = container ? container.clientWidth : 400;
var scale = Math.min(containerWidth / srcW, maxHeight / srcH, 1);
canvas.width = Math.round(srcW * scale);
canvas.height = Math.round(srcH * scale);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
if (annotations && annotations.objects) {
var imgScale = annotations.imageScale;
// v2+: use imageScale for correct canvas→image→preview mapping
// v1 fallback: annoScale = previewWidth / editorWidth
var annoScale = imgScale
? (scale / imgScale)
: (canvas.width / (annotations.width || srcW));
_drawEditorAnnotations(ctx, annotations, annoScale);
} else if (annotations && annotations.markers) {
_drawLegacyAnnotations(ctx, annotations, scale);
}
}
/**
* Draw annotations in the editor format (objects: marker/arrow/area).
*/
function _drawEditorAnnotations(ctx, data, scale) {
var objects = data.objects || [];
// Calculate annotation scale: annotations were placed on a canvas of
// data.width x data.height, but we're drawing on a scaled canvas.
// The image was scaled to fit data.width x data.height in the editor,
// and now we scale again for preview. Use overall scale factor.
var annoScale = scale;
for (var i = 0; i < objects.length; i++) {
var obj = objects[i];
if (obj.type === 'marker') {
_drawPreviewMarker(ctx, obj, annoScale);
} else if (obj.type === 'arrow') {
_drawPreviewArrow(ctx, obj, annoScale);
} else if (obj.type === 'area') {
_drawPreviewArea(ctx, obj, annoScale);
}
}
}
function _drawPreviewMarker(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var color = obj.fill || '#2563EB';
var radius = 14 * Math.min(scale, 1);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
if (obj.markerNumber) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold ' + Math.max(10, Math.round(12 * Math.min(scale, 1))) + 'px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(obj.markerNumber.toString(), x, y);
}
}
function _drawPreviewArrow(ctx, obj, scale) {
var x1 = (obj.x1 != null ? obj.x1 : obj.left) * scale;
var y1 = (obj.y1 != null ? obj.y1 : obj.top) * scale;
var x2 = (obj.x2 != null ? obj.x2 : (obj.left + (obj.width || 100))) * scale;
var y2 = (obj.y2 != null ? obj.y2 : (obj.top + (obj.height || 50))) * scale;
var color = obj.stroke || '#DC2626';
var lineWidth = obj.strokeWidth || 2;
var lineDash = obj.lineDash || [];
ctx.setLineDash(lineDash);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
ctx.setLineDash([]);
// Arrowhead
var angle = Math.atan2(y2 - y1, x2 - x1);
var headLen = 8 + lineWidth * 2;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function _drawPreviewArea(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var w = (obj.width || 150) * (obj.scaleX || 1) * scale;
var h = (obj.height || 100) * (obj.scaleY || 1) * scale;
var color = obj.stroke || '#2563EB';
var lineWidth = obj.strokeWidth || 2;
var lineDash = obj.lineDash || [5, 3];
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.setLineDash(lineDash);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
/**
* Draw annotations in the legacy format (markers: point/rectangle).
*/
function _drawLegacyAnnotations(ctx, data, scale) {
var markers = data.markers || [];
for (var i = 0; i < markers.length; i++) {
var m = markers[i];
if (m.type === 'point') {
_drawPreviewMarker(ctx, { left: m.x, top: m.y, markerNumber: m.marker_number, fill: '#64748B' }, scale);
} else if (m.type === 'rectangle') {
_drawPreviewArea(ctx, { left: m.x, top: m.y, width: m.width, height: m.height, stroke: '#64748B' }, scale);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Barcode Scanner Manager
* Integration with html5-qrcode library
* Supports QR codes, barcodes, and camera scanning
*/
function barcodeScanner() {
const _t = (key) => (window.BARCODE_I18N && window.BARCODE_I18N[key]) || key;
return {
scanning: false,
result: null,
error: null,
scanner: null,
cameraId: null,
async startScan() {
this.scanning = true;
this.result = null;
this.error = null;
// Check library availability
if (!window.Html5Qrcode) {
this.error = _t('scanner_lib_not_loaded');
this.scanning = false;
return;
}
try {
// Get available cameras
const devices = await Html5Qrcode.getCameras();
if (!devices || devices.length === 0) {
this.error = _t('no_camera_available');
this.scanning = false;
return;
}
// Prefer back camera
const backCamera = devices.find(d => d.label.toLowerCase().includes('back'));
this.cameraId = backCamera ? backCamera.id : devices[0].id;
// Initialize scanner
this.scanner = new Html5Qrcode("barcode-reader");
// Start scanning
await this.scanner.start(
this.cameraId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0
},
(decodedText, decodedResult) => {
// Success callback
this.result = decodedText;
this.stopScan();
// Dispatch event for parent components
this.$dispatch('barcode-scanned', {
code: decodedText,
format: decodedResult.result?.format?.formatName || 'unknown',
timestamp: new Date().toISOString()
});
},
(errorMessage) => {
// Error callback - ignore continuous scanning errors
// Only log non-routine errors
if (!errorMessage.includes('NotFoundException')) {
console.debug('Scan frame error:', errorMessage);
}
}
);
} catch (err) {
console.error('Scanner initialization error:', err);
this.error = _t('camera_access_error');
this.scanning = false;
}
},
async stopScan() {
if (this.scanner) {
try {
await this.scanner.stop();
await this.scanner.clear();
} catch (err) {
console.error('Stop scan error:', err);
}
this.scanner = null;
}
this.scanning = false;
},
clear() {
this.result = null;
this.error = null;
},
// Cleanup on component destroy
destroy() {
this.stopScan();
}
};
}
/**
* Barcode Input Helper
* For integrating barcode scanning into input fields
*/
function barcodeInput() {
return {
value: '',
showScanner: false,
openScanner() {
this.showScanner = true;
},
handleScan(event) {
this.value = event.detail.code;
this.showScanner = false;
// Dispatch value change event
this.$dispatch('input', { value: this.value });
this.$dispatch('change', { value: this.value });
},
clear() {
this.value = '';
this.$dispatch('input', { value: '' });
}
};
}
@@ -0,0 +1,45 @@
/**
* Caliper Status - Passive HID Monitor
* USB digital calipers act as keyboard emulators (HID),
* sending digits + Enter as keystrokes. This component
* monitors numpad-confirm events with inputMethod === 'usb_caliper'
* and provides visual feedback (no Web Serial needed).
*/
function caliperStatus() {
return {
active: false, // Flash active (for animation)
lastReading: null, // Last value read from caliper
readingCount: 0, // Session reading counter
_flashTimeout: null,
_onConfirm: null,
init() {
// Listen for numpad confirmations with usb_caliper method
this._onConfirm = (e) => {
if (e.detail && e.detail.inputMethod === 'usb_caliper') {
this.registerReading(e.detail.value);
}
};
window.addEventListener('numpad-confirm', this._onConfirm);
},
registerReading(value) {
this.lastReading = value;
this.readingCount++;
this.active = true;
clearTimeout(this._flashTimeout);
this._flashTimeout = setTimeout(() => {
this.active = false;
}, 1500);
},
destroy() {
if (this._onConfirm) {
window.removeEventListener('numpad-confirm', this._onConfirm);
}
clearTimeout(this._flashTimeout);
}
};
}
@@ -0,0 +1,270 @@
/**
* CSV Export Utility
* Client-side CSV generation and download with Italian locale support
* Uses Blob API for efficient file generation
*/
function csvExport() {
const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key;
return {
// Locale-specific settings
delimiter: ';', // Italian Excel standard
decimalSeparator: ',', // Italian number format
dateFormat: 'dd/MM/yyyy HH:mm:ss',
/**
* Export measurements to CSV file
* @param {Array} measurements - Array of measurement objects
* @param {String} filename - Optional filename (auto-generated if not provided)
*/
exportMeasurements(measurements, filename = null) {
if (!measurements || !Array.isArray(measurements) || measurements.length === 0) {
console.warn('No measurements to export');
return;
}
// Build CSV content
const csv = this.buildMeasurementCSV(measurements);
// Download file
this.downloadCSV(csv, filename || this.generateFilename('misure'));
},
/**
* Export task execution summary
* @param {Object} task - Task object with measurements
* @param {String} filename - Optional filename
*/
exportTaskSummary(task, filename = null) {
if (!task || !task.measurements || task.measurements.length === 0) {
console.warn('No task data to export');
return;
}
const csv = this.buildTaskSummaryCSV(task);
this.downloadCSV(csv, filename || this.generateFilename(`task_${task.id}`));
},
/**
* Build CSV content for measurements
*/
buildMeasurementCSV(measurements) {
const headers = [
'ID',
_t('subtask_id'),
_t('subtask_name'),
_t('measured_value'),
_t('unit'),
_t('nominal_value'),
_t('tolerance_plus'),
_t('tolerance_minus'),
_t('deviation'),
_t('result'),
_t('lot_number'),
_t('serial_number'),
_t('input_method'),
_t('measurement_date'),
_t('operator')
];
let csv = headers.join(this.delimiter) + '\n';
for (const m of measurements) {
const row = [
m.id || '',
m.subtask_id || '',
this.escapeCsvValue(m.subtask_name || ''),
this.formatNumber(m.value),
this.escapeCsvValue(m.unit || 'mm'),
this.formatNumber(m.nominal),
this.formatNumber(m.tolerance_plus),
this.formatNumber(m.tolerance_minus),
this.formatNumber(m.deviation),
m.pass_fail || '',
this.escapeCsvValue(m.lot_number || ''),
this.escapeCsvValue(m.serial_number || ''),
m.input_method || '',
this.formatDateTime(m.measured_at),
this.escapeCsvValue(m.operator_name || '')
];
csv += row.join(this.delimiter) + '\n';
}
return csv;
},
/**
* Build CSV content for task summary
*/
buildTaskSummaryCSV(task) {
// Header section
let csv = _t('task_summary_title') + '\n\n';
csv += `${_t('task_id')}${this.delimiter}${task.id}\n`;
csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`;
csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
// Statistics
csv += `\n${_t('statistics')}\n`;
const stats = this.calculateStats(task.measurements);
csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`;
csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`;
csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`;
csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
// Measurements detail
csv += `\n${_t('measurement_details')}\n`;
csv += this.buildMeasurementCSV(task.measurements);
return csv;
},
/**
* Calculate statistics from measurements
*/
calculateStats(measurements) {
const total = measurements.length;
const passed = measurements.filter(m => (m.pass_fail || '').toLowerCase() === 'pass').length;
const failed = total - passed;
const passRate = total > 0 ? (passed / total * 100) : 0;
return { total, passed, failed, passRate };
},
/**
* Download CSV content as file
*/
downloadCSV(csvContent, filename) {
// Add UTF-8 BOM for Excel compatibility
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8;'
});
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
/**
* Format number with Italian decimal separator
*/
formatNumber(value) {
if (value === null || value === undefined || value === '') {
return '';
}
const num = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(num)) {
return '';
}
// Convert to string and replace decimal separator
return num.toString().replace('.', this.decimalSeparator);
},
/**
* Format datetime for Italian locale
*/
formatDateTime(dateStr) {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
} catch (err) {
console.error('Date format error:', err);
return dateStr;
}
},
/**
* Escape CSV value (handle quotes, delimiters)
*/
escapeCsvValue(value) {
if (value === null || value === undefined) {
return '';
}
const str = String(value);
// If contains delimiter, newline, or quote, wrap in quotes
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) {
// Escape existing quotes by doubling them
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
},
/**
* Generate filename with timestamp
*/
generateFilename(prefix) {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`;
}
};
}
/**
* CSV Export Button Component
* Reusable Alpine component for export functionality
*/
function csvExportButton() {
return {
exporting: false,
async exportData(data, type = 'measurements') {
this.exporting = true;
try {
const exporter = csvExport();
if (type === 'measurements') {
exporter.exportMeasurements(data);
} else if (type === 'task') {
exporter.exportTaskSummary(data);
}
// Show success feedback
this.$dispatch('export-success', { type });
} catch (err) {
console.error('Export error:', err);
this.$dispatch('export-error', { error: err.message });
} finally {
this.exporting = false;
}
}
};
}
@@ -0,0 +1,76 @@
{
"numpad": {
"title": "Numeric Keypad",
"clear": "Clear",
"submit": "Confirm",
"decimal": "Decimal",
"close": "Close"
},
"measurement": {
"pass": "Pass",
"warning": "Warning",
"fail": "Fail",
"value": "Value",
"next": "Next measurement"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"search": "Search...",
"noResults": "No results",
"close": "Close",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"page": "Page",
"of": "of",
"previous": "Previous",
"next": "Next"
},
"theme": {
"light": "Light",
"dark": "Dark",
"toggle": "Toggle theme"
},
"language": {
"switch": "Switch language",
"it": "Italian",
"en": "English"
},
"auth": {
"login": "Sign in to your account",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"invalidCredentials": "Invalid credentials",
"enterCredentials": "Enter username and password",
"forgotPassword": "Forgot password? Contact administrator",
"welcome": "Welcome, {name}!",
"logoutSuccess": "Logged out successfully",
"loginRequired": "Please log in to continue"
},
"nav": {
"measurements": "Measurements",
"recipes": "Recipes",
"statistics": "Statistics",
"admin": "Admin",
"users": "Users",
"settings": "Settings",
"profile": "Profile",
"logout": "Logout"
},
"profile": {
"title": "User Profile",
"displayName": "Display Name",
"language": "Language",
"theme": "Theme",
"roles": "Roles",
"saveChanges": "Save Changes",
"updateSuccess": "Profile updated successfully"
}
}
@@ -0,0 +1,76 @@
{
"numpad": {
"title": "Tastierino Numerico",
"clear": "Cancella",
"submit": "Conferma",
"decimal": "Virgola",
"close": "Chiudi"
},
"measurement": {
"pass": "Conforme",
"warning": "Attenzione",
"fail": "Non Conforme",
"value": "Valore",
"next": "Prossima misura"
},
"common": {
"loading": "Caricamento...",
"save": "Salva",
"cancel": "Annulla",
"confirm": "Conferma",
"delete": "Elimina",
"edit": "Modifica",
"search": "Cerca...",
"noResults": "Nessun risultato",
"close": "Chiudi",
"error": "Errore",
"success": "Successo",
"warning": "Attenzione",
"info": "Info",
"page": "Pagina",
"of": "di",
"previous": "Precedente",
"next": "Successivo"
},
"theme": {
"light": "Chiaro",
"dark": "Scuro",
"toggle": "Cambia tema"
},
"language": {
"switch": "Cambia lingua",
"it": "Italiano",
"en": "English"
},
"auth": {
"login": "Accedi al sistema",
"username": "Username",
"password": "Password",
"signIn": "Accedi",
"invalidCredentials": "Credenziali non valide",
"enterCredentials": "Inserisci username e password",
"forgotPassword": "Hai dimenticato la password? Contatta l'amministratore",
"welcome": "Benvenuto, {name}!",
"logoutSuccess": "Logout effettuato",
"loginRequired": "Effettua il login per continuare"
},
"nav": {
"measurements": "Misure",
"recipes": "Ricette",
"statistics": "Statistiche",
"admin": "Admin",
"users": "Utenti",
"settings": "Impostazioni",
"profile": "Profilo",
"logout": "Esci"
},
"profile": {
"title": "Profilo Utente",
"displayName": "Nome Visualizzato",
"language": "Lingua",
"theme": "Tema",
"roles": "Ruoli",
"saveChanges": "Salva Modifiche",
"updateSuccess": "Profilo aggiornato con successo"
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Numpad Component - Alpine.js component for touch-friendly numeric input
* Used for measurement data entry in task_execute.html
*/
function numpad() {
return {
// State
value: '', // String representation of the current value
negative: false, // Whether the value is negative
hasDecimal: false, // Whether a decimal point has been entered
unit: 'mm', // Unit of measurement (can be set externally)
maxIntDigits: 6, // Maximum integer digits
maxDecDigits: 6, // Maximum decimal digits
// HID burst detection (USB caliper vs manual typing)
_lastKeyTime: 0, // Timestamp of last keystroke
_burstCount: 0, // Consecutive fast keystrokes
/**
* Get the display value with sign
*/
get displayValue() {
if (!this.value) return '';
return (this.negative ? '-' : '') + this.value;
},
/**
* Get the numeric value as a number
*/
get numericValue() {
if (!this.value) return null;
const v = parseFloat(this.value);
return this.negative ? -v : v;
},
/**
* Check if a valid value has been entered
*/
get hasValue() {
return this.value.length > 0 && this.value !== '.';
},
/**
* Add a digit to the current value
* @param {string} d - The digit to add (0-9)
*/
addDigit(d) {
// Validate: don't exceed max digits
const parts = this.value.split('.');
if (this.hasDecimal) {
// Check decimal part length
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
} else {
// Check integer part length
if (parts[0] && parts[0].length >= this.maxIntDigits) return;
}
// Prevent leading zeros (except "0.")
if (this.value === '0' && d !== '.') {
this.value = d;
return;
}
this.value += d;
},
/**
* Add a decimal point to the current value
*/
addDecimal() {
// Don't add decimal if one already exists
if (this.hasDecimal) return;
// If value is empty, start with "0."
if (!this.value) this.value = '0';
this.value += '.';
this.hasDecimal = true;
},
/**
* Toggle the sign of the current value
*/
toggleSign() {
if (!this.value) return;
this.negative = !this.negative;
},
/**
* Remove the last character from the current value
*/
backspace() {
if (!this.value) return;
const removed = this.value.charAt(this.value.length - 1);
// If removing decimal point, update flag
if (removed === '.') this.hasDecimal = false;
this.value = this.value.slice(0, -1);
},
/**
* Clear all entered data
*/
clearAll() {
this.value = '';
this.negative = false;
this.hasDecimal = false;
},
/**
* Confirm the current value and dispatch event
*/
confirm() {
if (!this.hasValue) return;
const val = this.numericValue;
// Determine input method: 3+ fast keystrokes = USB caliper burst
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
// Dispatch custom event for parent component to handle
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
// Reset after confirmation
this.clearAll();
this._burstCount = 0;
this._lastKeyTime = 0;
},
/**
* Set a value programmatically (e.g., from USB caliper)
* @param {number} val - The numeric value to set
*/
setValue(val) {
this.clearAll();
// Handle negative values
if (val < 0) {
this.negative = true;
val = Math.abs(val);
}
this.value = val.toString();
// Update decimal flag if value contains decimal point
if (this.value.includes('.')) this.hasDecimal = true;
},
/**
* Set the unit of measurement
* @param {string} newUnit - The unit to set (e.g., 'mm', 'cm', 'in')
*/
setUnit(newUnit) {
this.unit = newUnit;
},
/**
* Handle keyboard input for physical keyboard support
* @param {KeyboardEvent} e - The keyboard event
*/
handleKeydown(e) {
// Number keys
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
this._trackBurst();
this.addDigit(e.key);
}
// Decimal point (both . and ,)
else if (e.key === '.' || e.key === ',') {
e.preventDefault();
this._trackBurst();
this.addDecimal();
}
// Backspace
else if (e.key === 'Backspace') {
e.preventDefault();
this.backspace();
}
// Escape - clear all
else if (e.key === 'Escape') {
e.preventDefault();
this.clearAll();
}
// Enter - confirm
else if (e.key === 'Enter') {
e.preventDefault();
this.confirm();
}
// Minus sign - toggle sign
else if (e.key === '-') {
e.preventDefault();
this.toggleSign();
}
},
/**
* Track keystroke timing for HID burst detection.
* USB calipers send digits in rapid succession (<80ms apart).
* Human typing is much slower (>100ms between keys).
*/
_trackBurst() {
const now = performance.now();
const gap = now - this._lastKeyTime;
if (this._lastKeyTime > 0 && gap < 80) {
this._burstCount++;
} else {
this._burstCount = 1;
}
this._lastKeyTime = now;
}
};
}
@@ -0,0 +1,296 @@
/**
* SPC Charts — Plotly.js helper functions for Metrologist dashboard.
*
* Requires Plotly.js loaded via CDN before this script.
* Requires window.SPC_I18N object set in the template for translations.
*
* Brand colors:
* Primary: #2563EB
* Pass: #10b981
* Warning: #f59e0b
* Fail: #ef4444
*/
// i18n helper — falls back to Italian defaults
const _t = (key) => (window.SPC_I18N && window.SPC_I18N[key]) || key;
const SPC_COLORS = {
primary: '#2563EB',
primaryLight: '#3B82F6',
pass: '#10b981',
warning: '#f59e0b',
fail: '#ef4444',
steel: '#64748B',
steelLight: '#94A3B8',
nominal: '#8b5cf6',
ucl_lcl: '#ef4444',
uwl_lwl: '#f59e0b',
utl_ltl: '#dc2626',
bg: '#ffffff',
gridColor: '#e2e8f0',
textColor: '#334155',
};
const PLOTLY_LAYOUT_DEFAULTS = {
font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
margin: { t: 20, r: 30, b: 50, l: 60 },
xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
};
const PLOTLY_CONFIG = {
responsive: true,
displayModeBar: 'hover',
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false,
};
/**
* Render a control chart (X-bar) with tolerance and control limits.
*
* @param {string} containerId - DOM element ID for the chart.
* @param {Object} data - ControlChartData from API.
*/
function renderControlChart(containerId, data) {
if (!data || !data.values || data.values.length === 0) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
return;
}
const n = data.values.length;
const xLabels = data.timestamps.map((t, i) => i + 1);
// Separate in-control and out-of-control points
const oocSet = new Set(data.out_of_control || []);
const inControlX = [], inControlY = [];
const oocX = [], oocY = [];
for (let i = 0; i < n; i++) {
if (oocSet.has(i)) {
oocX.push(xLabels[i]);
oocY.push(data.values[i]);
} else {
inControlX.push(xLabels[i]);
inControlY.push(data.values[i]);
}
}
const traces = [
// In-control points
{
x: inControlX, y: inControlY,
mode: 'markers',
type: 'scatter',
name: _t('inControl'),
marker: { color: SPC_COLORS.primary, size: 6 },
},
// Out-of-control points
{
x: oocX, y: oocY,
mode: 'markers',
type: 'scatter',
name: _t('outOfControl'),
marker: { color: SPC_COLORS.fail, size: 9, symbol: 'diamond' },
},
// Line connecting all points
{
x: xLabels, y: data.values,
mode: 'lines',
type: 'scatter',
name: '',
line: { color: SPC_COLORS.steelLight, width: 1 },
showlegend: false,
},
];
// Helper to add horizontal line
const shapes = [];
function addLine(yVal, color, dash) {
if (yVal == null) return;
shapes.push({
type: 'line', xref: 'paper', x0: 0, x1: 1, y0: yVal, y1: yVal,
line: { color, width: 1.5, dash },
});
}
// Mean line
addLine(data.mean, SPC_COLORS.primary, 'solid');
// UCL / LCL
addLine(data.ucl, SPC_COLORS.ucl_lcl, 'dash');
addLine(data.lcl, SPC_COLORS.ucl_lcl, 'dash');
// UTL / LTL
addLine(data.utl, SPC_COLORS.utl_ltl, 'dot');
addLine(data.ltl, SPC_COLORS.utl_ltl, 'dot');
// UWL / LWL
addLine(data.uwl, SPC_COLORS.uwl_lwl, 'dashdot');
addLine(data.lwl, SPC_COLORS.uwl_lwl, 'dashdot');
// Nominal
addLine(data.nominal, SPC_COLORS.nominal, 'longdash');
// Build annotations for limit labels
const annotations = [];
function addLabel(yVal, text, color) {
if (yVal == null) return;
annotations.push({
x: 1, xref: 'paper', xanchor: 'left',
y: yVal, yanchor: 'middle',
text: text,
showarrow: false,
font: { size: 10, color },
});
}
addLabel(data.mean, 'X\u0304', SPC_COLORS.primary);
addLabel(data.ucl, 'UCL', SPC_COLORS.ucl_lcl);
addLabel(data.lcl, 'LCL', SPC_COLORS.ucl_lcl);
addLabel(data.utl, 'UTL', SPC_COLORS.utl_ltl);
addLabel(data.ltl, 'LTL', SPC_COLORS.utl_ltl);
addLabel(data.uwl, 'UWL', SPC_COLORS.uwl_lwl);
addLabel(data.lwl, 'LWL', SPC_COLORS.uwl_lwl);
addLabel(data.nominal, 'NOM', SPC_COLORS.nominal);
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') },
shapes,
annotations,
showlegend: true,
legend: { orientation: 'h', y: -0.15 },
margin: { ...PLOTLY_LAYOUT_DEFAULTS.margin, r: 50 },
};
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
}
/**
* Render histogram with normal curve overlay.
*
* @param {string} containerId - DOM element ID.
* @param {Object} data - HistogramData from API.
* @param {Object} [tol] - Optional tolerance limits {utl, ltl, nominal}.
*/
function renderHistogram(containerId, data, tol) {
if (!data || !data.bins || data.bins.length === 0 || data.n === 0) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
return;
}
// Bin centers for bar chart
const binCenters = [];
const binWidth = data.bins[1] - data.bins[0];
for (let i = 0; i < data.counts.length; i++) {
binCenters.push((data.bins[i] + data.bins[i + 1]) / 2);
}
const traces = [
// Histogram bars
{
x: binCenters, y: data.counts,
type: 'bar',
name: _t('frequency'),
marker: { color: SPC_COLORS.primaryLight, opacity: 0.7 },
width: binWidth * 0.9,
},
];
// Normal curve overlay
if (data.normal_x && data.normal_x.length > 0) {
traces.push({
x: data.normal_x, y: data.normal_y,
type: 'scatter',
mode: 'lines',
name: _t('normalCurve'),
line: { color: SPC_COLORS.fail, width: 2 },
});
}
const shapes = [];
function addVertLine(xVal, color, dash) {
if (xVal == null) return;
shapes.push({
type: 'line', yref: 'paper', y0: 0, y1: 1, x0: xVal, x1: xVal,
line: { color, width: 1.5, dash },
});
}
if (tol) {
addVertLine(tol.utl, SPC_COLORS.utl_ltl, 'dot');
addVertLine(tol.ltl, SPC_COLORS.utl_ltl, 'dot');
addVertLine(tol.nominal, SPC_COLORS.nominal, 'longdash');
}
// Mean line
addVertLine(data.mean, SPC_COLORS.primary, 'dash');
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') },
shapes,
bargap: 0.05,
showlegend: true,
legend: { orientation: 'h', y: -0.15 },
};
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
}
/**
* Render a capability gauge (horizontal bar indicator for Cpk).
*
* @param {string} containerId - DOM element ID.
* @param {Object} data - CapabilityData from API.
*/
function renderCapabilityGauge(containerId, data) {
if (!data || data.cpk == null) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-8">' + _t('indicesNotAvailable') + '</p>';
return;
}
const cpk = data.cpk;
// Color zones: <1.0 = fail, 1.0-1.33 = warning, >=1.33 = pass
let barColor = SPC_COLORS.fail;
if (cpk >= 1.33) barColor = SPC_COLORS.pass;
else if (cpk >= 1.0) barColor = SPC_COLORS.warning;
const trace = {
type: 'indicator',
mode: 'gauge+number',
value: cpk,
title: { text: 'Cpk', font: { size: 14 } },
number: { font: { family: 'JetBrains Mono, monospace', size: 28 } },
gauge: {
axis: { range: [0, 2.5], tickwidth: 1 },
bar: { color: barColor, thickness: 0.6 },
bgcolor: '#f1f5f9',
borderwidth: 0,
steps: [
{ range: [0, 1.0], color: '#fecaca' },
{ range: [1.0, 1.33], color: '#fef3c7' },
{ range: [1.33, 2.5], color: '#d1fae5' },
],
threshold: {
line: { color: SPC_COLORS.primary, width: 3 },
thickness: 0.75,
value: cpk,
},
},
};
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
height: 200,
margin: { t: 30, r: 20, b: 10, l: 20 },
};
Plotly.newPlot(containerId, [trace], layout, { ...PLOTLY_CONFIG, displayModeBar: false });
}
View File