Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,864 @@
/* Fusion Authorizer Portal - Custom Styles */
/* Color Scheme: Dark Blue (#1a365d, #2c5282) with Green accents (#38a169) */
:root {
--portal-primary: #1a365d;
--portal-primary-light: #2c5282;
--portal-accent: #38a169;
--portal-accent-light: #48bb78;
--portal-dark: #1a202c;
--portal-gray: #718096;
--portal-light: #f7fafc;
}
/* Portal Header Styling - Only for Fusion Portal pages */
/* Removed global navbar styling to prevent affecting other portal pages */
/* Card Headers with Portal Theme */
.card-header.bg-dark {
background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important;
}
.card-header.bg-primary {
background: var(--portal-primary-light) !important;
}
.card-header.bg-success {
background: var(--portal-accent) !important;
}
/* Stat Cards */
.card.bg-primary {
background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important;
}
.card.bg-success {
background: linear-gradient(135deg, var(--portal-accent) 0%, var(--portal-accent-light) 100%) !important;
}
/* Table Styling */
.table-dark th {
background: var(--portal-primary) !important;
}
.table-success th {
background: var(--portal-accent) !important;
color: white !important;
}
.table-info th {
background: var(--portal-primary-light) !important;
color: white !important;
}
/* Badges */
.badge.bg-primary {
background: var(--portal-primary-light) !important;
}
.badge.bg-success {
background: var(--portal-accent) !important;
}
/* Buttons */
.btn-primary {
background: var(--portal-primary-light) !important;
border-color: var(--portal-primary-light) !important;
}
.btn-primary:hover {
background: var(--portal-primary) !important;
border-color: var(--portal-primary) !important;
}
.btn-success {
background: var(--portal-accent) !important;
border-color: var(--portal-accent) !important;
}
.btn-success:hover {
background: var(--portal-accent-light) !important;
border-color: var(--portal-accent-light) !important;
}
.btn-outline-primary {
color: var(--portal-primary-light) !important;
border-color: var(--portal-primary-light) !important;
}
.btn-outline-primary:hover {
background: var(--portal-primary-light) !important;
color: white !important;
}
/* Search Box Styling */
#portal-search-input {
border-radius: 25px 0 0 25px;
padding-left: 20px;
}
#portal-search-input:focus {
border-color: var(--portal-primary-light);
box-shadow: 0 0 0 0.2rem rgba(44, 82, 130, 0.25);
}
/* Case List Row Hover */
.table-hover tbody tr:hover {
background-color: rgba(44, 82, 130, 0.1);
}
/* Document Upload Area */
.document-upload-area {
border: 2px dashed var(--portal-gray);
border-radius: 10px;
padding: 20px;
text-align: center;
background: var(--portal-light);
transition: all 0.3s ease;
}
.document-upload-area:hover {
border-color: var(--portal-primary-light);
background: rgba(44, 82, 130, 0.05);
}
/* Comment Section */
.comment-item {
border-left: 4px solid var(--portal-primary-light);
padding-left: 15px;
margin-bottom: 15px;
}
.comment-item .comment-author {
font-weight: 600;
color: var(--portal-primary);
}
.comment-item .comment-date {
font-size: 0.85em;
color: var(--portal-gray);
}
/* Signature Pad */
.signature-pad-container {
border: 2px solid var(--portal-gray);
border-radius: 8px;
padding: 10px;
background: white;
touch-action: none;
}
.signature-pad-container canvas {
cursor: crosshair;
width: 100%;
height: 200px;
}
/* Progress Bar */
.progress {
border-radius: 15px;
overflow: hidden;
}
.progress-bar {
font-size: 0.75rem;
font-weight: 600;
}
/* Assessment Form Cards */
.assessment-section-card {
transition: all 0.3s ease;
}
.assessment-section-card:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
/* Status Badges */
.status-badge {
padding: 0.5em 1em;
border-radius: 20px;
font-weight: 500;
}
.status-draft {
background: #e2e8f0;
color: #4a5568;
}
.status-pending {
background: #faf089;
color: #744210;
}
.status-completed {
background: #c6f6d5;
color: #276749;
}
.status-cancelled {
background: #fed7d7;
color: #9b2c2c;
}
/* Quick Action Buttons */
.quick-action-btn {
min-width: 150px;
margin-bottom: 10px;
}
/* Loading Spinner */
.search-loading {
display: none;
position: absolute;
right: 50px;
top: 50%;
transform: translateY(-50%);
}
.search-loading.active {
display: block;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.card-header {
font-size: 0.9rem;
}
.btn-lg {
font-size: 1rem;
padding: 0.5rem 1rem;
}
.table-responsive {
font-size: 0.85rem;
}
.signature-pad-container canvas {
height: 150px;
}
}
/* Animation for Search Results */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-result-row {
animation: fadeIn 0.3s ease;
}
/* Highlight matching text */
.search-highlight {
background-color: #faf089;
padding: 0 2px;
border-radius: 2px;
}
/* ========================================
EXPRESS ASSESSMENT FORM STYLES
======================================== */
.assessment-express-form .form-label {
color: #333;
font-size: 0.95rem;
}
.assessment-express-form .form-label.fw-bold {
font-weight: 600 !important;
}
.assessment-express-form .form-control,
.assessment-express-form .form-select {
border-radius: 6px;
border-color: #dee2e6;
padding: 0.625rem 0.875rem;
}
.assessment-express-form .form-control:focus,
.assessment-express-form .form-select:focus {
border-color: #2e7aad;
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
}
.assessment-express-form .form-select-lg {
padding: 0.75rem 1rem;
font-size: 1.1rem;
}
/* Input Group with Inch suffix */
.assessment-express-form .input-group-text {
background-color: #f8f9fa;
border-color: #dee2e6;
color: #6c757d;
font-weight: 500;
}
/* Checkbox and Radio Styling */
.assessment-express-form .form-check {
padding-left: 1.75rem;
margin-bottom: 0.5rem;
}
.assessment-express-form .form-check-input {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
margin-left: -1.75rem;
}
.assessment-express-form .form-check-input:checked {
background-color: #2e7aad;
border-color: #2e7aad;
}
.assessment-express-form .form-check-label {
color: #333;
cursor: pointer;
}
/* Equipment Form Sections */
.assessment-express-form .equipment-form h2 {
color: #1a1a1a;
font-size: 1.5rem;
letter-spacing: 1px;
}
/* Card Styling */
.assessment-express-form .card {
border: none;
border-radius: 12px;
}
.assessment-express-form .card-header.bg-primary {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
border-radius: 12px 12px 0 0;
}
.assessment-express-form .card-body {
padding: 2rem;
}
.assessment-express-form .card-footer {
border-top: 1px solid #e9ecef;
padding: 1.25rem 2rem;
}
/* Button Styling */
.assessment-express-form .btn-primary {
background: #2e7aad !important;
border-color: #4361ee !important;
padding: 0.75rem 2rem;
font-weight: 600;
border-radius: 6px;
}
.assessment-express-form .btn-primary:hover {
background: #3451d1 !important;
border-color: #3451d1 !important;
}
.assessment-express-form .btn-success {
background: #10b981 !important;
border-color: #10b981 !important;
padding: 0.75rem 2rem;
font-weight: 600;
border-radius: 6px;
}
.assessment-express-form .btn-success:hover {
background: #059669 !important;
border-color: #059669 !important;
}
.assessment-express-form .btn-outline-secondary {
border-width: 2px;
font-weight: 500;
}
/* Progress Bar */
.assessment-express-form .progress {
height: 8px;
background-color: #e9ecef;
}
.assessment-express-form .progress-bar {
background: linear-gradient(90deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
}
/* Section Separators */
.assessment-express-form hr {
border-color: #e9ecef;
opacity: 1;
}
/* Required Field Indicator */
.assessment-express-form .text-danger {
color: #dc3545 !important;
}
/* Section Headers within form */
.assessment-express-form h5.fw-bold {
color: #374151;
border-bottom: 2px solid #2e7aad;
padding-bottom: 0.5rem;
display: inline-block;
}
/* New Assessment Card on Portal Home */
.portal-new-assessment-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-new-assessment-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3) !important;
}
.portal-new-assessment-card .card-body {
background: transparent !important;
}
.portal-new-assessment-card h5,
.portal-new-assessment-card small {
color: #fff !important;
}
.portal-new-assessment-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-new-assessment-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Authorizer Portal Card on Portal Home */
.portal-authorizer-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-authorizer-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(46, 122, 173, 0.3) !important;
}
.portal-authorizer-card .card-body {
background: transparent !important;
}
.portal-authorizer-card h5,
.portal-authorizer-card small {
color: #fff !important;
}
.portal-authorizer-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-authorizer-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.assessment-express-form .card-body {
padding: 1.25rem;
}
.assessment-express-form .d-flex.flex-wrap.gap-4 {
gap: 0.5rem !important;
}
.assessment-express-form .row {
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.assessment-express-form .col-md-4,
.assessment-express-form .col-md-6 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
/* ================================================================== */
/* AUTHORIZER DASHBOARD - MOBILE-FIRST REDESIGN */
/* ================================================================== */
.auth-dash {
background: #f8f9fb;
min-height: 80vh;
}
/* Content Area */
.auth-dash-content {
padding-top: 24px;
padding-bottom: 40px;
}
/* Welcome Header */
.auth-dash-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;
}
.auth-dash-header-inner {
padding: 28px 30px 24px;
}
.auth-dash-greeting {
color: #fff;
font-size: 24px;
font-weight: 700;
margin: 0 0 4px 0;
letter-spacing: -0.3px;
}
.auth-dash-subtitle {
color: rgba(255,255,255,0.85);
font-size: 14px;
margin: 0;
font-weight: 400;
}
/* ---- Action Tiles ---- */
.auth-dash-tiles {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}
.auth-tile {
display: flex;
align-items: center;
background: #fff;
border-radius: 14px;
padding: 18px 20px;
text-decoration: none !important;
color: #333 !important;
border: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
min-height: 72px;
}
.auth-tile:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
border-color: #d0d5dd;
}
.auth-tile:active {
transform: scale(0.98);
}
.auth-tile-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
color: #fff;
margin-right: 16px;
}
.auth-tile-cases .auth-tile-icon {
background: linear-gradient(135deg, #2e7aad, #1a6b9a);
}
.auth-tile-assessments .auth-tile-icon {
background: linear-gradient(135deg, #5ba848, #4a9e3f);
}
.auth-tile-new .auth-tile-icon {
background: linear-gradient(135deg, #3a8fb7, #2e7aad);
}
.auth-tile-info {
flex: 1;
min-width: 0;
}
.auth-tile-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
line-height: 1.3;
}
.auth-tile-desc {
font-size: 13px;
color: #8b95a5;
line-height: 1.3;
margin-top: 3px;
}
.auth-tile-badge .badge {
background: #eef1f7;
color: #3949ab;
font-size: 15px;
font-weight: 700;
padding: 5px 14px;
border-radius: 20px;
margin-right: 10px;
}
.auth-tile-arrow {
color: #c5ccd6;
font-size: 14px;
flex-shrink: 0;
}
/* ---- Sections ---- */
.auth-dash-section {
background: #fff;
border-radius: 14px;
overflow: hidden;
margin-bottom: 20px;
border: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.auth-section-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #444;
border-bottom: 1px solid #f0f2f5;
display: flex;
align-items: center;
}
.auth-section-attention {
color: #c0392b;
background: #fef5f5;
border-bottom-color: #fce4e4;
}
.auth-section-pending {
color: #d97706;
background: #fef9f0;
border-bottom-color: #fdecd0;
}
/* ---- Case List Items ---- */
.auth-case-list {
padding: 0;
}
.auth-case-item {
display: flex;
align-items: center;
padding: 16px 20px;
text-decoration: none !important;
color: inherit !important;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s ease;
cursor: pointer;
}
.auth-case-item:last-child {
border-bottom: none;
}
.auth-case-item:hover {
background: #f9fafb;
}
.auth-case-item:active {
background: #f0f2f5;
}
.auth-case-attention {
border-left: 3px solid #e74c3c;
}
.auth-case-main {
flex: 1;
min-width: 0;
}
.auth-case-client {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.auth-case-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 5px;
align-items: center;
}
.auth-case-ref {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.auth-case-type {
font-size: 11px;
background: #e3f2fd;
color: #1565c0;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
text-transform: uppercase;
}
.auth-case-status {
font-size: 11px;
background: #f3e5f5;
color: #7b1fa2;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
}
.badge-attention {
font-size: 11px;
background: #fce4ec;
color: #c62828;
padding: 2px 10px;
border-radius: 10px;
font-weight: 600;
}
.auth-case-date {
font-size: 12px;
color: #9ca3af;
}
.auth-case-arrow {
color: #c5ccd6;
font-size: 14px;
flex-shrink: 0;
margin-left: 12px;
}
/* ---- Empty State ---- */
.auth-empty-state {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.auth-empty-state i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.auth-empty-state h5 {
color: #666;
margin-bottom: 8px;
}
/* ---- Desktop Enhancements ---- */
@media (min-width: 768px) {
.auth-dash-header-inner {
padding: 32px 36px 28px;
}
.auth-dash-greeting {
font-size: 28px;
}
.auth-dash-tiles {
flex-direction: row;
gap: 16px;
}
.auth-tile {
flex: 1;
padding: 20px 22px;
}
.auth-dash-content {
padding-top: 28px;
}
}
/* ---- Mobile Optimizations ---- */
@media (max-width: 767px) {
.auth-dash-content {
padding-left: 12px;
padding-right: 12px;
padding-top: 16px;
}
.auth-dash-header {
border-radius: 0;
margin-left: -12px;
margin-right: -12px;
margin-top: -24px;
margin-bottom: 20px;
}
.auth-dash-header-inner {
padding: 22px 20px 20px;
}
.auth-dash-greeting {
font-size: 20px;
}
.auth-dash-subtitle {
font-size: 13px;
}
.auth-tile {
padding: 16px 18px;
min-height: 66px;
}
.auth-tile-icon {
width: 44px;
height: 44px;
font-size: 18px;
margin-right: 14px;
}
.auth-case-item {
padding: 14px 18px;
}
.auth-section-header {
padding: 14px 18px;
}
}

View File

@@ -0,0 +1,540 @@
/* ==========================================================================
Fusion Technician Portal - Mobile-First Styles (v2)
========================================================================== */
/* ---- Base & Mobile First ---- */
.tech-portal {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
max-width: 640px;
margin: 0 auto;
}
/* ---- Quick Stats Bar (Dashboard) ---- */
.tech-stats-bar {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.tech-stats-bar::-webkit-scrollbar { display: none; }
.tech-stat-card {
flex: 0 0 auto;
min-width: 100px;
padding: 0.75rem 1rem;
border-radius: 12px;
text-align: center;
color: #fff;
font-weight: 600;
}
.tech-stat-card .stat-number {
font-size: 1.5rem;
line-height: 1.2;
}
.tech-stat-card .stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
}
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
/* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card {
border: none;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 1.5rem;
}
.tech-hero-card .card-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
color: #fff;
padding: 1rem 1.25rem;
border: none;
}
.tech-hero-card .card-header h5 {
color: #fff;
margin: 0;
}
.tech-hero-card .card-body {
padding: 1.25rem;
}
.tech-hero-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.85;
margin-bottom: 0.15rem;
}
/* ---- Timeline (Dashboard) ---- */
.tech-timeline {
position: relative;
padding-left: 2rem;
}
.tech-timeline::before {
content: '';
position: absolute;
left: 0.75rem;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.tech-timeline-item {
position: relative;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
}
.tech-timeline-dot {
position: absolute;
left: -1.55rem;
top: 0.35rem;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #dee2e6;
z-index: 1;
}
.tech-timeline-dot.status-scheduled { background: #6c757d; box-shadow: 0 0 0 2px #6c757d; }
.tech-timeline-dot.status-en_route { background: #3498db; box-shadow: 0 0 0 2px #3498db; }
.tech-timeline-dot.status-in_progress { background: #f39c12; box-shadow: 0 0 0 2px #f39c12; animation: pulse-dot 1.5s infinite; }
.tech-timeline-dot.status-completed { background: #27ae60; box-shadow: 0 0 0 2px #27ae60; }
.tech-timeline-dot.status-cancelled { background: #e74c3c; box-shadow: 0 0 0 2px #e74c3c; }
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 2px #f39c12; }
50% { box-shadow: 0 0 0 6px rgba(243, 156, 18, 0.3); }
}
.tech-timeline-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 0.875rem 1rem;
background: #fff;
transition: box-shadow 0.2s, transform 0.15s;
text-decoration: none !important;
color: inherit !important;
display: block;
}
.tech-timeline-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.tech-timeline-card.active {
border-color: #f39c12;
border-width: 2px;
box-shadow: 0 4px 16px rgba(243, 156, 18, 0.15);
}
.tech-timeline-time {
font-size: 0.85rem;
font-weight: 600;
color: #495057;
}
.tech-timeline-title {
font-size: 0.95rem;
font-weight: 600;
color: #212529;
margin: 0.15rem 0;
}
.tech-timeline-meta {
font-size: 0.8rem;
color: #6c757d;
}
/* Travel indicator between tasks */
.tech-travel-indicator {
padding: 0.35rem 0 0.35rem 0;
margin-left: -0.2rem;
font-size: 0.75rem;
color: #8e44ad;
}
/* ---- Task Type Badges ---- */
.tech-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tech-badge-delivery { background: #d4edda; color: #155724; }
.tech-badge-repair { background: #fff3cd; color: #856404; }
.tech-badge-pickup { background: #cce5ff; color: #004085; }
.tech-badge-troubleshoot { background: #f8d7da; color: #721c24; }
.tech-badge-assessment { background: #e2e3e5; color: #383d41; }
.tech-badge-installation { background: #d1ecf1; color: #0c5460; }
.tech-badge-maintenance { background: #e8daef; color: #6c3483; }
.tech-badge-other { background: #e9ecef; color: #495057; }
/* Status badges */
.tech-status-badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.tech-status-scheduled { background: #e9ecef; color: #495057; }
.tech-status-en_route { background: #cce5ff; color: #004085; }
.tech-status-in_progress { background: #fff3cd; color: #856404; }
.tech-status-completed { background: #d4edda; color: #155724; }
.tech-status-cancelled { background: #f8d7da; color: #721c24; }
/* ==========================================================================
Task Detail Page - v2 Redesign
========================================================================== */
/* ---- Back button ---- */
.tech-back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--o-main-bg-color, #f8f9fa);
color: var(--o-main-text-color, #495057);
text-decoration: none !important;
transition: background 0.15s;
border: 1px solid var(--o-main-border-color, #dee2e6);
}
.tech-back-btn:hover {
background: var(--o-main-border-color, #dee2e6);
}
/* ---- Task Hero Header ---- */
.tech-task-hero {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--o-main-border-color, #eee);
}
/* ---- Quick Actions Row ---- */
.tech-quick-actions {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding: 0.25rem 0;
}
.tech-quick-actions::-webkit-scrollbar { display: none; }
.tech-quick-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-width: 68px;
padding: 0.6rem 0.5rem;
border-radius: 14px;
background: var(--o-main-bg-color, #f8f9fa);
border: 1px solid var(--o-main-border-color, #e9ecef);
color: var(--o-main-text-color, #495057) !important;
text-decoration: none !important;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
transition: all 0.15s;
flex-shrink: 0;
}
.tech-quick-btn i {
font-size: 1.15rem;
color: #3498db;
}
.tech-quick-btn:hover {
background: #e3f2fd;
border-color: #90caf9;
}
.tech-quick-btn:active {
transform: scale(0.95);
}
/* ---- Card (unified style for all sections) ---- */
.tech-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 1rem;
}
.tech-card-success {
border-color: #c3e6cb;
background: color-mix(in srgb, #d4edda 30%, var(--o-main-card-bg, #fff));
}
/* ---- Card icon (left gutter icon) ---- */
.tech-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
font-size: 1rem;
margin-right: 0.75rem;
flex-shrink: 0;
}
/* ---- Equipment highlight tag ---- */
.tech-equipment-tag {
background: color-mix(in srgb, #ffeeba 25%, var(--o-main-card-bg, #fff));
border: 1px solid #ffeeba;
border-radius: 10px;
padding: 0.75rem;
}
/* ---- Action Buttons (Large Touch Targets) ---- */
.tech-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 48px;
padding: 0.75rem 1.5rem;
border-radius: 14px;
font-weight: 600;
font-size: 0.95rem;
border: none;
cursor: pointer;
transition: all 0.15s;
text-decoration: none !important;
}
.tech-action-btn:active { transform: scale(0.97); }
.tech-btn-navigate {
background: #3498db;
color: #fff !important;
}
.tech-btn-navigate:hover { background: #2980b9; color: #fff !important; }
.tech-btn-start {
background: #27ae60;
color: #fff !important;
}
.tech-btn-start:hover { background: #219a52; color: #fff !important; }
.tech-btn-complete {
background: #f39c12;
color: #fff !important;
}
.tech-btn-complete:hover { background: #e67e22; color: #fff !important; }
.tech-btn-call {
background: #9b59b6;
color: #fff !important;
}
.tech-btn-call:hover { background: #8e44ad; color: #fff !important; }
.tech-btn-enroute {
background: #2980b9;
color: #fff !important;
}
.tech-btn-enroute:hover { background: #2471a3; color: #fff !important; }
/* ---- Bottom Action Bar (Fixed on mobile) ---- */
.tech-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--o-main-card-bg, #fff);
border-top: 1px solid var(--o-main-border-color, #dee2e6);
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
z-index: 1050;
display: flex;
gap: 0.5rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.08);
}
.tech-bottom-bar .tech-action-btn {
flex: 1;
}
/* Padding to prevent content being hidden behind fixed bar */
.has-bottom-bar {
padding-bottom: 5rem;
}
/* ---- Completion Overlay ---- */
.tech-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 9999;
align-items: center;
justify-content: center;
}
.tech-overlay-card {
background: var(--o-main-card-bg, #fff);
border-radius: 20px;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
animation: slideUp 0.3s ease;
}
.tech-overlay-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Voice Recording UI ---- */
.tech-voice-recorder {
border: 2px dashed var(--o-main-border-color, #dee2e6);
border-radius: 16px;
padding: 1.5rem 1rem;
text-align: center;
transition: all 0.3s;
}
.tech-voice-recorder.recording {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.04);
}
.tech-record-btn {
width: 64px;
height: 64px;
border-radius: 50%;
border: none;
background: #e74c3c;
color: #fff;
font-size: 1.3rem;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tech-record-btn:hover { transform: scale(1.05); }
.tech-record-btn:active { transform: scale(0.95); }
.tech-record-btn.recording {
animation: pulse-record 1.5s infinite;
}
@keyframes pulse-record {
0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); }
50% { box-shadow: 0 0 0 15px rgba(231, 76, 60, 0); }
}
.tech-record-timer {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 0.5rem;
color: #e74c3c;
}
/* ---- Tomorrow Prep ---- */
.tech-prep-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
background: #fff;
}
.tech-prep-card .prep-time {
font-weight: 700;
font-size: 0.9rem;
}
.tech-prep-card .prep-type {
margin-left: 0.5rem;
}
.tech-prep-equipment {
background: #fff9e6;
border: 1px solid #ffeeba;
border-radius: 12px;
padding: 1rem;
}
/* ---- Responsive: Desktop enhancements ---- */
@media (min-width: 768px) {
.tech-stats-bar {
gap: 1rem;
}
.tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem;
}
.tech-stat-card .stat-number {
font-size: 2rem;
}
.tech-bottom-bar {
position: static;
box-shadow: none;
border: none;
padding: 0;
margin-top: 1rem;
}
.has-bottom-bar {
padding-bottom: 0;
}
.tech-timeline {
padding-left: 3rem;
}
.tech-timeline::before {
left: 1.25rem;
}
.tech-timeline-dot {
left: -2.05rem;
}
.tech-quick-btn {
min-width: 80px;
padding: 0.75rem 0.75rem;
}
}
/* ---- Legacy detail section support ---- */
.tech-detail-section {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 1rem;
margin-bottom: 1rem;
}
.tech-detail-section h6 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6c757d;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--o-main-border-color, #f1f3f5);
}
.tech-detail-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
}
.tech-detail-label {
font-weight: 500;
color: var(--o-main-text-color, #495057);
font-size: 0.9rem;
}
.tech-detail-value {
color: var(--o-main-text-color, #212529);
font-size: 0.9rem;
text-align: right;
}

View File

@@ -0,0 +1,109 @@
/**
* Fusion Authorizer Portal - Assessment Form
*/
odoo.define('fusion_authorizer_portal.assessment_form', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.AssessmentForm = publicWidget.Widget.extend({
selector: '#assessment-form',
events: {
'change input, change select, change textarea': '_onFieldChange',
'submit': '_onSubmit',
},
init: function () {
this._super.apply(this, arguments);
this.hasUnsavedChanges = false;
},
start: function () {
this._super.apply(this, arguments);
this._initializeForm();
return Promise.resolve();
},
_initializeForm: function () {
var self = this;
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', function (e) {
if (self.hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
return '';
}
});
// Auto-fill full name from first + last name
var firstNameInput = this.el.querySelector('[name="client_first_name"]');
var lastNameInput = this.el.querySelector('[name="client_last_name"]');
var fullNameInput = this.el.querySelector('[name="client_name"]');
if (firstNameInput && lastNameInput && fullNameInput) {
var updateFullName = function () {
var first = firstNameInput.value.trim();
var last = lastNameInput.value.trim();
if (first || last) {
fullNameInput.value = (first + ' ' + last).trim();
}
};
firstNameInput.addEventListener('blur', updateFullName);
lastNameInput.addEventListener('blur', updateFullName);
}
// Number input validation
var numberInputs = this.el.querySelectorAll('input[type="number"]');
numberInputs.forEach(function (input) {
input.addEventListener('input', function () {
var value = parseFloat(this.value);
var min = parseFloat(this.min) || 0;
var max = parseFloat(this.max) || 9999;
if (value < min) this.value = min;
if (value > max) this.value = max;
});
});
},
_onFieldChange: function (ev) {
this.hasUnsavedChanges = true;
// Visual feedback that form has changes
var saveBtn = this.el.querySelector('button[value="save"]');
if (saveBtn) {
saveBtn.classList.add('btn-warning');
saveBtn.classList.remove('btn-primary');
}
},
_onSubmit: function (ev) {
// Validate required fields
var requiredFields = this.el.querySelectorAll('[required]');
var isValid = true;
requiredFields.forEach(function (field) {
if (!field.value.trim()) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
});
if (!isValid) {
ev.preventDefault();
alert('Please fill in all required fields.');
return false;
}
this.hasUnsavedChanges = false;
return true;
}
});
return publicWidget.registry.AssessmentForm;
});

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
// Fusion Authorizer Portal - Message Authorizer Chatter Button
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
//
// Patches the Chatter component to add a "Message Authorizer" button
// that opens the mail composer targeted at the assigned authorizer.
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(Chatter.prototype, {
setup() {
super.setup(...arguments);
this._fapActionService = useService("action");
this._fapOrm = useService("orm");
},
async onClickMessageAuthorizer() {
const thread = this.state.thread;
if (!thread || thread.model !== "sale.order") return;
try {
const result = await this._fapOrm.call(
"sale.order",
"action_message_authorizer",
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
this._fapActionService.doAction(result);
}
} catch (e) {
console.warn("Message Authorizer action failed:", e);
}
},
});

View File

@@ -0,0 +1,478 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.LoanerPortal = publicWidget.Widget.extend({
selector: '#loanerSection, #btn_checkout_loaner, .btn-loaner-return',
start: function () {
this._super.apply(this, arguments);
this._allProducts = [];
this._initLoanerSection();
this._initCheckoutButton();
this._initReturnButtons();
this._initModal();
},
// =====================================================================
// MODAL: Initialize and wire up the loaner checkout modal
// =====================================================================
_initModal: function () {
var self = this;
var modal = document.getElementById('loanerCheckoutModal');
if (!modal) return;
var categorySelect = document.getElementById('modal_category_id');
var productSelect = document.getElementById('modal_product_id');
var lotSelect = document.getElementById('modal_lot_id');
var loanDays = document.getElementById('modal_loan_days');
var btnCheckout = document.getElementById('modal_btn_checkout');
var btnCreateProduct = document.getElementById('modal_btn_create_product');
var newCategorySelect = document.getElementById('modal_new_category_id');
var createResult = document.getElementById('modal_create_result');
// Load categories when modal opens
modal.addEventListener('show.bs.modal', function () {
self._loadCategories(categorySelect, newCategorySelect);
self._loadProducts(null, productSelect, lotSelect);
});
// Category change -> filter products
if (categorySelect) {
categorySelect.addEventListener('change', function () {
var catId = this.value ? parseInt(this.value) : null;
self._filterProducts(catId, productSelect, lotSelect);
});
}
// Product change -> filter lots
if (productSelect) {
productSelect.addEventListener('change', function () {
var prodId = this.value ? parseInt(this.value) : null;
self._filterLots(prodId, lotSelect, loanDays);
});
}
// Quick Create Product
if (btnCreateProduct) {
btnCreateProduct.addEventListener('click', function () {
var name = document.getElementById('modal_new_product_name').value.trim();
var serial = document.getElementById('modal_new_serial').value.trim();
var catId = newCategorySelect ? newCategorySelect.value : '';
if (!name || !serial) {
alert('Please enter both product name and serial number.');
return;
}
btnCreateProduct.disabled = true;
btnCreateProduct.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name,
serial_number: serial,
category_id: catId || null,
}).then(function (result) {
if (result.success) {
// Add to product dropdown
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
// Add to lots
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
// Add to internal data
self._allProducts.push({
id: result.product_id,
name: result.product_name,
category_id: catId ? parseInt(catId) : null,
period_days: 7,
lots: [{ id: result.lot_id, name: result.lot_name }],
});
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2"><i class="fa fa-check me-1"></i> "' + result.product_name + '" (S/N: ' + result.lot_name + ') created!</div>';
}
// Clear fields
document.getElementById('modal_new_product_name').value = '';
document.getElementById('modal_new_serial').value = '';
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreateProduct.disabled = false;
btnCreateProduct.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
// Checkout button
if (btnCheckout) {
btnCheckout.addEventListener('click', function () {
var productId = productSelect.value ? parseInt(productSelect.value) : null;
var lotId = lotSelect.value ? parseInt(lotSelect.value) : null;
var days = parseInt(loanDays.value) || 7;
var orderId = document.getElementById('modal_order_id').value;
var clientId = document.getElementById('modal_client_id').value;
if (!productId) {
alert('Please select a product.');
return;
}
btnCheckout.disabled = true;
btnCheckout.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/checkout', {
product_id: productId,
lot_id: lotId,
sale_order_id: orderId ? parseInt(orderId) : null,
client_id: clientId ? parseInt(clientId) : null,
loaner_period_days: days,
checkout_condition: 'good',
checkout_notes: '',
}).then(function (result) {
if (result.success) {
self._hideModal(modal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnCheckout.disabled = false;
btnCheckout.innerHTML = '<i class="fa fa-check me-1"></i> Checkout Loaner';
}
});
});
}
},
_loadCategories: function (categorySelect, newCategorySelect) {
this._rpc('/my/loaner/categories', {}).then(function (categories) {
categories = categories || [];
// Main category dropdown
if (categorySelect) {
categorySelect.innerHTML = '<option value="">All Categories</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
categorySelect.appendChild(opt);
});
}
// Quick create category dropdown
if (newCategorySelect) {
newCategorySelect.innerHTML = '<option value="">-- Select --</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
newCategorySelect.appendChild(opt);
});
}
});
},
_loadProducts: function (categoryId, productSelect, lotSelect) {
var self = this;
var params = {};
if (categoryId) params.category_id = categoryId;
this._rpc('/my/loaner/products', params).then(function (products) {
self._allProducts = products || [];
self._renderProducts(self._allProducts, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
});
},
_filterProducts: function (categoryId, productSelect, lotSelect) {
var filtered = this._allProducts;
if (categoryId) {
filtered = this._allProducts.filter(function (p) { return p.category_id === categoryId; });
}
this._renderProducts(filtered, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
},
_renderProducts: function (products, productSelect) {
if (!productSelect) return;
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
products.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
},
_filterLots: function (productId, lotSelect, loanDays) {
if (!lotSelect) return;
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
if (!productId) return;
var product = this._allProducts.find(function (p) { return p.id === productId; });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (loanDays && product.period_days) {
loanDays.value = product.period_days;
}
}
},
// =====================================================================
// CHECKOUT BUTTON: Opens the modal
// =====================================================================
_initCheckoutButton: function () {
var self = this;
var btns = document.querySelectorAll('#btn_checkout_loaner');
btns.forEach(function (btn) {
btn.addEventListener('click', function () {
var orderId = btn.dataset.orderId || '';
var clientId = btn.dataset.clientId || '';
// Set context in modal
var modalOrderId = document.getElementById('modal_order_id');
var modalClientId = document.getElementById('modal_client_id');
if (modalOrderId) modalOrderId.value = orderId;
if (modalClientId) modalClientId.value = clientId;
// Show modal
var modal = document.getElementById('loanerCheckoutModal');
self._showModal(modal);
});
});
},
// =====================================================================
// RETURN BUTTONS
// =====================================================================
_initReturnButtons: function () {
var self = this;
var returnModal = document.getElementById('loanerReturnModal');
if (!returnModal) return;
var btnSubmitReturn = document.getElementById('return_modal_btn_submit');
document.querySelectorAll('.btn-loaner-return').forEach(function (btn) {
btn.addEventListener('click', function () {
var checkoutId = parseInt(btn.dataset.checkoutId);
var productName = btn.dataset.productName || 'Loaner';
// Set modal values
document.getElementById('return_modal_checkout_id').value = checkoutId;
document.getElementById('return_modal_product_name').textContent = productName;
document.getElementById('return_modal_condition').value = 'good';
document.getElementById('return_modal_notes').value = '';
// Load locations
var locSelect = document.getElementById('return_modal_location_id');
locSelect.innerHTML = '<option value="">-- Loading... --</option>';
self._rpc('/my/loaner/locations', {}).then(function (locations) {
locations = locations || [];
locSelect.innerHTML = '<option value="">-- Select Location --</option>';
locations.forEach(function (l) {
var opt = document.createElement('option');
opt.value = l.id;
opt.text = l.name;
locSelect.appendChild(opt);
});
});
// Show modal
self._showModal(returnModal);
});
});
// Submit return
if (btnSubmitReturn) {
btnSubmitReturn.addEventListener('click', function () {
var checkoutId = parseInt(document.getElementById('return_modal_checkout_id').value);
var condition = document.getElementById('return_modal_condition').value;
var notes = document.getElementById('return_modal_notes').value;
var locationId = document.getElementById('return_modal_location_id').value;
btnSubmitReturn.disabled = true;
btnSubmitReturn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/return', {
checkout_id: checkoutId,
return_condition: condition,
return_notes: notes,
return_location_id: locationId ? parseInt(locationId) : null,
}).then(function (result) {
if (result.success) {
self._hideModal(returnModal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnSubmitReturn.disabled = false;
btnSubmitReturn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm Return';
}
});
});
}
},
// =====================================================================
// EXPRESS ASSESSMENT: Loaner Section
// =====================================================================
_initLoanerSection: function () {
var self = this;
var loanerSection = document.getElementById('loanerSection');
if (!loanerSection) return;
var productSelect = document.getElementById('loaner_product_id');
var lotSelect = document.getElementById('loaner_lot_id');
var periodInput = document.getElementById('loaner_period_days');
var checkoutFlag = document.getElementById('loaner_checkout');
var existingFields = document.getElementById('loaner_existing_fields');
var newFields = document.getElementById('loaner_new_fields');
var modeRadios = document.querySelectorAll('input[name="loaner_mode"]');
var btnCreate = document.getElementById('btn_create_loaner_product');
var createResult = document.getElementById('loaner_create_result');
var productsData = [];
loanerSection.addEventListener('show.bs.collapse', function () {
if (productSelect && productSelect.options.length <= 1) {
self._rpc('/my/loaner/products', {}).then(function (data) {
productsData = data || [];
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
productsData.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
});
}
});
loanerSection.addEventListener('shown.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '1';
});
loanerSection.addEventListener('hidden.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '0';
});
modeRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
if (this.value === 'existing') {
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
} else {
if (existingFields) existingFields.style.display = 'none';
if (newFields) newFields.style.display = '';
}
});
});
if (productSelect) {
productSelect.addEventListener('change', function () {
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
var product = productsData.find(function (p) { return p.id === parseInt(productSelect.value); });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (periodInput && product.period_days) periodInput.value = product.period_days;
}
});
}
if (btnCreate) {
btnCreate.addEventListener('click', function () {
var name = document.getElementById('loaner_new_product_name').value.trim();
var serial = document.getElementById('loaner_new_serial').value.trim();
if (!name || !serial) { alert('Enter both name and serial.'); return; }
btnCreate.disabled = true;
btnCreate.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name, serial_number: serial,
}).then(function (result) {
if (result.success) {
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
document.getElementById('loaner_existing').checked = true;
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2">Created "' + result.product_name + '" (S/N: ' + result.lot_name + ')</div>';
}
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreate.disabled = false;
btnCreate.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
},
// =====================================================================
// HELPERS
// =====================================================================
_rpc: function (url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params }),
}).then(function (r) { return r.json(); }).then(function (d) { return d.result; });
},
_showModal: function (modalEl) {
if (!modalEl) return;
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal) {
var inst = Modal.getOrCreateInstance ? Modal.getOrCreateInstance(modalEl) : new Modal(modalEl);
inst.show();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('show');
}
},
_hideModal: function (modalEl) {
if (!modalEl) return;
try {
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal && Modal.getInstance) {
var inst = Modal.getInstance(modalEl);
if (inst) inst.hide();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('hide');
}
} catch (e) { /* non-blocking */ }
},
});

View File

@@ -0,0 +1,626 @@
/**
* Fusion PDF Field Position Editor
*
* Features:
* - Drag field types from sidebar palette onto PDF to create new fields
* - Drag existing fields to reposition them
* - Resize handles on each field (bottom-right corner)
* - Click to select and edit properties in right panel
* - Percentage-based positions (0.0-1.0), same as Odoo Sign module
* - Auto-save on every drag/resize
*/
document.addEventListener('DOMContentLoaded', function () {
'use strict';
var editor = document.getElementById('pdf_field_editor');
if (!editor) return;
var templateId = parseInt(editor.dataset.templateId);
var pageCount = parseInt(editor.dataset.pageCount) || 1;
var currentPage = 1;
var fields = {};
var selectedFieldId = null;
var fieldCounter = 0;
var container = document.getElementById('pdf_canvas_container');
var pageImage = document.getElementById('pdf_page_image');
// ================================================================
// Colors per field type
// ================================================================
// ================================================================
// Available data keys (grouped for the dropdown)
// ================================================================
var DATA_KEYS = [
{ group: 'Client Info', keys: [
{ key: 'client_last_name', label: 'Last Name' },
{ key: 'client_first_name', label: 'First Name' },
{ key: 'client_middle_name', label: 'Middle Name' },
{ key: 'client_name', label: 'Full Name' },
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_street', label: 'Street' },
{ key: 'client_unit', label: 'Unit/Apt' },
{ key: 'client_city', label: 'City' },
{ key: 'client_state', label: 'Province' },
{ key: 'client_postal_code', label: 'Postal Code' },
{ key: 'client_phone', label: 'Phone' },
{ key: 'client_email', label: 'Email' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'Client Type', keys: [
{ key: 'client_type_reg', label: 'REG Checkbox' },
{ key: 'client_type_ods', label: 'ODS Checkbox' },
{ key: 'client_type_acs', label: 'ACS Checkbox' },
{ key: 'client_type_owp', label: 'OWP Checkbox' },
]},
{ group: 'Consent', keys: [
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
{ key: 'consent_agent', label: 'Agent Checkbox' },
{ key: 'consent_date', label: 'Consent Date' },
]},
{ group: 'Agent Relationship', keys: [
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
{ key: 'agent_rel_child', label: 'Child Checkbox' },
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
]},
{ group: 'Agent Info', keys: [
{ key: 'agent_last_name', label: 'Agent Last Name' },
{ key: 'agent_first_name', label: 'Agent First Name' },
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
{ key: 'agent_unit', label: 'Agent Unit' },
{ key: 'agent_street_number', label: 'Agent Street No.' },
{ key: 'agent_street_name', label: 'Agent Street Name' },
{ key: 'agent_city', label: 'Agent City' },
{ key: 'agent_province', label: 'Agent Province' },
{ key: 'agent_postal_code', label: 'Agent Postal Code' },
{ key: 'agent_home_phone', label: 'Agent Home Phone' },
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
]},
{ group: 'Equipment', keys: [
{ key: 'equipment_type', label: 'Equipment Type' },
{ key: 'seat_width', label: 'Seat Width' },
{ key: 'seat_depth', label: 'Seat Depth' },
{ key: 'seat_to_floor_height', label: 'Seat to Floor Height' },
{ key: 'back_height', label: 'Back Height' },
{ key: 'legrest_length', label: 'Legrest Length' },
{ key: 'cane_height', label: 'Cane Height' },
]},
{ group: 'Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
{ key: 'claim_authorization_date', label: 'Authorization Date' },
]},
{ group: 'Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
{ group: 'Signatures', keys: [
{ key: 'signature_page_11', label: 'Page 11 Signature' },
{ key: 'signature_page_12', label: 'Page 12 Signature' },
]},
{ group: 'Other', keys: [
{ key: 'reference', label: 'Assessment Reference' },
{ key: 'reason_for_application', label: 'Reason for Application' },
]},
];
// Build a flat lookup: key -> label
var KEY_LABELS = {};
DATA_KEYS.forEach(function (g) {
g.keys.forEach(function (k) { KEY_LABELS[k.key] = k.label; });
});
// Build <option> HTML for the data key dropdown
function buildDataKeyOptions(selectedKey) {
var html = '<option value="">(custom / none)</option>';
DATA_KEYS.forEach(function (g) {
html += '<optgroup label="' + g.group + '">';
g.keys.forEach(function (k) {
html += '<option value="' + k.key + '"'
+ (k.key === selectedKey ? ' selected' : '')
+ '>' + k.label + ' (' + k.key + ')</option>';
});
html += '</optgroup>';
});
return html;
}
var COLORS = {
text: { bg: 'rgba(52,152,219,0.25)', border: '#3498db' },
checkbox: { bg: 'rgba(46,204,113,0.25)', border: '#2ecc71' },
date: { bg: 'rgba(230,126,34,0.25)', border: '#e67e22' },
signature: { bg: 'rgba(155,89,182,0.25)', border: '#9b59b6' },
};
var DEFAULT_SIZES = {
text: { w: 0.150, h: 0.018 },
checkbox: { w: 0.018, h: 0.018 },
date: { w: 0.120, h: 0.018 },
signature: { w: 0.200, h: 0.050 },
};
// ================================================================
// JSONRPC helper
// ================================================================
function jsonrpc(url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params || {} })
}).then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { console.error('RPC error', d.error); return null; }
return d.result;
});
}
// ================================================================
// Init
// ================================================================
function init() {
loadFields();
setupPageNavigation();
setupPaletteDrag();
setupContainerDrop();
setupPreviewButton();
// Prevent the image from intercepting drag events
if (pageImage) {
pageImage.style.pointerEvents = 'none';
}
// Also prevent any child (except field markers) from blocking drops
container.querySelectorAll('#no_preview_placeholder').forEach(function (el) {
el.style.pointerEvents = 'none';
});
}
// ================================================================
// Load fields
// ================================================================
function loadFields() {
jsonrpc('/fusion/pdf-editor/fields', { template_id: templateId }).then(function (result) {
if (!result) return;
fields = {};
result.forEach(function (f) { fields[f.id] = f; fieldCounter++; });
renderFieldsForPage(currentPage);
});
}
// ================================================================
// Render fields on current page
// ================================================================
function renderFieldsForPage(page) {
container.querySelectorAll('.pdf-field-marker').forEach(function (el) { el.remove(); });
Object.values(fields).forEach(function (f) {
if (f.page === page) renderFieldMarker(f);
});
updateFieldCount();
}
function renderFieldMarker(field) {
var c = COLORS[field.field_type] || COLORS.text;
var marker = document.createElement('div');
marker.className = 'pdf-field-marker';
marker.dataset.fieldId = field.id;
marker.setAttribute('draggable', 'true');
Object.assign(marker.style, {
position: 'absolute',
left: (field.pos_x * 100) + '%',
top: (field.pos_y * 100) + '%',
width: (field.width * 100) + '%',
height: Math.max(field.height * 100, 1.5) + '%',
backgroundColor: c.bg,
border: '2px solid ' + c.border,
borderRadius: '3px',
cursor: 'move',
display: 'flex',
alignItems: 'center',
fontSize: '10px',
color: '#333',
fontWeight: '600',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
padding: '0 4px',
zIndex: 10,
boxSizing: 'border-box',
userSelect: 'none',
});
// Label text
var label = document.createElement('span');
label.style.pointerEvents = 'none';
label.style.flex = '1';
label.style.overflow = 'hidden';
label.style.textOverflow = 'ellipsis';
label.textContent = field.label || field.name;
marker.appendChild(label);
// Resize handle (bottom-right corner)
var handle = document.createElement('div');
Object.assign(handle.style, {
position: 'absolute',
right: '0',
bottom: '0',
width: '10px',
height: '10px',
backgroundColor: c.border,
cursor: 'nwse-resize',
borderRadius: '2px 0 2px 0',
opacity: '0.7',
});
handle.className = 'resize-handle';
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
startResize(field.id, e);
});
marker.appendChild(handle);
// Tooltip
marker.title = (field.label || field.name) + '\nKey: ' + (field.field_key || 'unmapped') + '\nType: ' + field.field_type;
// Drag to reposition
marker.addEventListener('dragstart', function (e) { onFieldDragStart(e, field.id); });
marker.addEventListener('dragend', function (e) { e.target.style.opacity = ''; });
// Click to select
marker.addEventListener('click', function (e) {
e.stopPropagation();
selectField(field.id);
});
container.appendChild(marker);
// Highlight if selected
if (field.id === selectedFieldId) {
marker.style.boxShadow = '0 0 0 3px #007bff';
marker.style.zIndex = '20';
}
}
// ================================================================
// Drag existing fields to reposition
// ================================================================
var dragOffsetX = 0, dragOffsetY = 0;
var dragFieldId = null;
var dragSource = null; // 'field' or 'palette'
var dragFieldType = null;
function onFieldDragStart(e, fieldId) {
dragSource = 'field';
dragFieldId = fieldId;
var rect = e.target.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'field');
requestAnimationFrame(function () { e.target.style.opacity = '0.4'; });
}
// ================================================================
// Drag from palette to create new field
// ================================================================
function setupPaletteDrag() {
document.querySelectorAll('.pdf-palette-item').forEach(function (item) {
item.addEventListener('dragstart', function (e) {
dragSource = 'palette';
dragFieldType = e.currentTarget.dataset.fieldType;
dragFieldId = null;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', 'palette');
e.currentTarget.style.opacity = '0.5';
});
item.addEventListener('dragend', function (e) {
e.currentTarget.style.opacity = '';
});
});
}
// ================================================================
// Drop handler on PDF container
// ================================================================
function setupContainerDrop() {
// Must preventDefault on dragover for drop to fire
container.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = (dragSource === 'palette') ? 'copy' : 'move';
});
container.addEventListener('dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
});
container.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
// Use the container rect as the reference area
// (the image has pointer-events:none, so we use the container which matches its size)
var rect = container.getBoundingClientRect();
if (dragSource === 'palette' && dragFieldType) {
// ---- CREATE new field at drop position ----
var defaults = DEFAULT_SIZES[dragFieldType] || DEFAULT_SIZES.text;
var posX = (e.clientX - rect.left) / rect.width;
var posY = (e.clientY - rect.top) / rect.height;
posX = normalize(posX, defaults.w);
posY = normalize(posY, defaults.h);
posX = round3(posX);
posY = round3(posY);
fieldCounter++;
var autoName = dragFieldType + '_' + fieldCounter;
var newField = {
template_id: templateId,
name: autoName,
label: autoName,
field_type: dragFieldType,
field_key: autoName,
page: currentPage,
pos_x: posX,
pos_y: posY,
width: defaults.w,
height: defaults.h,
font_size: 10,
};
jsonrpc('/fusion/pdf-editor/create-field', newField).then(function (res) {
if (res && res.id) {
newField.id = res.id;
fields[res.id] = newField;
renderFieldsForPage(currentPage);
selectField(res.id);
}
});
} else if (dragSource === 'field' && dragFieldId && fields[dragFieldId]) {
// ---- MOVE existing field ----
var field = fields[dragFieldId];
var posX = (e.clientX - rect.left - dragOffsetX) / rect.width;
var posY = (e.clientY - rect.top - dragOffsetY) / rect.height;
posX = normalize(posX, field.width);
posY = normalize(posY, field.height);
posX = round3(posX);
posY = round3(posY);
field.pos_x = posX;
field.pos_y = posY;
saveField(field.id, { pos_x: posX, pos_y: posY });
renderFieldsForPage(currentPage);
selectField(field.id);
}
dragSource = null;
dragFieldId = null;
dragFieldType = null;
});
}
// ================================================================
// Resize handles
// ================================================================
function startResize(fieldId, startEvent) {
var field = fields[fieldId];
if (!field) return;
var imgRect = container.getBoundingClientRect();
var startX = startEvent.clientX;
var startY = startEvent.clientY;
var startW = field.width;
var startH = field.height;
var marker = container.querySelector('[data-field-id="' + fieldId + '"]');
function onMove(e) {
var dx = (e.clientX - startX) / imgRect.width;
var dy = (e.clientY - startY) / imgRect.height;
var newW = Math.max(startW + dx, 0.010);
var newH = Math.max(startH + dy, 0.005);
// Clamp to page bounds
if (field.pos_x + newW > 1.0) newW = 1.0 - field.pos_x;
if (field.pos_y + newH > 1.0) newH = 1.0 - field.pos_y;
field.width = round3(newW);
field.height = round3(newH);
if (marker) {
marker.style.width = (field.width * 100) + '%';
marker.style.height = Math.max(field.height * 100, 1.5) + '%';
}
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveField(fieldId, { width: field.width, height: field.height });
renderFieldsForPage(currentPage);
selectField(fieldId);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ================================================================
// Select field and show properties
// ================================================================
function selectField(fieldId) {
selectedFieldId = fieldId;
var field = fields[fieldId];
if (!field) return;
// Re-render to update highlights
renderFieldsForPage(currentPage);
var panel = document.getElementById('field_props_body');
panel.innerHTML = ''
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Data Key</label>'
+ ' <select class="form-select form-select-sm" id="prop_field_key">'
+ buildDataKeyOptions(field.field_key || '')
+ ' </select>'
+ '</div>'
+ row('Name', 'text', 'prop_name', field.name || '')
+ row('Label', 'text', 'prop_label', field.label || '')
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Type</label>'
+ ' <select class="form-select form-select-sm" id="prop_type">'
+ ' <option value="text"' + sel(field.field_type, 'text') + '>Text</option>'
+ ' <option value="checkbox"' + sel(field.field_type, 'checkbox') + '>Checkbox</option>'
+ ' <option value="signature"' + sel(field.field_type, 'signature') + '>Signature</option>'
+ ' <option value="date"' + sel(field.field_type, 'date') + '>Date</option>'
+ ' </select>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Font Size', 'number', 'prop_font_size', field.font_size || 10, '0.5') + '</div>'
+ ' <div class="col-6">' + row('Page', 'number', 'prop_page', field.page || 1) + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Width', 'number', 'prop_width', field.width || 0.15, '0.005') + '</div>'
+ ' <div class="col-6">' + row('Height', 'number', 'prop_height', field.height || 0.015, '0.005') + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">X</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_x) + '" readonly/></div>'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">Y</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_y) + '" readonly/></div>'
+ '</div>'
+ '<button type="button" class="btn btn-primary btn-sm w-100 mb-2" id="btn_save_props">'
+ ' <i class="fa fa-save me-1"/>Save</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm w-100" id="btn_delete_field">'
+ ' <i class="fa fa-trash me-1"/>Delete</button>';
// Auto-fill name and label when data key is selected
document.getElementById('prop_field_key').addEventListener('change', function () {
var selectedKey = this.value;
if (selectedKey && KEY_LABELS[selectedKey]) {
document.getElementById('prop_name').value = selectedKey;
document.getElementById('prop_label').value = KEY_LABELS[selectedKey];
}
});
document.getElementById('btn_save_props').addEventListener('click', function () {
var keySelect = document.getElementById('prop_field_key');
var selectedKey = keySelect ? keySelect.value : '';
var vals = {
name: val('prop_name'),
label: val('prop_label'),
field_key: selectedKey,
field_type: val('prop_type'),
font_size: parseFloat(val('prop_font_size')) || 10,
page: parseInt(val('prop_page')) || 1,
width: parseFloat(val('prop_width')) || 0.15,
height: parseFloat(val('prop_height')) || 0.015,
};
Object.assign(field, vals);
saveField(fieldId, vals);
renderFieldsForPage(currentPage);
selectField(fieldId);
});
document.getElementById('btn_delete_field').addEventListener('click', function () {
if (!confirm('Delete "' + (field.label || field.name) + '"?')) return;
jsonrpc('/fusion/pdf-editor/delete-field', { field_id: fieldId }).then(function () {
delete fields[fieldId];
selectedFieldId = null;
renderFieldsForPage(currentPage);
panel.innerHTML = '<p class="text-muted small">Field deleted.</p>';
});
});
}
// ================================================================
// Save field to server
// ================================================================
function saveField(fieldId, values) {
jsonrpc('/fusion/pdf-editor/update-field', { field_id: fieldId, values: values });
}
// ================================================================
// Page navigation
// ================================================================
function setupPageNavigation() {
var prev = document.getElementById('btn_prev_page');
var next = document.getElementById('btn_next_page');
if (prev) prev.addEventListener('click', function () { if (currentPage > 1) switchPage(--currentPage); });
if (next) next.addEventListener('click', function () { if (currentPage < pageCount) switchPage(++currentPage); });
}
function switchPage(page) {
currentPage = page;
var d = document.getElementById('current_page_display');
if (d) d.textContent = page;
jsonrpc('/fusion/pdf-editor/page-image', { template_id: templateId, page: page }).then(function (r) {
if (r && r.image_url && pageImage) pageImage.src = r.image_url;
renderFieldsForPage(page);
});
}
// ================================================================
// Preview
// ================================================================
function setupPreviewButton() {
var btn = document.getElementById('btn_preview');
if (btn) btn.addEventListener('click', function () {
window.open('/fusion/pdf-editor/preview/' + templateId, '_blank');
});
}
// ================================================================
// Helpers
// ================================================================
function normalize(pos, dim) {
if (pos < 0) return 0;
if (pos + dim > 1.0) return 1.0 - dim;
return pos;
}
function round3(n) { return Math.round((n || 0) * 1000) / 1000; }
function val(id) { var el = document.getElementById(id); return el ? el.value : ''; }
function sel(current, option) { return current === option ? ' selected' : ''; }
function row(label, type, id, value, step) {
return '<div class="mb-2"><label class="form-label fw-bold small mb-0">' + label + '</label>'
+ '<input type="' + type + '" class="form-control form-control-sm" id="' + id + '"'
+ ' value="' + value + '"' + (step ? ' step="' + step + '"' : '') + '/></div>';
}
function updateFieldCount() {
var el = document.getElementById('field_count');
if (el) el.textContent = Object.keys(fields).length;
}
// ================================================================
// Start
// ================================================================
init();
});

View File

@@ -0,0 +1,161 @@
/**
* Fusion Authorizer Portal - Real-time Search
*/
odoo.define('fusion_authorizer_portal.portal_search', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
publicWidget.registry.PortalSearch = publicWidget.Widget.extend({
selector: '#portal-search-input',
events: {
'input': '_onSearchInput',
'keydown': '_onKeyDown',
},
init: function () {
this._super.apply(this, arguments);
this.debounceTimer = null;
this.searchEndpoint = this._getSearchEndpoint();
this.resultsContainer = null;
},
start: function () {
this._super.apply(this, arguments);
this.resultsContainer = document.getElementById('cases-table-body');
return Promise.resolve();
},
_getSearchEndpoint: function () {
// Determine which portal we're on
var path = window.location.pathname;
if (path.includes('/my/authorizer')) {
return '/my/authorizer/cases/search';
} else if (path.includes('/my/sales')) {
return '/my/sales/cases/search';
}
return null;
},
_onSearchInput: function (ev) {
var self = this;
var query = ev.target.value.trim();
clearTimeout(this.debounceTimer);
if (query.length < 2) {
// If query is too short, reload original page
return;
}
// Debounce - wait 250ms before searching
this.debounceTimer = setTimeout(function () {
self._performSearch(query);
}, 250);
},
_onKeyDown: function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
var query = ev.target.value.trim();
if (query.length >= 2) {
this._performSearch(query);
}
} else if (ev.key === 'Escape') {
ev.target.value = '';
window.location.reload();
}
},
_performSearch: function (query) {
var self = this;
if (!this.searchEndpoint) {
console.error('Search endpoint not found');
return;
}
// Show loading indicator
this._showLoading(true);
ajax.jsonRpc(this.searchEndpoint, 'call', {
query: query
}).then(function (response) {
self._showLoading(false);
if (response.error) {
console.error('Search error:', response.error);
return;
}
self._renderResults(response.results, query);
}).catch(function (error) {
self._showLoading(false);
console.error('Search failed:', error);
});
},
_showLoading: function (show) {
var spinner = document.querySelector('.search-loading');
if (spinner) {
spinner.classList.toggle('active', show);
}
},
_renderResults: function (results, query) {
if (!this.resultsContainer) {
return;
}
if (!results || results.length === 0) {
this.resultsContainer.innerHTML = `
<tr>
<td colspan="6" class="text-center py-4">
<i class="fa fa-search fa-2x text-muted mb-2"></i>
<p class="text-muted mb-0">No results found for "${query}"</p>
</td>
</tr>
`;
return;
}
var html = '';
var isAuthorizer = window.location.pathname.includes('/my/authorizer');
var baseUrl = isAuthorizer ? '/my/authorizer/case/' : '/my/sales/case/';
results.forEach(function (order) {
var stateClass = 'bg-secondary';
if (order.state === 'sent') stateClass = 'bg-primary';
else if (order.state === 'sale') stateClass = 'bg-success';
html += `
<tr class="search-result-row">
<td>${self._highlightMatch(order.name, query)}</td>
<td>${self._highlightMatch(order.partner_name, query)}</td>
<td>${order.date_order}</td>
<td>${self._highlightMatch(order.claim_number || '-', query)}</td>
<td><span class="badge ${stateClass}">${order.state_display}</span></td>
<td>
<a href="${baseUrl}${order.id}" class="btn btn-sm btn-primary">
<i class="fa fa-eye me-1"></i>View
</a>
</td>
</tr>
`;
});
this.resultsContainer.innerHTML = html;
},
_highlightMatch: function (text, query) {
if (!text || !query) return text || '';
var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
});
return publicWidget.registry.PortalSearch;
});

View File

@@ -0,0 +1,167 @@
/**
* Fusion Authorizer Portal - Signature Pad
* Touch-enabled digital signature capture
*/
odoo.define('fusion_authorizer_portal.signature_pad', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
// Signature Pad Class
var SignaturePad = function (canvas, options) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = Object.assign({
strokeColor: '#000000',
strokeWidth: 2,
backgroundColor: '#ffffff'
}, options || {});
this.isDrawing = false;
this.lastX = 0;
this.lastY = 0;
this.points = [];
this._initialize();
};
SignaturePad.prototype = {
_initialize: function () {
var self = this;
// Set canvas size
this._resizeCanvas();
// Set drawing style
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// Clear with background color
this.clear();
// Event listeners
this.canvas.addEventListener('mousedown', this._startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this._draw.bind(this));
this.canvas.addEventListener('mouseup', this._stopDrawing.bind(this));
this.canvas.addEventListener('mouseout', this._stopDrawing.bind(this));
// Touch events
this.canvas.addEventListener('touchstart', this._startDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchmove', this._draw.bind(this), { passive: false });
this.canvas.addEventListener('touchend', this._stopDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchcancel', this._stopDrawing.bind(this), { passive: false });
// Resize handler
window.addEventListener('resize', this._resizeCanvas.bind(this));
},
_resizeCanvas: function () {
var rect = this.canvas.getBoundingClientRect();
var ratio = window.devicePixelRatio || 1;
this.canvas.width = rect.width * ratio;
this.canvas.height = rect.height * ratio;
this.ctx.scale(ratio, ratio);
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
// Restore drawing style after resize
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
},
_getPos: function (e) {
var rect = this.canvas.getBoundingClientRect();
var x, y;
if (e.touches && e.touches.length > 0) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
return { x: x, y: y };
},
_startDrawing: function (e) {
e.preventDefault();
this.isDrawing = true;
var pos = this._getPos(e);
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y, start: true });
},
_draw: function (e) {
e.preventDefault();
if (!this.isDrawing) return;
var pos = this._getPos(e);
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y });
},
_stopDrawing: function (e) {
e.preventDefault();
this.isDrawing = false;
},
clear: function () {
this.ctx.fillStyle = this.options.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.points = [];
},
isEmpty: function () {
return this.points.length === 0;
},
toDataURL: function (type, quality) {
return this.canvas.toDataURL(type || 'image/png', quality || 1.0);
}
};
// Make SignaturePad available globally for inline scripts
window.SignaturePad = SignaturePad;
// Widget for signature pads in portal
publicWidget.registry.SignaturePadWidget = publicWidget.Widget.extend({
selector: '.signature-pad-container',
start: function () {
this._super.apply(this, arguments);
var canvas = this.el.querySelector('canvas');
if (canvas) {
this.signaturePad = new SignaturePad(canvas);
}
return Promise.resolve();
},
getSignaturePad: function () {
return this.signaturePad;
}
});
return {
SignaturePad: SignaturePad,
Widget: publicWidget.registry.SignaturePadWidget
};
});

View File

@@ -0,0 +1,97 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var locationTimer = null;
function isWorkingHours() {
var now = new Date();
var hour = now.getHours();
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
}
function isTechnicianPortal() {
// Check if we're on a technician portal page
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function logLocation() {
if (!isWorkingHours()) {
return;
}
if (document.hidden) {
return;
}
if (!navigator.geolocation) {
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
jsonrpc: '2.0',
method: 'call',
params: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
}
};
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(function () {
// Silently fail - location logging is best-effort
});
},
function () {
// Geolocation permission denied or error - silently ignore
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
logLocation();
// Set interval for periodic logging
locationTimer = setInterval(logLocation, INTERVAL_MS);
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
// Tab hidden - clear interval to save battery
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {
startLocationLogging();
}
})();

View File

@@ -0,0 +1,96 @@
/**
* Fusion Technician Portal - Push Notification Registration
* Registers service worker and subscribes to push notifications.
* Include this script on technician portal pages.
*/
(function() {
'use strict';
// Only run on technician portal pages
if (!document.querySelector('.tech-portal') && !window.location.pathname.startsWith('/my/technician')) {
return;
}
// Get VAPID public key from meta tag or page data
var vapidMeta = document.querySelector('meta[name="vapid-public-key"]');
var vapidPublicKey = vapidMeta ? vapidMeta.content : null;
if (!vapidPublicKey || !('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
function urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var rawData = window.atob(base64);
var outputArray = new Uint8Array(rawData.length);
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function registerPushSubscription() {
try {
// Register service worker
var registration = await navigator.serviceWorker.register(
'/fusion_authorizer_portal/static/src/js/technician_sw.js',
{scope: '/my/technician/'}
);
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
// Check existing subscription
var subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Request permission
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('[TechPush] Notification permission denied');
return;
}
// Subscribe
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
// Send subscription to server
var key = subscription.getKey('p256dh');
var auth = subscription.getKey('auth');
var response = await fetch('/my/technician/push/subscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
endpoint: subscription.endpoint,
p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(key))),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(auth))),
}
}),
});
var data = await response.json();
if (data.result && data.result.success) {
console.log('[TechPush] Push subscription registered successfully');
}
} catch (error) {
console.warn('[TechPush] Push registration failed:', error);
}
}
// Register after page load
if (document.readyState === 'complete') {
registerPushSubscription();
} else {
window.addEventListener('load', registerPushSubscription);
}
})();

View File

@@ -0,0 +1,77 @@
/**
* Fusion Technician Portal - Service Worker for Push Notifications
* Handles push events and notification clicks.
*/
self.addEventListener('push', function(event) {
if (!event.data) return;
var data;
try {
data = event.data.json();
} catch (e) {
data = {title: 'New Notification', body: event.data.text()};
}
var options = {
body: data.body || '',
icon: '/fusion_authorizer_portal/static/description/icon.png',
badge: '/fusion_authorizer_portal/static/description/icon.png',
tag: 'tech-task-' + (data.task_id || 'general'),
renotify: true,
data: {
url: data.url || '/my/technician',
taskId: data.task_id,
taskType: data.task_type,
},
actions: [],
};
// Add contextual actions based on task type
if (data.url) {
options.actions.push({action: 'view', title: 'View Task'});
}
if (data.task_type === 'delivery' || data.task_type === 'repair') {
options.actions.push({action: 'navigate', title: 'Navigate'});
}
event.waitUntil(
self.registration.showNotification(data.title || 'Fusion Technician', options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = '/my/technician';
if (event.notification.data && event.notification.data.url) {
url = event.notification.data.url;
}
if (event.action === 'navigate' && event.notification.data && event.notification.data.taskId) {
// Open Google Maps for the task (will redirect through portal)
url = '/my/technician/task/' + event.notification.data.taskId;
}
event.waitUntil(
clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(clientList) {
// Focus existing window if open
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url.indexOf('/my/technician') !== -1 && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Keep service worker alive
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Fusion Authorizer Portal - Message Authorizer Chatter Button
Copyright 2026 Nexa Systems Inc.
License OPL-1
Adds a "Message Authorizer" icon button to the chatter topbar
on sale.order forms (after the Activity button).
-->
<templates xml:space="preserve">
<t t-inherit="mail.Chatter" t-inherit-mode="extension">
<!-- Insert after the Activity button -->
<xpath expr="//button[hasclass('o-mail-Chatter-activity')]" position="after">
<button t-if="state.thread and state.thread.model === 'sale.order'"
class="o-mail-Chatter-messageAuthorizer btn btn-secondary text-nowrap me-1"
t-att-class="{ 'my-2': !props.compactHeight }"
t-on-click="onClickMessageAuthorizer"
title="Message Authorizer">
<span>Message Authorizer</span>
</button>
</xpath>
</t>
</templates>