Initial commit
This commit is contained in:
BIN
fusion_authorizer_portal/static/description/icon.png
Normal file
BIN
fusion_authorizer_portal/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
864
fusion_authorizer_portal/static/src/css/portal_style.css
Normal file
864
fusion_authorizer_portal/static/src/css/portal_style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
540
fusion_authorizer_portal/static/src/css/technician_portal.css
Normal file
540
fusion_authorizer_portal/static/src/css/technician_portal.css
Normal 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;
|
||||
}
|
||||
109
fusion_authorizer_portal/static/src/js/assessment_form.js
Normal file
109
fusion_authorizer_portal/static/src/js/assessment_form.js
Normal 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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
478
fusion_authorizer_portal/static/src/js/loaner_portal.js
Normal file
478
fusion_authorizer_portal/static/src/js/loaner_portal.js
Normal 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 */ }
|
||||
},
|
||||
});
|
||||
626
fusion_authorizer_portal/static/src/js/pdf_field_editor.js
Normal file
626
fusion_authorizer_portal/static/src/js/pdf_field_editor.js
Normal 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();
|
||||
});
|
||||
161
fusion_authorizer_portal/static/src/js/portal_search.js
Normal file
161
fusion_authorizer_portal/static/src/js/portal_search.js
Normal 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;
|
||||
});
|
||||
167
fusion_authorizer_portal/static/src/js/signature_pad.js
Normal file
167
fusion_authorizer_portal/static/src/js/signature_pad.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
96
fusion_authorizer_portal/static/src/js/technician_push.js
Normal file
96
fusion_authorizer_portal/static/src/js/technician_push.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
77
fusion_authorizer_portal/static/src/js/technician_sw.js
Normal file
77
fusion_authorizer_portal/static/src/js/technician_sw.js
Normal 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());
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user