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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user