This commit is contained in:
gsinghpal
2026-02-27 14:32:32 -05:00
parent b649246e81
commit b925766966
80 changed files with 7831 additions and 1041 deletions

View File

@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
posting_info = self._get_adp_posting_info()
response.qcontext.update(posting_info)
response.qcontext.update(self._get_clock_status_data())
# Add signature count (documents to sign) - only if Sign module is installed
sign_count = 0
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
'sale_type_filter': sale_type,
'status_filter': status,
}
values.update(self._get_clock_status_data())
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error downloading proof of delivery: {e}")
return request.redirect('/my/funding-claims')
# ==================== CLOCK STATUS HELPER ====================
def _get_clock_status_data(self):
"""Get clock in/out status for the current portal user."""
try:
user = request.env.user
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('user_id', '=', user.id)], limit=1)
if not employee:
employee = Employee.search([
('name', '=', user.partner_id.name),
('user_id', '=', False),
], limit=1)
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
return {'clock_enabled': False}
is_checked_in = employee.attendance_state == 'checked_in'
check_in_time = ''
location_name = ''
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '=', False),
], limit=1)
if att:
check_in_time = att.check_in.isoformat() if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
return {
'clock_enabled': True,
'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time,
'clock_location_name': location_name,
}
except Exception as e:
_logger.warning("Clock status check failed: %s", e)
return {'clock_enabled': False}
# ==================== TECHNICIAN PORTAL ====================
def _check_technician_access(self):
"""Check if current user is a technician portal user."""
partner = request.env.user.partner_id
if not partner.is_technician_portal:
return False
return True
if partner.is_technician_portal:
return True
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
'|',
('technician_id', '=', request.env.user.id),
('additional_technician_ids', 'in', [request.env.user.id]),
], limit=1)
if has_tasks:
partner.sudo().write({'is_technician_portal': True})
return True
return False
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
def technician_dashboard(self, **kw):
@@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
clock_data = self._get_clock_status_data()
values = {
'today_tasks': today_tasks,
'current_task': current_task,
@@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key,
'page_name': 'technician_dashboard',
}
values.update(clock_data)
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
@@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
def technician_task_action(self, task_id, action, **kw):
"""Handle task status changes (start, complete, en_route, cancel)."""
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
"""Handle task status changes (start, complete, en_route, cancel).
Location is mandatory -- the client must send GPS coordinates."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
@@ -1439,21 +1495,32 @@ class AuthorizerPortal(CustomerPortal):
):
return {'success': False, 'error': 'Task not found or not assigned to you'}
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
if action == 'en_route':
task.action_start_en_route()
task.with_context(**location_ctx).action_start_en_route()
elif action == 'start':
task.action_start_task()
task.with_context(**location_ctx).action_start_task()
elif action == 'complete':
completion_notes = kw.get('completion_notes', '')
if completion_notes:
task.completion_notes = completion_notes
task.action_complete_task()
task.with_context(**location_ctx).action_complete_task()
elif action == 'cancel':
task.action_cancel_task()
task.with_context(**location_ctx).action_cancel_task()
else:
return {'success': False, 'error': f'Unknown action: {action}'}
# For completion, also return next task info
result = {
'success': True,
'status': task.status,
@@ -1600,10 +1667,14 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
def technician_voice_complete(self, task_id, transcription, **kw):
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
"""Format transcription with GPT and complete the task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
@@ -1675,7 +1746,18 @@ class AuthorizerPortal(CustomerPortal):
'completion_notes': completion_html,
'voice_note_transcription': transcription,
})
task.action_complete_task()
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
task.with_context(**location_ctx).action_complete_task()
return {
'success': True,

View File

@@ -499,6 +499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current',
}
@@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model):
'name': _('Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('assessment_id', '=', self.id)],
'context': {'default_assessment_id': self.id},
}
@@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model):
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'current',
}

View File

@@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model):
'type': 'ir.actions.act_window',
'res_model': 'fusion.assessment',
'view_mode': 'form',
'views': [(False, 'form')],
'res_id': self.assessment_id.id,
}

View File

@@ -596,6 +596,7 @@ class ResPartner(models.Model):
'name': _('Assigned Cases'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_authorizer_id', '=', self.id)],
'context': {'default_x_fc_authorizer_id': self.id},
}
@@ -614,6 +615,7 @@ class ResPartner(models.Model):
'name': _('Assessments'),
'res_model': 'fusion.assessment',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': domain,
}
@@ -697,6 +699,7 @@ class ResPartner(models.Model):
'name': _('Assigned Deliveries'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
}

View File

@@ -101,6 +101,7 @@ class SaleOrder(models.Model):
'name': 'Message Authorizer',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'default_model': 'sale.order',
@@ -137,6 +138,7 @@ class SaleOrder(models.Model):
'name': _('Portal Comments'),
'res_model': 'fusion.authorizer.comment',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
@@ -149,6 +151,7 @@ class SaleOrder(models.Model):
'name': _('Portal Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}

View File

@@ -14,16 +14,12 @@
.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;
flex: 1 1 0;
min-width: 0;
padding: 0.75rem 0.5rem;
border-radius: 12px;
text-align: center;
color: #fff;
@@ -42,7 +38,145 @@
.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); }
/* ---- Clock In/Out Card ---- */
.tech-clock-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 0.875rem 1rem;
}
.tech-clock-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #adb5bd;
flex-shrink: 0;
}
.tech-clock-dot--active {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: tech-clock-pulse 2s ease-in-out infinite;
}
@keyframes tech-clock-pulse {
0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
}
.tech-clock-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--o-main-text-color, #212529);
line-height: 1.2;
}
.tech-clock-timer {
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #6c757d;
}
.tech-clock-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tech-clock-btn:active { transform: scale(0.96); }
.tech-clock-btn--in {
background: #10b981;
color: #fff;
}
.tech-clock-btn--in:hover { background: #059669; }
.tech-clock-btn--out {
background: #ef4444;
color: #fff;
}
.tech-clock-btn--out:hover { background: #dc2626; }
.tech-clock-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tech-clock-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 0.8rem;
font-weight: 500;
}
/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */
.tech-quick-links {
display: flex;
gap: 0.5rem;
}
.tech-quick-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.875rem 0.5rem;
border-radius: 12px;
border: 1.5px solid;
text-decoration: none !important;
font-weight: 600;
font-size: 0.8rem;
transition: all 0.15s;
position: relative;
}
.tech-quick-link:active { transform: scale(0.97); }
.tech-quick-link i { font-size: 1.1rem; }
.tech-quick-link-primary {
border-color: #3498db;
color: #3498db !important;
background: rgba(52, 152, 219, 0.04);
}
.tech-quick-link-primary:hover { background: rgba(52, 152, 219, 0.1); }
.tech-quick-link-secondary {
border-color: #6c757d;
color: #6c757d !important;
background: rgba(108, 117, 125, 0.04);
}
.tech-quick-link-secondary:hover { background: rgba(108, 117, 125, 0.1); }
.tech-quick-link-warning {
border-color: #e67e22;
color: #e67e22 !important;
background: rgba(230, 126, 34, 0.04);
}
.tech-quick-link-warning:hover { background: rgba(230, 126, 34, 0.1); }
.tech-quick-link-badge {
position: absolute;
top: -6px;
right: -6px;
background: #3498db;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 4px;
}
/* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card {
@@ -475,12 +609,18 @@
gap: 1rem;
}
.tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem;
}
.tech-stat-card .stat-number {
font-size: 2rem;
}
.tech-quick-links {
gap: 1rem;
}
.tech-quick-link {
padding: 1rem 0.75rem;
font-size: 0.85rem;
}
.tech-bottom-bar {
position: static;
box-shadow: none;

View File

@@ -28,6 +28,9 @@ patch(Chatter.prototype, {
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
if (!result.views && result.view_mode) {
result.views = result.view_mode.split(",").map(v => [false, v.trim()]);
}
this._fapActionService.doAction(result);
}
} catch (e) {

View File

@@ -1,15 +1,144 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
* Technician Location Services
*
* 1. Background logger -- logs GPS every 5 minutes during working hours.
* 2. getLocation() -- returns a Promise that resolves to {latitude, longitude, accuracy}.
* If the user denies permission or the request times out a blocking modal is shown
* and the promise is rejected.
* 3. Blocking modal -- cannot be dismissed; forces the technician to grant permission.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var INTERVAL_MS = 5 * 60 * 1000;
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var locationTimer = null;
var permissionDenied = false;
// =====================================================================
// BLOCKING MODAL
// =====================================================================
var modalEl = null;
function ensureModal() {
if (modalEl) return;
var div = document.createElement('div');
div.id = 'fusionLocationModal';
div.innerHTML =
'<div style="position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:99999;display:flex;align-items:center;justify-content:center;">' +
'<div style="background:#fff;border-radius:16px;max-width:400px;width:90%;padding:2rem;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3);">' +
'<div style="font-size:3rem;color:#dc3545;margin-bottom:1rem;"><i class="fa fa-map-marker"></i></div>' +
'<h4 style="margin-bottom:0.5rem;">Location Required</h4>' +
'<p style="color:#666;font-size:0.95rem;">Your GPS location is mandatory to perform this action. ' +
'Please allow location access in your browser settings and try again.</p>' +
'<p style="color:#999;font-size:0.85rem;">If you previously denied access, open your browser settings ' +
'and reset the location permission for this site.</p>' +
'<button id="fusionLocationRetryBtn" style="background:#0d6efd;color:#fff;border:none;border-radius:12px;padding:0.75rem 2rem;font-size:1rem;cursor:pointer;margin-top:0.5rem;width:100%;">' +
'<i class="fa fa-refresh" style="margin-right:6px;"></i>Try Again' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
modalEl = div;
document.getElementById('fusionLocationRetryBtn').addEventListener('click', function () {
hideModal();
window.fusionGetLocation().catch(function () {
showModal();
});
});
}
function showModal() {
ensureModal();
modalEl.style.display = '';
}
function hideModal() {
if (modalEl) modalEl.style.display = 'none';
}
// =====================================================================
// PERMISSION-DENIED BANNER (persistent warning for background logger)
// =====================================================================
var bannerEl = null;
function showDeniedBanner() {
if (bannerEl) return;
bannerEl = document.createElement('div');
bannerEl.id = 'fusionLocationBanner';
bannerEl.style.cssText =
'position:fixed;top:0;left:0;right:0;z-index:9999;background:#dc3545;color:#fff;' +
'padding:10px 16px;text-align:center;font-size:0.9rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);';
bannerEl.innerHTML =
'<i class="fa fa-exclamation-triangle" style="margin-right:6px;"></i>' +
'Location access is denied. Your location is not being tracked. ' +
'Please enable location in browser settings.';
document.body.appendChild(bannerEl);
}
// =====================================================================
// getLocation() -- public API
// =====================================================================
function getLocation() {
return new Promise(function (resolve, reject) {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser.'));
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
permissionDenied = false;
if (bannerEl) { bannerEl.remove(); bannerEl = null; }
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
});
},
function (error) {
permissionDenied = true;
showDeniedBanner();
console.error('Fusion Location: GPS error', error.code, error.message);
reject(error);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 }
);
});
}
window.fusionGetLocation = getLocation;
// =====================================================================
// NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback
// =====================================================================
function openGoogleMapsNav(el) {
var addr = (el.dataset.navAddr || '').trim();
var fallbackUrl = el.dataset.navUrl || '';
if (!addr && !fallbackUrl) return;
var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1];
var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
var isAndroid = /Android/i.test(navigator.userAgent);
if (isIOS) {
window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving';
} else if (isAndroid) {
window.location.href = 'google.navigation:q=' + dest;
} else {
window.open(fallbackUrl, '_blank');
}
}
window.openGoogleMapsNav = openGoogleMapsNav;
// =====================================================================
// BACKGROUND LOGGER
// =====================================================================
function isWorkingHours() {
var now = new Date();
@@ -18,77 +147,54 @@
}
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;
}
if (!isWorkingHours() || document.hidden || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
getLocation().then(function (coords) {
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
}
};
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 }
);
}),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.result && !data.result.success) {
console.warn('Fusion Location: server rejected log', data.result);
}
})
.catch(function (err) {
console.warn('Fusion Location: network error', err);
});
}).catch(function () {
/* permission denied -- banner already shown */
});
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
if (!isTechnicianPortal()) return;
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;
}
if (locationTimer) { clearInterval(locationTimer); locationTimer = null; }
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
if (!locationTimer) { locationTimer = setInterval(logLocation, INTERVAL_MS); }
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {

View File

@@ -18,6 +18,41 @@
</ol>
</nav>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Quick Stats Bar -->
<div class="tech-stats-bar mb-4">
<div class="tech-stat-card tech-stat-total">
@@ -32,10 +67,6 @@
<div class="stat-number"><t t-out="completed_today"/></div>
<div class="stat-label">Done</div>
</div>
<div class="tech-stat-card tech-stat-travel">
<div class="stat-number"><t t-out="total_travel"/></div>
<div class="stat-label">Travel min</div>
</div>
</div>
<!-- Current / Next Task Hero Card -->
@@ -61,8 +92,11 @@
<p class="mb-2 small"><t t-out="current_task.description"/></p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="current_task.get_google_maps_url()" t-att-href="current_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="current_task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="current_task.get_google_maps_url()"
t-att-data-nav-addr="current_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<a t-attf-href="/my/technician/task/#{current_task.id}"
@@ -102,8 +136,11 @@
</p>
</t>
<div class="d-flex gap-2 flex-wrap mt-3">
<a t-if="next_task.get_google_maps_url()" t-att-href="next_task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="next_task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="next_task.get_google_maps_url()"
t-att-data-nav-addr="next_task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<button class="tech-action-btn tech-btn-enroute"
@@ -170,25 +207,22 @@
</t>
<!-- Quick Links -->
<div class="row g-2 mb-4">
<div class="col-4">
<a href="/my/technician/tasks" class="btn btn-outline-primary w-100 py-3">
<i class="fa fa-list me-1"/>All Tasks
</a>
</div>
<div class="col-4">
<a href="/my/technician/tomorrow" class="btn btn-outline-secondary w-100 py-3">
<i class="fa fa-calendar me-1"/>Tomorrow
<t t-if="tomorrow_count">
<span class="badge bg-primary ms-1"><t t-out="tomorrow_count"/></span>
</t>
</a>
</div>
<div class="col-4">
<a href="/repair-form" class="btn btn-outline-warning w-100 py-3">
<i class="fa fa-wrench me-1"/>Repair Form
</a>
</div>
<div class="tech-quick-links mb-4">
<a href="/my/technician/tasks" class="tech-quick-link tech-quick-link-primary">
<i class="fa fa-list"/>
<span>All Tasks</span>
</a>
<a href="/my/technician/tomorrow" class="tech-quick-link tech-quick-link-secondary">
<i class="fa fa-calendar"/>
<span>Tomorrow</span>
<t t-if="tomorrow_count">
<span class="tech-quick-link-badge"><t t-out="tomorrow_count"/></span>
</t>
</a>
<a href="/repair-form" class="tech-quick-link tech-quick-link-warning">
<i class="fa fa-wrench"/>
<span>Repair Form</span>
</a>
</div>
<!-- My Start Location -->
@@ -221,30 +255,146 @@
</div>
</div>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() {
stopTimer();
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
};
})();
</script>
<!-- Inline JS for task actions -->
<script type="text/javascript">
function techTaskAction(btn, action) {
var taskId = btn.dataset.taskId;
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: action,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
}
})
.catch(function() {
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-road"></i> En Route';
btn.innerHTML = origHtml;
});
}
@@ -462,7 +612,10 @@
<!-- ===== QUICK ACTIONS ROW ===== -->
<div class="tech-quick-actions mb-3">
<t t-if="task.get_google_maps_url()">
<a t-att-href="task.get_google_maps_url()" class="tech-quick-btn" target="_blank">
<a href="#" class="tech-quick-btn"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>
<span>Navigate</span>
</a>
@@ -695,8 +848,11 @@
</button>
</t>
<t t-if="task.status == 'en_route'">
<a t-if="task.get_google_maps_url()" t-att-href="task.get_google_maps_url()"
class="tech-action-btn tech-btn-navigate" target="_blank">
<a t-if="task.get_google_maps_url()"
href="#" class="tech-action-btn tech-btn-navigate"
t-att-data-nav-url="task.get_google_maps_url()"
t-att-data-nav-addr="task.address_display or ''"
onclick="openGoogleMapsNav(this); return false;">
<i class="fa fa-location-arrow"/>Navigate
</a>
<button class="tech-action-btn tech-btn-start"
@@ -750,46 +906,78 @@
var recordingSeconds = 0;
function techTaskAction(btn, action) {
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: action}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i>';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: action,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
window.location.reload();
} else {
alert(data.result ? data.result.error : 'Error');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Network error. Please try again.');
btn.disabled = false;
}
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = origHtml;
});
}
function techCompleteTask(btn) {
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {action: 'complete'}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Getting location...';
window.fusionGetLocation().then(function(coords) {
btn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Completing...';
fetch('/my/technician/task/' + taskId + '/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
action: 'complete',
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.result &amp;&amp; data.result.success) {
showCompletionOverlay(data.result);
} else {
alert(data.result ? data.result.error : 'Error completing task');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
}
})
.catch(function(err) {
alert('Network error. Please try again.');
btn.innerHTML = origHtml;
});
}).catch(function() {
alert('Location access is required. Please enable GPS and try again.');
btn.disabled = false;
btn.innerHTML = '&lt;i class="fa fa-check-circle">&lt;/i> Complete Task';
btn.innerHTML = origHtml;
});
}
@@ -1184,10 +1372,16 @@
btns.forEach(function(b){b.disabled = true;});
try {
var coords = await window.fusionGetLocation();
var resp = await fetch('/my/technician/task/' + taskId + '/voice-complete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {transcription: text}})
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
transcription: text,
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy
}})
});
var data = await resp.json();
if (data.result &amp;&amp; data.result.success) {
@@ -1197,7 +1391,11 @@
btns.forEach(function(b){b.disabled = false;});
}
} catch (err) {
alert('Error: ' + err.message);
if (err instanceof GeolocationPositionError || err.code) {
alert('Location access is required. Please enable GPS and try again.');
} else {
alert('Error: ' + err.message);
}
btns.forEach(function(b){b.disabled = false;});
}
}

View File

@@ -188,7 +188,73 @@
</a>
</div>
</t>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="col-md-6">
<style>
@keyframes hcPulseGreen {
0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); }
70% { box-shadow: 0 0 0 14px rgba(16,185,129,0); }
100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); }
}
@keyframes hcPulseRed {
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
70% { box-shadow: 0 0 0 14px rgba(239,68,68,0); }
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
}
.hc-btn-ring {
width: 56px; height: 56px; border-radius: 50%; border: none;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; pointer-events: none;
}
.hc-btn-ring--in {
background: #10b981;
animation: hcPulseGreen 2s ease-in-out infinite;
}
.hc-btn-ring--out {
background: #ef4444;
animation: hcPulseRed 2s ease-in-out infinite;
}
.hc-btn-ring i { color: #fff; font-size: 1.4rem; }
.hc-btn-ring--in i { padding-left: 3px; }
.hc-timer-badge {
display: inline-block; font-family: monospace; font-size: 0.75rem; font-weight: 700;
color: #10b981; background: rgba(16,185,129,0.1); border-radius: 20px;
padding: 2px 10px; letter-spacing: 0.05em;
}
.hc-clock-link { text-decoration: none; }
.hc-clock-link:hover { text-decoration: none; }
.hc-clock-link:hover .card { box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
.hc-clock-link:active .hc-btn-ring { transform: scale(0.92); }
</style>
<a href="/my/clock" class="hc-clock-link"
id="homeClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="card h-100 border-0 shadow-sm" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div t-attf-class="hc-btn-ring #{clock_checked_in and 'hc-btn-ring--out' or 'hc-btn-ring--in'}">
<i t-attf-class="fa #{clock_checked_in and 'fa-stop' or 'fa-play'}"/>
</div>
</div>
<div style="min-width: 0;">
<h5 class="mb-0 text-dark" id="homeClockStatus">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Clock In</t>
</h5>
<div id="homeClockTimer">
<t t-if="clock_checked_in"><span class="hc-timer-badge">00:00:00</span></t>
<t t-else=""><small class="text-muted">Tap to start your shift</small></t>
</div>
</div>
</div>
</div>
</a>
</div>
</t>
<!-- Funding Claims (Clients/Authorizers) -->
<t t-if="request.env.user.partner_id.is_client_portal or request.env.user.partner_id.is_authorizer">
<div class="col-md-6">
@@ -251,6 +317,28 @@
</div>
</div>
</div>
<!-- Home Clock Timer (display only, links to /my/clock) -->
<script type="text/javascript">
(function() {
var card = document.getElementById('homeClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
if (!isCheckedIn || !checkInTime) return;
function pad(n) { return n &lt; 10 ? '0' + n : '' + n; }
var badge = document.querySelector('#homeClockTimer .hc-timer-badge');
if (!badge) return;
function tick() {
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
badge.textContent = pad(Math.floor(diff / 3600)) + ':' + pad(Math.floor((diff % 3600) / 60)) + ':' + pad(diff % 60);
}
tick();
setInterval(tick, 1000);
})();
</script>
</t>
</xpath>
</template>
@@ -1086,7 +1174,42 @@
<p class="text-muted">Welcome back, <t t-out="partner.name"/>!</p>
</div>
</div>
<!-- Clock In/Out -->
<t t-if="clock_enabled">
<div class="tech-clock-card mb-3"
id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/>
<div>
<div class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t>
</div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
</div>
</div>
<button class="tech-clock-btn" id="clockActionBtn"
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'"
onclick="handleClockAction()">
<t t-if="clock_checked_in">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
</div>
<div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/>
</div>
</div>
</t>
<!-- Stats Cards - 2x2 on mobile, 4 columns on desktop -->
<div class="row mb-3 g-2">
<div class="col-6 col-md-3">
@@ -1377,6 +1500,113 @@
<!-- Include loaner modals -->
<t t-call="fusion_authorizer_portal.loaner_checkout_modal"/>
<t t-call="fusion_authorizer_portal.loaner_return_modal"/>
<!-- Clock In/Out JS -->
<script type="text/javascript">
(function() {
var card = document.getElementById('techClockCard');
if (!card) return;
var isCheckedIn = card.dataset.checkedIn === 'true';
var checkInTime = card.dataset.checkInTime ? new Date(card.dataset.checkInTime + 'Z') : null;
var timerInterval = null;
function updateTimer() {
if (!checkInTime) return;
var diff = Math.max(0, Math.floor((new Date() - checkInTime) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
var pad = function(n) { return n &lt; 10 ? '0' + n : '' + n; };
document.getElementById('clockTimer').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
}
function startTimer() { stopTimer(); updateTimer(); timerInterval = setInterval(updateTimer, 1000); }
function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
function applyState() {
var dot = card.querySelector('.tech-clock-dot');
var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
}
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
}
if (isCheckedIn &amp;&amp; checkInTime) startTimer();
window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
btn.disabled = true;
errEl.style.display = 'none';
function doClockAction(lat, lng, acc) {
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: lat, longitude: lng, accuracy: acc, source: 'portal'
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var result = data.result || {};
if (result.error) {
errText.textContent = result.error;
errEl.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}
if (window.fusionGetLocation) {
window.fusionGetLocation().then(function(coords) {
doClockAction(coords.latitude, coords.longitude, coords.accuracy);
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(pos) {
doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
}, function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
}, {enableHighAccuracy: true, timeout: 15000});
} else {
doClockAction(0, 0, 0);
}
};
})();
</script>
</t>
</template>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.7.0.0',
'version': '19.0.7.2.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -153,7 +153,8 @@
'report/report_proof_of_delivery.xml',
'report/report_proof_of_delivery_standard.xml',
'report/report_proof_of_pickup.xml',
'report/report_rental_agreement.xml',
'report/report_approved_items.xml',
'report/report_grab_bar_waiver.xml',
'report/report_accessibility_contract.xml',
'report/report_mod_quotation.xml',
@@ -178,6 +179,7 @@
'fusion_claims/static/src/js/calendar_store_hours.js',
'fusion_claims/static/src/js/fusion_task_map_view.js',
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
'fusion_claims/static/src/xml/fusion_task_map_view.xml',
],

View File

@@ -105,9 +105,11 @@ class AccountMove(models.Model):
try:
report = self.env.ref('fusion_claims.action_report_mod_invoice')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '')
name_parts = (so.partner_id.name or 'Client').strip().split()
first = name_parts[0] if name_parts else 'Client'
last = name_parts[-1] if len(name_parts) > 1 else ''
att = Attachment.create({
'name': f'Invoice - {client_name} - {self.name}.pdf',
'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'account.move',

View File

@@ -2,8 +2,12 @@
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import requests
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
@@ -14,6 +18,65 @@ class ResPartner(models.Model):
'Used as origin for first travel time calculation. '
'If empty, the company default HQ address is used.',
)
x_fc_start_address_lat = fields.Float(
string='Start Latitude', digits=(10, 7),
)
x_fc_start_address_lng = fields.Float(
string='Start Longitude', digits=(10, 7),
)
def _geocode_start_address(self, address):
if not address or not address.strip():
return 0.0, 0.0
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
if not api_key:
return 0.0, 0.0
try:
resp = requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address.strip(), 'key': api_key, 'region': 'ca'},
timeout=10,
)
data = resp.json()
if data.get('status') == 'OK' and data.get('results'):
loc = data['results'][0]['geometry']['location']
return loc['lat'], loc['lng']
except Exception as e:
_logger.warning("Start address geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec, vals in zip(records, vals_list):
addr = vals.get('x_fc_start_address')
if addr:
lat, lng = rec._geocode_start_address(addr)
if lat and lng:
rec.write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
return records
def write(self, vals):
res = super().write(vals)
if 'x_fc_start_address' in vals:
addr = vals['x_fc_start_address']
if addr and addr.strip():
lat, lng = self._geocode_start_address(addr)
if lat and lng:
super().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
else:
super().write({
'x_fc_start_address_lat': 0.0,
'x_fc_start_address_lng': 0.0,
})
return res
# ==========================================================================
# CONTACT TYPE

View File

@@ -22,7 +22,7 @@ class SaleOrder(models.Model):
for order in self:
name = order.name or ''
if order.partner_id and order.partner_id.name:
name = f"{name} -- {order.partner_id.name}"
name = f"{name} - {order.partner_id.name}"
order.display_name = name
# ==========================================================================
@@ -1318,17 +1318,12 @@ class SaleOrder(models.Model):
att_ids = []
att_names = []
# 1. Signed SA Form -- reuse existing attachment created by attachment=True
signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else (
'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None)
if signed_field:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', signed_field),
], order='id desc', limit=1)
if att:
att_ids.append(att.id)
att_id = self._get_and_prepare_field_attachment(signed_field, 'Signed SA Form')
if att_id:
att_ids.append(att_id)
att_names.append('Signed SA Form')
# 2. Internal POD -- generate on-the-fly from the standard report
@@ -1353,12 +1348,14 @@ class SaleOrder(models.Model):
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'Invoice_{invoice.name}.pdf',
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
@@ -1388,16 +1385,10 @@ class SaleOrder(models.Model):
att_ids = []
att_names = []
# 1. Approval document
if self.x_fc_odsp_approval_document:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_odsp_approval_document'),
], order='id desc', limit=1)
if att:
att_ids.append(att.id)
att_names.append('ODSP Approval Document')
att_id = self._get_and_prepare_field_attachment('x_fc_odsp_approval_document', 'ODSP Approval Document')
if att_id:
att_ids.append(att_id)
att_names.append('ODSP Approval Document')
# 2. Internal POD
try:
@@ -1426,12 +1417,14 @@ class SaleOrder(models.Model):
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'Invoice_{invoice.name}.pdf',
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
@@ -1680,44 +1673,6 @@ class SaleOrder(models.Model):
)
_logger.info("POD signature applied to approval form for %s", self.name)
# ==========================================================================
# DELIVERY STATUS FIELDS
# ==========================================================================
x_fc_delivery_status = fields.Selection(
selection=[
('waiting', 'Waiting'),
('waiting_approval', 'Waiting for Approval'),
('ready', 'Ready for Delivery'),
('scheduled', 'Delivery Scheduled'),
('shipped_warehouse', 'Shipped to Warehouse'),
('received_warehouse', 'Received in Warehouse'),
('delivered', 'Delivered'),
('hold', 'Hold'),
('cancelled', 'Cancelled'),
],
string='Delivery Status',
tracking=True,
help='Current delivery status of the order',
)
x_fc_delivery_datetime = fields.Datetime(
string='Delivery Date & Time',
tracking=True,
help='Scheduled or actual delivery date and time',
)
# Computed field to show/hide delivery datetime
x_fc_show_delivery_datetime = fields.Boolean(
compute='_compute_show_delivery_datetime',
string='Show Delivery DateTime',
)
@api.depends('x_fc_delivery_status')
def _compute_show_delivery_datetime(self):
"""Compute whether to show delivery datetime field."""
for order in self:
order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered')
# ==========================================================================
# ADP CLAIM FIELDS
# ==========================================================================
@@ -1729,11 +1684,11 @@ class SaleOrder(models.Model):
)
x_fc_client_ref_1 = fields.Char(
string='Client Reference 1',
help='Primary client reference (e.g., Health Card Number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
x_fc_client_ref_2 = fields.Char(
string='Client Reference 2',
help='Secondary client reference',
help='Last four digits of the client\'s health card number. Example: 1234',
)
x_fc_adp_delivery_date = fields.Date(
string='ADP Delivery Date',
@@ -2988,10 +2943,136 @@ class SaleOrder(models.Model):
# ==========================================================================
# PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler)
# ==========================================================================
MIME_TO_EXT = {
'application/pdf': '.pdf',
'application/xml': '.xml',
'text/xml': '.xml',
'text/plain': '.txt',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-excel': '.xls',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/zip': '.zip',
'application/octet-stream': '',
}
FIELD_NAME_TEMPLATE = {
'x_fc_final_submitted_application': '{first}_{last}.pdf',
'x_fc_xml_file': '{first}_{last}_data.xml',
'x_fc_original_application': '{first}_{last}_Original_Application.pdf',
'x_fc_signed_pages_11_12': '{first}_{last}_Signed_Pages.pdf',
'x_fc_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_approval_letter': '{first}_{last}_Approval_Letter.pdf',
'x_fc_sa_signed_form': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_physical_signed_copy': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_approval_form': '{first}_{last}_SA_Approval.pdf',
'x_fc_odsp_approval_document': '{first}_{last}_ODSP_Approval.pdf',
'x_fc_odsp_authorizer_letter': '{first}_{last}_ODSP_Authorizer_Letter.pdf',
'x_fc_ow_discretionary_form': '{first}_{last}_OW_Discretionary_Form.pdf',
'x_fc_ow_authorizer_letter': '{first}_{last}_OW_Authorizer_Letter.pdf',
'x_fc_mod_drawing': '{first}_{last}_Drawing.pdf',
'x_fc_mod_initial_photos': '{first}_{last}_Initial_Photos.pdf',
'x_fc_mod_pca_document': '{first}_{last}_PCA_Document.pdf',
'x_fc_mod_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_mod_completion_photos': '{first}_{last}_Completion_Photos.pdf',
}
FIELD_FILENAME_MAP = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
'x_fc_approval_letter': 'x_fc_approval_letter_filename',
'x_fc_sa_signed_form': 'x_fc_sa_signed_form_filename',
'x_fc_sa_physical_signed_copy': 'x_fc_sa_physical_signed_copy_filename',
'x_fc_sa_approval_form': 'x_fc_sa_approval_form_filename',
'x_fc_odsp_approval_document': 'x_fc_odsp_approval_document_filename',
'x_fc_odsp_authorizer_letter': 'x_fc_odsp_authorizer_letter_filename',
'x_fc_ow_discretionary_form': 'x_fc_ow_discretionary_form_filename',
'x_fc_ow_authorizer_letter': 'x_fc_ow_authorizer_letter_filename',
'x_fc_mod_drawing': 'x_fc_mod_drawing_filename',
'x_fc_mod_initial_photos': 'x_fc_mod_initial_photos_filename',
'x_fc_mod_pca_document': 'x_fc_mod_pca_filename',
'x_fc_mod_proof_of_delivery': 'x_fc_mod_pod_filename',
'x_fc_mod_completion_photos': 'x_fc_mod_completion_photos_filename',
}
def _get_ext_from_mime(self, mimetype):
"""Return a file extension (with dot) for a MIME type."""
return self.MIME_TO_EXT.get(mimetype or '', '')
def _get_client_name_parts(self):
"""Return (first_name, last_name) cleaned for filenames."""
full_name = (self.partner_id.name or 'Client').strip()
parts = full_name.split()
first = parts[0] if parts else 'Client'
last = parts[-1] if len(parts) > 1 else ''
clean = lambda s: s.replace(',', '').replace("'", '').replace('"', '')
return clean(first), clean(last)
def _build_attachment_name(self, field_name, mimetype=None):
"""Build the proper filename for a field attachment.
Uses FIELD_NAME_TEMPLATE for known fields with Firstname_Lastname convention.
For the XML file, respects the actual mimetype (could be .xml, .docx, .txt).
"""
first, last = self._get_client_name_parts()
template = self.FIELD_NAME_TEMPLATE.get(field_name)
if template:
name = template.format(first=first, last=last)
if field_name == 'x_fc_xml_file' and mimetype:
ext = self._get_ext_from_mime(mimetype)
if ext and ext != '.xml':
name = f'{first}_{last}_data{ext}'
return name
ext = self._get_ext_from_mime(mimetype) if mimetype else '.pdf'
return f'{first}_{last}_Document{ext}'
def _prepare_attachment_for_email(self, attachment, field_name=None, label=None):
"""Rename an attachment to a clean, professional filename.
Always renames to the standard convention (Firstname_Lastname pattern)
so recipients get consistently named files regardless of what was uploaded.
"""
if not attachment:
return None
new_name = self._build_attachment_name(field_name, attachment.mimetype)
if attachment.name == new_name:
return attachment.id
try:
attachment.sudo().write({'name': new_name})
except Exception:
_logger.warning("Could not rename attachment %s to %s", attachment.id, new_name)
return attachment.id
def _get_and_prepare_field_attachment(self, field_name, label=None):
"""Find the ir.attachment for a binary field, rename it properly, return its id.
Convenience wrapper combining _get_document_attachment + _prepare_attachment_for_email.
Returns None if the field has no data or attachment is not found.
"""
self.ensure_one()
if not getattr(self, field_name, None):
return None
attachment = self._get_document_attachment(field_name)
if not attachment:
return None
return self._prepare_attachment_for_email(attachment, field_name=field_name, label=label)
def _get_document_attachment(self, field_name):
"""Get the ir.attachment record for a binary field stored as attachment."""
self.ensure_one()
# Find the attachment by field name - get most recent one
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
@@ -3001,9 +3082,9 @@ class SaleOrder(models.Model):
def _get_or_create_attachment(self, field_name, document_label):
"""Get the current attachment for a binary field (attachment=True).
For attachment=True fields, Odoo creates attachments automatically.
We find the one with res_field set and return it.
We find the one with res_field set, ensure it has a proper name, and return it.
"""
self.ensure_one()
@@ -3011,43 +3092,20 @@ class SaleOrder(models.Model):
if not data:
return None
# For attachment=True fields, Odoo creates/updates an attachment with res_field set
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', field_name),
], order='id desc', limit=1)
if attachment:
# If attachment name is the field name (Odoo default), use the actual filename
if attachment.name == field_name:
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
filename_field = filename_mapping.get(field_name)
if filename_field:
filename = getattr(self, filename_field, None)
if filename and filename != field_name:
attachment.sudo().write({'name': filename})
self._prepare_attachment_for_email(attachment, field_name=field_name, label=document_label)
return attachment
# Fallback: create attachment manually (shouldn't happen for attachment=True fields)
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
filename_field = filename_mapping.get(field_name)
filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf'
filename = self._build_attachment_name(field_name)
attachment = self.env['ir.attachment'].sudo().create({
'name': filename or f'{document_label}.pdf',
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
@@ -3399,16 +3457,20 @@ class SaleOrder(models.Model):
}
def action_complete_assessment(self):
"""Open wizard to mark assessment as completed with date."""
"""Open wizard to mark assessment as completed with date.
Allowed from 'quotation' (override) or 'assessment_scheduled' (normal flow)."""
self.ensure_one()
if self.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
if self.x_fc_adp_application_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
return {
'name': 'Assessment Completed',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.assessment.completed.wizard',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'active_id': self.id,
@@ -4627,82 +4689,67 @@ class SaleOrder(models.Model):
# ==========================================================================
# DOCUMENT CHATTER POSTING
# ==========================================================================
def _post_document_to_chatter(self, field_name, document_label=None):
"""Post a document attachment to the chatter with a link.
def _post_document_to_chatter(self, field_name, document_label=None, preserve_copy=False):
"""Post a document to the chatter, reusing the existing field attachment.
By default, references the existing ir.attachment (created by Odoo for
attachment=True fields) instead of creating a duplicate.
Args:
field_name: The binary field name (e.g., 'x_fc_final_submitted_application')
document_label: Optional label for the document (defaults to field string)
preserve_copy: If True, creates a separate copy (used when the original
is about to be deleted/replaced and we need to keep a snapshot).
"""
self.ensure_one()
# Map field names to filename fields
filename_mapping = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
}
data_field = field_name
filename_field = filename_mapping.get(field_name, field_name + '_filename')
data = getattr(self, data_field, None)
original_filename = getattr(self, filename_field, None) or 'document'
data = getattr(self, field_name, None)
if not data:
return
# Get document label from field definition if not provided
if not document_label:
field_obj = self._fields.get(data_field)
document_label = field_obj.string if field_obj else data_field
# Check for existing attachments with same name for revision numbering
existing_count = self.env['ir.attachment'].sudo().search_count([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('name', '=like', original_filename.rsplit('.', 1)[0] + '%'),
])
# Add revision number if this is a replacement
if existing_count > 0 and '(replaced)' in (document_label or ''):
# This is an old document being replaced - add revision number
base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '')
filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}"
field_obj = self._fields.get(field_name)
document_label = field_obj.string if field_obj else field_name
if preserve_copy:
proper_name = self._build_attachment_name(field_name)
base, _, ext = proper_name.rpartition('.')
if base:
copy_name = f"{base}_archived.{ext}"
else:
copy_name = f"{proper_name}_archived"
attachment = self.env['ir.attachment'].sudo().create({
'name': copy_name,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
else:
filename = original_filename
# Create attachment with the original/revised filename
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
# Post message with attachment (shows as native Odoo attachment with preview)
attachment = self._get_document_attachment(field_name)
if not attachment:
return
self._prepare_attachment_for_email(attachment, field_name=field_name)
user_name = self.env.user.name
now = fields.Datetime.now()
body = Markup("""
<p><strong>{label}</strong> uploaded by <b>{user}</b></p>
<p class="text-muted small">{timestamp}</p>
""").format(
body = Markup(
'<p><strong>{label}</strong> uploaded by <b>{user}</b></p>'
'<p class="text-muted small">{timestamp}</p>'
).format(
label=document_label,
user=user_name,
timestamp=now.strftime('%Y-%m-%d %H:%M:%S')
timestamp=now.strftime('%Y-%m-%d %H:%M:%S'),
)
# Use attachment_ids to show as native attachment with preview capability
self.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=[attachment.id],
)
return attachment
# ==========================================================================
@@ -4844,28 +4891,15 @@ class SaleOrder(models.Model):
if not to_emails and not cc_emails:
return False
# Reuse existing field attachments (created by Odoo for attachment=True fields)
# instead of creating duplicates
attachments = []
attachment_names = []
Attachment = self.env['ir.attachment'].sudo()
if self.x_fc_final_submitted_application:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_final_submitted_application'),
], order='id desc', limit=1)
if att:
attachments.append(att.id)
att_id = self._get_and_prepare_field_attachment('x_fc_final_submitted_application', 'ADP Application')
if att_id:
attachments.append(att_id)
attachment_names.append('Final ADP Application (PDF)')
if self.x_fc_xml_file:
att = Attachment.search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', 'x_fc_xml_file'),
], order='id desc', limit=1)
if att:
attachments.append(att.id)
att_id = self._get_and_prepare_field_attachment('x_fc_xml_file', 'ADP XML Data')
if att_id:
attachments.append(att_id)
attachment_names.append('XML Data File')
client_name = recipients.get('client', self.partner_id).name or 'Client'
@@ -5113,8 +5147,144 @@ class SaleOrder(models.Model):
_logger.error(f"Failed to send billed email for {self.name}: {e}")
return False
def _build_approved_items_html(self, for_pdf=False):
"""Build an HTML table of approved order line items.
Columns: S/N, ADP Code, Device Type, Product Name, Qty,
ADP Portion, Client Portion, Deduction.
"""
self.ensure_one()
lines = self.order_line.filtered(
lambda l: l.product_id and l.display_type not in ('line_section', 'line_note')
)
if not lines:
return ''
font = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"
if for_pdf:
font = "font-family:Arial,Helvetica,sans-serif;"
hdr_style = (
f'style="background:#2d3748;color:#fff;padding:8px 10px;'
f'font-size:11px;font-weight:600;text-align:left;'
f'border-bottom:2px solid #4a5568;{font}"'
)
cell_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;"'
)
alt_row = 'style="background:#f7fafc;"'
amt_style = (
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
'border-bottom:1px solid #e2e8f0;text-align:right;"'
)
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
has_deduction = any(
l.x_fc_deduction_type and l.x_fc_deduction_type != 'none'
for l in lines
)
html = (
'<div style="margin:20px 0;">'
f'<h3 style="color:#1a202c;font-size:15px;font-weight:700;'
f'margin:0 0 10px 0;{font}">Approved Items</h3>'
'<table style="width:100%;border-collapse:collapse;border:1px solid #e2e8f0;">'
'<thead><tr>'
f'<th {hdr_style}>S/N</th>'
f'<th {hdr_style}>ADP Code</th>'
f'<th {hdr_style}>Device Type</th>'
f'<th {hdr_style}>Product</th>'
f'<th {hdr_r}>Qty</th>'
f'<th {hdr_r}>ADP Portion</th>'
f'<th {hdr_r}>Client Portion</th>'
)
if has_deduction:
html += f'<th {hdr_r}>Deduction</th>'
html += '</tr></thead><tbody>'
total_adp = 0.0
total_client = 0.0
for idx, line in enumerate(lines, 1):
row_attr = alt_row if idx % 2 == 0 else ''
adp_code = line._get_adp_code_for_report()
device_type = line._get_adp_device_type()
product_name = line.product_id.name or '-'
if len(product_name) > 40 and not for_pdf:
product_name = product_name[:37] + '...'
qty = int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty
adp_portion = line.x_fc_adp_portion or 0.0
client_portion = line.x_fc_client_portion or 0.0
total_adp += adp_portion
total_client += client_portion
deduction_str = '-'
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
deduction_str = f'{line.x_fc_deduction_value:.0f}%'
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
deduction_str = f'${line.x_fc_deduction_value:,.2f}'
html += f'<tr {row_attr}>'
html += f'<td {cell_style}>{idx}</td>'
html += f'<td {cell_style}>{adp_code}</td>'
html += f'<td {cell_style}>{device_type}</td>'
html += f'<td {cell_style}>{product_name}</td>'
html += f'<td {amt_style}>{qty}</td>'
html += f'<td {amt_style}>${adp_portion:,.2f}</td>'
html += f'<td {amt_style}>${client_portion:,.2f}</td>'
if has_deduction:
html += f'<td {amt_style}>{deduction_str}</td>'
html += '</tr>'
# Totals row
colspan = 5
total_style = (
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
)
total_label_style = (
f'style="padding:8px 10px;font-size:12px;font-weight:700;'
f'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
)
html += f'<tr style="background:#edf2f7;">'
html += f'<td colspan="{colspan}" {total_label_style}>Total</td>'
html += f'<td {total_style}>${total_adp:,.2f}</td>'
html += f'<td {total_style}>${total_client:,.2f}</td>'
if has_deduction:
html += f'<td {total_style}></td>'
html += '</tr>'
html += '</tbody></table></div>'
return html
def _generate_approved_items_pdf(self):
"""Generate the Approved Items PDF using the QWeb report and return an ir.attachment id."""
self.ensure_one()
import base64
first, last = self._get_client_name_parts()
try:
report = self.env.ref('fusion_claims.action_report_approved_items')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
except Exception as e:
_logger.error("Failed to generate approved items PDF for %s: %s", self.name, e)
return None
filename = f'{first}_{last}_Approved_Items.pdf'
att = self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content) if isinstance(pdf_content, bytes) else pdf_content,
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
return att.id
def _send_approval_email(self):
"""Send notification when ADP application is approved."""
"""Send notification when ADP application is approved, with approved items report."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
@@ -5138,27 +5308,46 @@ class SaleOrder(models.Model):
'contact you with the details and next steps for delivery.'
)
items_html = self._build_approved_items_html()
body_html = self._email_build(
title='Application Approved',
summary=f'The ADP application for <strong>{client_name}</strong> has been '
f'<strong>{status_label.lower()}</strong>.',
email_type='success',
sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))],
extra_html=items_html,
note=note_text,
note_color='#38a169',
attachments_note='Approved Items Report (PDF)' if items_html else None,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
attachment_ids = []
try:
att_id = self._generate_approved_items_pdf()
if att_id:
attachment_ids.append(att_id)
except Exception as e:
_logger.warning("Could not generate approved items PDF for %s: %s", self.name, e)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
self.env['mail.mail'].sudo().create({
mail_vals = {
'subject': f'Application {status_label} - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log(f'Application {status_label} email sent', email_to, email_cc)
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(
f'Application {status_label} email sent', email_to, email_cc,
['Attached: Approved Items Report'] if attachment_ids else None,
)
return True
except Exception as e:
_logger.error(f"Failed to send approval email for {self.name}: {e}")
@@ -5492,6 +5681,18 @@ class SaleOrder(models.Model):
# ==========================================================================
# OVERRIDE WRITE
# ==========================================================================
def web_save(self, vals, specification):
"""TEMP DEBUG: Intercept web_save to diagnose 'Missing required fields' on old orders."""
_logger.warning(
"DEBUG web_save() on %s: vals keys = %s",
[r.name for r in self], list(vals.keys())
)
try:
return super().web_save(vals, specification)
except Exception as e:
_logger.error("DEBUG web_save() FAILED on %s: %s", [r.name for r in self], e)
raise
def write(self, vals):
"""Override write to handle ADP status changes, date auto-population, and document tracking."""
from datetime import date as date_class
@@ -5690,19 +5891,17 @@ class SaleOrder(models.Model):
label = document_labels.get(field_name, field_name)
if old_data and new_data:
# REPLACEMENT: Old document being replaced with new one
# Preserve old document in chatter as attachment
order._post_document_to_chatter(
field_name,
f"{label} (replaced)"
field_name,
f"{label} (replaced)",
preserve_copy=True,
)
elif old_data and not new_data:
# DELETION: Document is being deleted
# Preserve the deleted document in chatter
order._post_document_to_chatter(
field_name,
f"{label} (DELETED)"
field_name,
f"{label} (DELETED)",
preserve_copy=True,
)
# Post deletion notice
@@ -5737,11 +5936,17 @@ class SaleOrder(models.Model):
for order in self:
# Post existing final application to chatter before clearing
if order.x_fc_final_submitted_application:
order._post_document_to_chatter('x_fc_final_submitted_application',
'Final Application (before correction)')
order._post_document_to_chatter(
'x_fc_final_submitted_application',
'Final Application (before correction)',
preserve_copy=True,
)
if order.x_fc_xml_file:
order._post_document_to_chatter('x_fc_xml_file',
'XML File (before correction)')
order._post_document_to_chatter(
'x_fc_xml_file',
'XML File (before correction)',
preserve_copy=True,
)
# Clear the document fields AND submission date
# Use _correction_cleared to prevent the audit trail from posting duplicates
@@ -6131,8 +6336,9 @@ class SaleOrder(models.Model):
for order in self:
order._send_correction_needed_email()
elif new_app_status == 'case_closed':
for order in self:
order._send_case_closed_email()
if not self.env.context.get('skip_status_emails'):
for order in self:
order._send_case_closed_email()
# ==================================================================
# MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS
@@ -6679,7 +6885,7 @@ class SaleOrder(models.Model):
for order in orders_to_close:
try:
# Use context to skip status validation for automated process
order.with_context(skip_status_validation=True).write({
order.with_context(skip_status_validation=True, skip_status_emails=True).write({
'x_fc_adp_application_status': 'case_closed',
})
@@ -6725,7 +6931,7 @@ class SaleOrder(models.Model):
if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received':
continue
try:
order._odsp_advance_status(
order.with_context(skip_status_emails=True)._odsp_advance_status(
'case_closed',
"Case automatically closed 7 days after %s." % status.replace('_', ' '),
)

View File

@@ -306,6 +306,49 @@ class SaleOrderLine(models.Model):
# 5. Final fallback - return default_code even if not in ADP database
return self.product_id.default_code or ''
def _get_adp_code_for_report(self):
"""Return the ADP device code for display on reports.
Uses the product's x_fc_adp_device_code field (not default_code).
Returns 'NON-FUNDED' for non-ADP products.
"""
self.ensure_one()
if not self.product_id:
return 'NON-FUNDED'
if self.product_id.is_non_adp_funded():
return 'NON-FUNDED'
product_tmpl = self.product_id.product_tmpl_id
code = ''
if hasattr(product_tmpl, 'x_fc_adp_device_code'):
code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
if not code and hasattr(product_tmpl, 'x_adp_code'):
code = getattr(product_tmpl, 'x_adp_code', '') or ''
if not code:
return 'NON-FUNDED'
ADPDevice = self.env['fusion.adp.device.code'].sudo()
if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
return 'NON-FUNDED'
def _get_adp_device_type(self):
"""Live lookup of device type from the ADP device code table.
Returns 'No Funding Available' for non-ADP products.
"""
self.ensure_one()
if not self.product_id or self.product_id.is_non_adp_funded():
return 'No Funding Available'
code = self._get_adp_code_for_report()
if code == 'NON-FUNDED':
return 'No Funding Available'
if self.x_fc_adp_device_type:
return self.x_fc_adp_device_type
adp_device = self.env['fusion.adp.device.code'].sudo().search([
('device_code', '=', code),
('active', '=', True),
], limit=1)
return adp_device.device_type if adp_device else 'No Funding Available'
def _get_serial_number(self):
"""Get serial number from mapped field or native field."""
self.ensure_one()

View File

@@ -350,6 +350,13 @@ class FusionTechnicianTask(models.Model):
compute='_compute_address_display',
)
# In-store flag -- uses company address instead of client address
is_in_store = fields.Boolean(
string='In Store',
default=False,
help='Task takes place at the store/office. Uses company address automatically.',
)
# Geocoding
address_lat = fields.Float(string='Latitude', digits=(10, 7))
address_lng = fields.Float(string='Longitude', digits=(10, 7))
@@ -382,6 +389,30 @@ class FusionTechnicianTask(models.Model):
string='Completed At',
tracking=True,
)
# GPS location captured at task actions
started_latitude = fields.Float(
string='Started Latitude', digits=(10, 7), readonly=True,
)
started_longitude = fields.Float(
string='Started Longitude', digits=(10, 7), readonly=True,
)
completed_latitude = fields.Float(
string='Completed Latitude', digits=(10, 7), readonly=True,
)
completed_longitude = fields.Float(
string='Completed Longitude', digits=(10, 7), readonly=True,
)
action_latitude = fields.Float(
string='Last Action Latitude', digits=(10, 7), readonly=True,
)
action_longitude = fields.Float(
string='Last Action Longitude', digits=(10, 7), readonly=True,
)
action_location_accuracy = fields.Float(
string='Location Accuracy (m)', readonly=True,
)
voice_note_audio = fields.Binary(
string='Voice Recording',
attachment=True,
@@ -961,9 +992,21 @@ class FusionTechnicianTask(models.Model):
# ONCHANGE - Auto-fill address from client
# ------------------------------------------------------------------
@api.onchange('is_in_store')
def _onchange_is_in_store(self):
"""Auto-fill company address when task is marked as in-store."""
if self.is_in_store:
company_partner = self.env.company.partner_id
if company_partner and company_partner.street:
self._fill_address_from_partner(company_partner)
else:
self.address_street = self.env.company.name or 'In Store'
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Auto-fill address fields from the selected client's address."""
if self.is_in_store:
return
if self.partner_id:
addr = self.partner_id
self.address_partner_id = addr.id
@@ -1046,6 +1089,19 @@ class FusionTechnicianTask(models.Model):
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
))
@api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store')
def _check_address_required(self):
"""Non-in-store tasks must have a geocoded address."""
for task in self:
if task.x_fc_sync_source:
continue
if task.is_in_store:
continue
if not task.address_street:
raise ValidationError(_(
"A valid address is required. If this task is at the store, "
"please check the 'In Store' option."
))
@api.constrains('technician_id', 'additional_technician_ids',
'scheduled_date', 'time_start', 'time_end')
@@ -1334,6 +1390,14 @@ class FusionTechnicianTask(models.Model):
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
vals['x_fc_sync_uuid'] = str(uuid.uuid4())
# In-store tasks: auto-fill company address
if vals.get('is_in_store') and not vals.get('address_street'):
company_partner = self.env.company.partner_id
if company_partner and company_partner.street:
self._fill_address_vals(vals, company_partner)
else:
vals['address_street'] = self.env.company.name or 'In Store'
# Auto-populate address from sale order if not provided
if vals.get('sale_order_id') and not vals.get('address_street'):
order = self.env['sale.order'].browse(vals['sale_order_id'])
@@ -1676,6 +1740,22 @@ class FusionTechnicianTask(models.Model):
"Please complete previous task %s first before starting this one."
) % earlier_incomplete.name)
def _write_action_location(self, extra_vals=None):
"""Write GPS coordinates from context onto the task record."""
ctx = self.env.context
lat = ctx.get('action_latitude', 0)
lng = ctx.get('action_longitude', 0)
acc = ctx.get('action_accuracy', 0)
vals = {
'action_latitude': lat,
'action_longitude': lng,
'action_location_accuracy': acc,
}
if extra_vals:
vals.update(extra_vals)
if lat and lng:
self.with_context(skip_travel_recalc=True).write(vals)
def action_start_en_route(self):
"""Mark task as En Route."""
for task in self:
@@ -1683,6 +1763,7 @@ class FusionTechnicianTask(models.Model):
raise UserError(_("Only scheduled tasks can be marked as En Route."))
task._check_previous_tasks_completed()
task.status = 'en_route'
task._write_action_location()
task._post_status_message('en_route')
def action_start_task(self):
@@ -1692,6 +1773,11 @@ class FusionTechnicianTask(models.Model):
raise UserError(_("Task must be scheduled or en route to start."))
task._check_previous_tasks_completed()
task.status = 'in_progress'
ctx = self.env.context
task._write_action_location({
'started_latitude': ctx.get('action_latitude', 0),
'started_longitude': ctx.get('action_longitude', 0),
})
task._post_status_message('in_progress')
def action_view_sale_order(self):
@@ -1742,9 +1828,15 @@ class FusionTechnicianTask(models.Model):
"technician portal first."
))
ctx = self.env.context
task.with_context(skip_travel_recalc=True).write({
'status': 'completed',
'completion_datetime': fields.Datetime.now(),
'completed_latitude': ctx.get('action_latitude', 0),
'completed_longitude': ctx.get('action_longitude', 0),
'action_latitude': ctx.get('action_latitude', 0),
'action_longitude': ctx.get('action_longitude', 0),
'action_location_accuracy': ctx.get('action_accuracy', 0),
})
task._post_status_message('completed')
if task.completion_notes and (task.sale_order_id or task.purchase_order_id):
@@ -1811,6 +1903,7 @@ class FusionTechnicianTask(models.Model):
if task.status == 'completed':
raise UserError(_("Cannot cancel a completed task."))
task.status = 'cancelled'
task._write_action_location()
task._post_status_message('cancelled')
# If this was a delivery task linked to a sale order that is
# currently in "Ready for Delivery" -- revert the order back.
@@ -2302,20 +2395,144 @@ class FusionTechnicianTask(models.Model):
base_domain,
['name', 'partner_id', 'technician_id', 'task_type',
'address_lat', 'address_lng', 'address_display',
'time_start', 'time_start_display', 'time_end_display',
'time_start', 'time_end', 'time_start_display', 'time_end_display',
'status', 'scheduled_date', 'travel_time_minutes',
'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'],
order='scheduled_date asc NULLS LAST, time_start asc',
limit=500,
)
locations = self.env['fusion.technician.location'].get_latest_locations()
tech_starts = self._get_tech_start_locations(tasks, api_key)
return {
'api_key': api_key,
'tasks': tasks,
'locations': locations,
'local_instance_id': local_instance,
'tech_start_locations': tech_starts,
}
@api.model
def _get_tech_start_locations(self, tasks, api_key):
"""Build a dict of technician start locations for route origins.
Priority per technician:
1. Today's fusion_clock check-in location (if module installed)
2. Personal start address (x_fc_start_address with cached lat/lng)
3. Company default HQ address
"""
tech_ids = {
t['technician_id'][0]
for t in tasks
if t.get('technician_id')
}
if not tech_ids:
return {}
result = {}
today = fields.Date.today()
clock_locations = self._get_clock_in_locations(tech_ids, today)
hq_address = (
self.env['ir.config_parameter'].sudo()
.get_param('fusion_claims.technician_start_address', '') or ''
).strip()
hq_lat, hq_lng = 0.0, 0.0
for uid in tech_ids:
if uid in clock_locations:
result[uid] = clock_locations[uid]
continue
user = self.env['res.users'].sudo().browse(uid)
if not user.exists():
continue
partner = user.partner_id
if partner.x_fc_start_address and partner.x_fc_start_address.strip():
lat = partner.x_fc_start_address_lat
lng = partner.x_fc_start_address_lng
if not lat or not lng:
lat, lng = self._geocode_address_string(
partner.x_fc_start_address, api_key)
if lat and lng:
partner.sudo().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
if lat and lng:
result[uid] = {
'lat': lat, 'lng': lng,
'address': partner.x_fc_start_address.strip(),
'source': 'start_address',
}
continue
if hq_address:
if not hq_lat and not hq_lng:
hq_lat, hq_lng = self._geocode_address_string(
hq_address, api_key)
if hq_lat and hq_lng:
result[uid] = {
'lat': hq_lat, 'lng': hq_lng,
'address': hq_address,
'source': 'company_hq',
}
return result
@api.model
def _get_clock_in_locations(self, tech_ids, today):
"""Get today's clock-in lat/lng from fusion_clock if installed."""
result = {}
try:
module = self.env['ir.module.module'].sudo().search([
('name', '=', 'fusion_clock'),
('state', '=', 'installed'),
], limit=1)
if not module:
return result
except Exception:
return result
try:
Attendance = self.env['hr.attendance'].sudo()
Employee = self.env['hr.employee'].sudo()
except KeyError:
return result
employees = Employee.search([
('user_id', 'in', list(tech_ids)),
])
emp_to_user = {e.id: e.user_id.id for e in employees}
if not employees:
return result
today_start = dt_datetime.combine(today, dt_datetime.min.time())
today_end = today_start + timedelta(days=1)
attendances = Attendance.search([
('employee_id', 'in', employees.ids),
('check_in', '>=', today_start),
('check_in', '<', today_end),
], order='check_in asc')
for att in attendances:
uid = emp_to_user.get(att.employee_id.id)
if not uid or uid in result:
continue
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
if loc and loc.latitude and loc.longitude:
result[uid] = {
'lat': loc.latitude,
'lng': loc.longitude,
'address': loc.address or loc.name or '',
'source': 'clock_in',
}
return result
def _geocode_address(self):
"""Geocode the task address using Google Geocoding API."""
self.ensure_one()
@@ -2573,12 +2790,14 @@ class FusionTechnicianTask(models.Model):
return f'{display_hour}:{minutes:02d} {period}'
def get_google_maps_url(self):
"""Get Google Maps navigation URL. Uses lat/lng coordinates to
navigate to the exact location (text addresses cause Google to
resolve to nearby business names instead)."""
"""Get Google Maps navigation URL using the text address so the
destination shows a proper street name instead of raw coordinates.
Returns a google.com/maps URL that Android auto-opens in the app;
iOS handling is done client-side via JS to launch comgooglemaps://."""
self.ensure_one()
if self.address_display:
addr = urllib.parse.quote(self.address_display)
return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving'
if self.address_lat and self.address_lng:
return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving'
elif self.address_display:
return f'https://www.google.com/maps/dir/?api=1&destination={urllib.parse.quote(self.address_display)}&travelmode=driving'
return ''

View File

@@ -32,7 +32,7 @@
<field name="binding_type">report</field>
</record>
<!-- Landscape report - REMOVED FROM MENU (no binding) -->
<!-- Landscape ADP report - also attached to quotation/order emails -->
<record id="action_report_saleorder_landscape" model="ir.actions.report">
<field name="name">Quotation / Order (Landscape - ADP)</field>
<field name="model">sale.order</field>
@@ -40,7 +40,7 @@
<field name="report_name">fusion_claims.report_saleorder_landscape</field>
<field name="report_file">fusion_claims.report_saleorder_landscape</field>
<field name="print_report_name">'%s - %s' % (object.name, object.partner_id.name)</field>
<!-- No binding_model_id - removed from print menu -->
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
</record>
@@ -127,19 +127,6 @@
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Rental Agreement Report -->
<!-- =============================================================== -->
<record id="action_report_rental_agreement" model="ir.actions.report">
<field name="name">Rental Agreement</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_rental_agreement</field>
<field name="report_file">fusion_claims.report_rental_agreement</field>
<field name="print_report_name">'Rental Agreement - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Grab Bar Installation Waiver Report -->
@@ -169,6 +156,21 @@
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Approved Items Report (Landscape) -->
<!-- =============================================================== -->
<record id="action_report_approved_items" model="ir.actions.report">
<field name="name">Approved Items Report</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_approved_items</field>
<field name="report_file">fusion_claims.report_approved_items</field>
<field name="print_report_name">'Approved Items - %s - %s' % (object.name, object.partner_id.name)</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
</record>
<!-- =============================================================== -->
<!-- March of Dimes Quotation Report -->
<!-- =============================================================== -->

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Approved Items Report - Landscape
-->
<odoo>
<template id="report_approved_items">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<t t-set="is_deduction" t-value="doc.x_fc_adp_application_status == 'approved_deduction'"/>
<t t-set="lines" t-value="doc.order_line.filtered(lambda l: l.product_id and l.display_type not in ('line_section', 'line_note'))"/>
<t t-set="has_deduction" t-value="any(l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' for l in lines)"/>
<style>
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-ai h2 { color: #0066a1; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
.fc-ai table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-ai table.bordered, .fc-ai table.bordered th, .fc-ai table.bordered td { border: 1px solid #000; }
.fc-ai th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-ai td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-ai .text-center { text-align: center; }
.fc-ai .text-end { text-align: right; }
.fc-ai .info-label { font-weight: bold; background-color: #f5f5f5; width: 18%; }
.fc-ai .alt-row { background-color: #f7fafc; }
.fc-ai .total-row { background-color: #edf2f7; font-weight: bold; }
.fc-ai .status-approved { color: #38a169; font-weight: bold; }
.fc-ai .status-deduction { color: #d69e2e; font-weight: bold; }
</style>
<div class="fc-ai">
<div class="page">
<div style="height: 30px;"></div>
<h2>APPROVED ITEMS REPORT</h2>
<!-- Case Info -->
<table class="bordered" style="margin-bottom: 15px;">
<thead>
<tr>
<th colspan="4">CASE DETAILS</th>
</tr>
</thead>
<tbody>
<tr>
<td class="info-label">Case</td>
<td><t t-esc="doc.name"/></td>
<td class="info-label">Client</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td class="info-label">Claim Number</td>
<td><t t-esc="doc.x_fc_claim_number or 'N/A'"/></td>
<td class="info-label">Status</td>
<td>
<t t-if="is_deduction">
<span class="status-deduction">Approved with Deduction</span>
</t>
<t t-else="">
<span class="status-approved">Approved</span>
</t>
</td>
</tr>
<tr>
<td class="info-label">Assessment Date</td>
<td>
<t t-if="doc.x_fc_assessment_end_date"><span t-field="doc.x_fc_assessment_end_date" t-options="{'widget': 'date'}"/></t>
<t t-else="">N/A</t>
</td>
<td class="info-label">Approval Date</td>
<td>
<t t-if="doc.x_fc_claim_approval_date"><span t-field="doc.x_fc_claim_approval_date" t-options="{'widget': 'date'}"/></t>
<t t-else="">N/A</t>
</td>
</tr>
<tr>
<td class="info-label">Client Ref 1</td>
<td><t t-esc="doc.x_fc_client_ref_1 or 'N/A'"/></td>
<td class="info-label">Client Ref 2</td>
<td><t t-esc="doc.x_fc_client_ref_2 or 'N/A'"/></td>
</tr>
</tbody>
</table>
<!-- Items Table -->
<table class="bordered">
<thead>
<tr>
<th class="text-center" style="width: 5%;">S/N</th>
<th style="width: 12%;">ADP CODE</th>
<th style="width: 15%;">DEVICE TYPE</th>
<th style="width: 28%;">PRODUCT NAME</th>
<th class="text-end" style="width: 5%;">QTY</th>
<th class="text-end" style="width: 12%;">ADP PORTION</th>
<th class="text-end" style="width: 13%;">CLIENT PORTION</th>
<t t-if="has_deduction">
<th class="text-end" style="width: 10%;">DEDUCTION</th>
</t>
</tr>
</thead>
<tbody>
<t t-set="total_adp" t-value="0"/>
<t t-set="total_client" t-value="0"/>
<t t-set="idx" t-value="0"/>
<t t-foreach="lines" t-as="line">
<t t-set="idx" t-value="idx + 1"/>
<t t-set="total_adp" t-value="total_adp + (line.x_fc_adp_portion or 0)"/>
<t t-set="total_client" t-value="total_client + (line.x_fc_client_portion or 0)"/>
<tr t-attf-class="#{ 'alt-row' if idx % 2 == 0 else '' }">
<td class="text-center"><t t-esc="idx"/></td>
<td><t t-esc="line._get_adp_code_for_report()"/></td>
<td><t t-esc="line._get_adp_device_type()"/></td>
<td><t t-esc="line.product_id.name or '-'"/></td>
<td class="text-end">
<t t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-end">
$<t t-esc="'%.2f' % (line.x_fc_adp_portion or 0)"/>
</td>
<td class="text-end">
$<t t-esc="'%.2f' % (line.x_fc_client_portion or 0)"/>
</td>
<t t-if="has_deduction">
<td class="text-end">
<t t-if="line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value">
<t t-esc="'%.0f' % line.x_fc_deduction_value"/>%
</t>
<t t-elif="line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value">
$<t t-esc="'%.2f' % line.x_fc_deduction_value"/>
</t>
<t t-else="">-</t>
</td>
</t>
</tr>
</t>
<!-- Totals -->
<tr class="total-row">
<td colspan="5" class="text-end" style="border-top: 2px solid #000;">TOTAL</td>
<td class="text-end" style="border-top: 2px solid #000;">
$<t t-esc="'%.2f' % total_adp"/>
</td>
<td class="text-end" style="border-top: 2px solid #000;">
$<t t-esc="'%.2f' % total_client"/>
</td>
<t t-if="has_deduction">
<td style="border-top: 2px solid #000;"></td>
</t>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -1,365 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Rental Agreement Document - Compact 2-Page Layout
-->
<odoo>
<template id="report_rental_agreement">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<style>
.fc-rental { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
.fc-rental h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-rental h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-rental p { margin: 2px 0; text-align: justify; }
.fc-rental .parties { font-size: 8pt; margin-bottom: 8px; }
.fc-rental .intro { margin-bottom: 8px; font-size: 8pt; }
.fc-rental table { width: 100%; border-collapse: collapse; }
.fc-rental table.bordered, .fc-rental table.bordered th, .fc-rental table.bordered td { border: 1px solid #000; }
.fc-rental th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; font-size: 8pt; }
.fc-rental td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
.fc-rental .text-center { text-align: center; }
.fc-rental .info-header { background-color: #f5f5f5; color: #333; font-weight: bold; }
/* Two-column layout for terms */
.fc-rental .terms-container { column-count: 2; column-gap: 20px; margin-top: 10px; }
.fc-rental .term-section { break-inside: avoid; margin-bottom: 8px; }
/* Credit card section - 15% taller */
.fc-rental .cc-section { margin-top: 12px; padding: 12px; border: 2px solid #0066a1; background-color: #f8f9fa; }
.fc-rental .cc-title { font-size: 10pt; font-weight: bold; color: #0066a1; margin-bottom: 10px; text-align: center; }
.fc-rental .cc-box { border: 1px solid #000; display: inline-block; width: 21px; height: 21px; text-align: center; background: white; }
.fc-rental .authorization-text { font-size: 7pt; margin-top: 10px; font-style: italic; }
/* Signature - 40% taller */
.fc-rental .signature-section { margin-top: 15px; }
.fc-rental .signature-box { border: 1px solid #000; padding: 12px; }
.fc-rental .signature-line { border-bottom: 1px solid #000; min-height: 35px; margin-bottom: 5px; }
.fc-rental .signature-label { font-size: 7pt; color: #666; }
</style>
<div class="fc-rental">
<div class="page">
<!-- ============================================================ -->
<!-- PAGE 1: TERMS AND CONDITIONS -->
<!-- ============================================================ -->
<h1>RENTAL AGREEMENT</h1>
<!-- Parties - Compact -->
<div class="parties">
<strong>BETWEEN:</strong> <t t-esc="company.name"/> ("Company")
<strong style="margin-left: 20px;">AND:</strong> <t t-esc="doc.partner_id.name"/> ("Renter")
</div>
<!-- Introduction -->
<div class="intro">
<p><t t-esc="company.name"/> rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.</p>
</div>
<!-- Terms and Conditions in Two Columns -->
<div class="terms-container">
<div class="term-section">
<h2>1. Ownership and Condition of Equipment</h2>
<p>The medical equipment is the property of <t t-esc="company.name"/> and is provided in good condition. The Renter shall return the equipment in the same condition as when received, subject to normal wear and tear. <t t-esc="company.name"/> reserves the right to inspect the equipment upon its return and may repossess it without prior notice if it is being used in violation of this agreement.</p>
</div>
<div class="term-section">
<h2>2. Cancellation Policy</h2>
<p>The Renter may cancel the order before delivery and will be charged twenty-five percent (25%) of the total rental cost. If the order is canceled during the rental period after delivery, no refund will be provided.</p>
</div>
<div class="term-section">
<h2>3. Security Deposit</h2>
<p>The security deposit will be returned after an inspection of the equipment. If the equipment has any damage, the cost of repairs will be deducted from the security deposit. If the security deposit is insufficient to cover the damages, the credit card on file will be charged for the remaining amount. Security deposit refunds may take 4 to 15 business days to process. <t t-esc="company.name"/> is not responsible for delays caused by the Renter's financial institution.</p>
</div>
<div class="term-section">
<h2>4. Liability for Loss or Damage</h2>
<p><t t-esc="company.name"/> shall not be liable for any loss of or damage to property left, lost, damaged, stolen, stored, or transported by the Renter or any other person using the medical equipment. The Renter assumes all risks associated with such loss or damage and waives any claims against <t t-esc="company.name"/>. The Renter agrees to defend, indemnify, and hold <t t-esc="company.name"/> harmless against all claims arising from such loss or damage.</p>
</div>
<div class="term-section">
<h2>5. Risk and Liability</h2>
<p>The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. <t t-esc="company.name"/> is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.</p>
</div>
<div class="term-section">
<h2>6. Renter Responsibilities</h2>
<p>The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. <t t-esc="company.name"/> may charge the Renter's credit card for repair or replacement costs as deemed necessary. The equipment must not be used by individuals under the age of 18, under the influence of intoxicants or narcotics, or in an unsafe manner.</p>
</div>
<div class="term-section">
<h2>7. Indemnification</h2>
<p>The Renter shall indemnify, defend, and hold harmless <t t-esc="company.name"/>, its agents, officers, and employees, from any claims, demands, actions, or causes of action arising from the use or operation of the medical equipment, except where caused by <t t-esc="company.name"/>'s gross negligence or willful misconduct.</p>
</div>
<div class="term-section">
<h2>8. Accident Notification</h2>
<p>The Renter must immediately notify <t t-esc="company.name"/> of any accidents, damages, or incidents involving the medical equipment.</p>
</div>
<div class="term-section">
<h2>9. Costs and Expenses</h2>
<p>The Renter agrees to cover all costs, expenses, and attorney's fees incurred by <t t-esc="company.name"/> in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.</p>
</div>
<div class="term-section">
<h2>10. Independent Status</h2>
<p>The Renter or any driver of the equipment shall not be considered an agent or employee of <t t-esc="company.name"/>.</p>
</div>
<div class="term-section">
<h2>11. Binding Obligations</h2>
<p>Any individual signing this agreement on behalf of a corporation or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter.</p>
</div>
<div class="term-section">
<h2>12. Refusal of Service</h2>
<p><t t-esc="company.name"/> reserves the right to refuse rental to any individual or entity at its sole discretion.</p>
</div>
<div class="term-section">
<h2>13. Governing Law</h2>
<p>This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which <t t-esc="company.name"/> operates.</p>
</div>
<div class="term-section">
<h2>14. Entire Agreement</h2>
<p>This Agreement constitutes the entire understanding between the parties concerning the rental of medical equipment and supersedes all prior agreements, representations, or understandings, whether written or oral.</p>
</div>
</div>
<!-- ============================================================ -->
<!-- PAGE 2: RENTAL DETAILS, PAYMENT, AND SIGNATURE -->
<!-- ============================================================ -->
<div style="page-break-before: always;"></div>
<h1>RENTAL DETAILS</h1>
<!-- Customer Info and Rental Period Side by Side -->
<table style="width: 100%; margin-bottom: 10px;">
<tr>
<td style="width: 50%; vertical-align: top; padding-right: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTER INFORMATION</th>
</tr>
<tr>
<td style="width: 35%; font-weight: bold; background-color: #f5f5f5;">Name</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Address</td>
<td>
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Phone</td>
<td><t t-esc="doc.partner_id.phone or doc.partner_id.mobile or ''"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Order Ref</td>
<td><t t-esc="doc.name"/></td>
</tr>
</table>
</td>
<td style="width: 50%; vertical-align: top; padding-left: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTAL PERIOD</th>
</tr>
<tr>
<td style="width: 40%; font-weight: bold; background-color: #f5f5f5;">Start Date</td>
<td>
<t t-if="doc.rental_start_date">
<span t-field="doc.rental_start_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Return Date</td>
<td>
<t t-if="doc.rental_return_date">
<span t-field="doc.rental_return_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Duration</td>
<td>
<t t-if="doc.duration_days">
<span t-esc="doc.duration_days"/> Day<t t-if="doc.duration_days != 1">s</t>
<t t-if="doc.remaining_hours and doc.remaining_hours > 0">
, <t t-esc="doc.remaining_hours"/> Hr<t t-if="doc.remaining_hours != 1">s</t>
</t>
</t>
<t t-elif="doc.rental_start_date and doc.rental_return_date"><span>Less than 1 day</span></t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Total Amount</td>
<td><strong><span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></strong></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Equipment List - Compact -->
<table class="bordered" style="margin-bottom: 10px;">
<thead>
<tr>
<th class="text-center" style="width: 15%;">PRODUCT CODE</th>
<th style="width: 55%;">DESCRIPTION</th>
<th class="text-center" style="width: 15%;">SERIAL #</th>
<th class="text-center" style="width: 15%;">QTY</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="not line.display_type">
<tr>
<td class="text-center">
<span t-esc="line.product_id.default_code or ''"/>
</td>
<td>
<t t-if="line.name">
<t t-set="clean_name" t-value="line.name"/>
<t t-if="'] ' in clean_name">
<t t-set="clean_name" t-value="clean_name.split('] ', 1)[1]"/>
</t>
<t t-if="' to ' in clean_name and '\n' in clean_name">
<t t-set="clean_name" t-value="clean_name.split('\n')[0]"/>
</t>
<t t-esc="clean_name"/>
</t>
</td>
<td class="text-center">
<span t-esc="line.x_fc_serial_number or ''"/>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Credit Card Authorization - Compact -->
<div class="cc-section">
<div class="cc-title">CREDIT CARD PAYMENT AUTHORIZATION</div>
<table style="width: 100%; border: none;">
<tr>
<td style="width: 20%; padding: 5px 4px; border: none;"><strong>Card #:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_payment_token_id">
<span style="font-size: 14px;">**** **** **** <t t-out="doc._get_card_last_four() or '****'">1234</t></span>
</t>
<t t-else="">
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
</t>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Exp Date:</strong></td>
<td style="padding: 5px 4px; border: none;">
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 2px;">/</span>
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin-left: 20px;"><strong>CVV:</strong></span>
<span>***</span>
<t t-set="deposit_lines" t-value="doc.order_line.filtered(lambda l: l.is_security_deposit)"/>
<span style="margin-left: 20px;"><strong>Security Deposit:</strong>
<t t-if="deposit_lines">
$<t t-out="'%.2f' % sum(deposit_lines.mapped('price_unit'))">0.00</t>
</t>
<t t-else="">$___________</t>
</span>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Cardholder:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_agreement_signer_name">
<span t-out="doc.rental_agreement_signer_name">Name</span>
</t>
<t t-else="">
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%;"></div>
</t>
</td>
</tr>
<tr>
<td colspan="2" style="padding: 5px 4px; border: none;">
<strong>Billing Address (if different):</strong>
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%; margin-top: 4px;"></div>
</td>
</tr>
</table>
<div class="authorization-text">
<p>I authorize <t t-esc="company.name"/> to charge the credit card indicated in this authorization form according to the terms outlined above. I certify that I am an authorized user of this credit card and will not dispute the payment. By signing this form, I acknowledge that I have read the rental agreement and understand the terms and conditions. I understand that if the rented item is not returned on the agreed return date, additional charges will be incurred. *Payments for monthly rental items will be charged on the re-rental date until the item is returned.</p>
</div>
</div>
<!-- Signature Section - Compact -->
<div class="signature-section">
<div class="signature-box">
<table style="width: 100%; border: none;">
<tr>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">FULL NAME (PRINT)</div>
<t t-if="doc.rental_agreement_signer_name">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signer_name">Name</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">SIGNATURE</div>
<t t-if="doc.rental_agreement_signature">
<img t-att-src="'data:image/png;base64,' + doc.rental_agreement_signature.decode('utf-8') if doc.rental_agreement_signature else ''" style="max-height: 50px; max-width: 100%;"/>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 20%; padding: 5px; border: none;">
<div class="signature-label">DATE</div>
<t t-if="doc.rental_agreement_signed_date">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -320,6 +320,25 @@ $transition-speed: .25s;
.fa { opacity: .8; }
}
.fc_task_edit_btn {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 600;
color: var(--btn-primary-color, #fff);
background: var(--btn-primary-bg, #{$primary});
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
transition: all .15s;
&:hover {
opacity: .85;
filter: brightness(1.15);
}
}
// ── Map area ────────────────────────────────────────────────────────
.fc_map_area {
flex: 1 1 auto;
@@ -341,15 +360,21 @@ $transition-speed: .25s;
min-height: 400px;
}
// ── Google Maps InfoWindow override (always light bg) ───────────────
// InfoWindow is rendered by Google outside our DOM; we style via
// the .gm-style-iw container that Google injects.
// ── Google Maps InfoWindow override ──────────────────────────────────
.gm-style-iw-d {
overflow: auto !important;
}
.gm-style .gm-style-iw-c {
padding: 0 !important;
border-radius: 10px !important;
overflow: hidden !important;
box-shadow: 0 4px 20px rgba(0,0,0,.15) !important;
}
.gm-style .gm-style-iw-tc {
display: none !important;
}
.gm-style .gm-ui-hover-effect {
display: none !important;
}
// ── Responsive ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
import { Record } from "@web/model/relational_model/record";
import { patch } from "@web/core/utils/patch";
patch(Record.prototype, {
_displayInvalidFieldNotification() {
const fieldNames = [];
for (const fieldName of this._invalidFields) {
const fieldDef = this.fields[fieldName];
const label = fieldDef?.string || fieldName;
fieldNames.push(`${label} (${fieldName})`);
}
const message = fieldNames.length
? `Missing required fields:\n${fieldNames.join(", ")}`
: "Missing required fields (unknown)";
console.error("FUSION DEBUG:", message, Array.from(this._invalidFields));
return this.model.notification.add(message, { type: "danger" });
},
});

View File

@@ -203,11 +203,12 @@ function groupTasks(tasksData, localInstanceId) {
};
}
let globalIdx = 0;
const dayCounters = {};
for (const task of sorted) {
globalIdx++;
const g = classifyTask(task);
task._scheduleNum = globalIdx;
const dayKey = task.scheduled_date || "none";
dayCounters[dayKey] = (dayCounters[dayKey] || 0) + 1;
task._scheduleNum = dayCounters[dayKey];
task._group = g;
task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day
task._statusColor = STATUS_COLORS[task.status] || "#6b7280";
@@ -255,6 +256,7 @@ export class FusionTaskMapController extends Component {
showTasks: true,
showTechnicians: true,
showTraffic: true,
showRoute: true,
taskCount: 0,
techCount: 0,
// Sidebar
@@ -264,11 +266,11 @@ export class FusionTaskMapController extends Component {
activeTaskId: null, // Highlighted task
// Day filters for map pins (which groups show on map)
visibleGroups: {
[GROUP_YESTERDAY]: false, // hidden by default
[GROUP_YESTERDAY]: false,
[GROUP_TODAY]: true,
[GROUP_TOMORROW]: true,
[GROUP_THIS_WEEK]: false, // hidden by default
[GROUP_LATER]: false, // hidden by default
[GROUP_TOMORROW]: false,
[GROUP_THIS_WEEK]: false,
[GROUP_LATER]: false,
},
});
@@ -280,7 +282,11 @@ export class FusionTaskMapController extends Component {
this.taskMarkers = [];
this.taskMarkerMap = {}; // id → marker
this.techMarkers = [];
this.routeLines = []; // route polylines
this.routeLabels = []; // travel time overlay labels
this.routeAnimFrameId = null;
this.infoWindow = null;
this.techStartLocations = {};
this.apiKey = "";
this.tasksData = [];
this.locationsData = [];
@@ -312,6 +318,7 @@ export class FusionTaskMapController extends Component {
});
onWillUnmount(() => {
this._clearMarkers();
this._clearRoute();
window.__fusionMapOpenTask = () => {};
});
}
@@ -327,17 +334,22 @@ export class FusionTaskMapController extends Component {
}
// ── Data ─────────────────────────────────────────────────────────
_storeResult(result) {
this.localInstanceId = result.local_instance_id || this.localInstanceId || "";
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.techStartLocations = result.tech_start_locations || {};
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
}
async _loadAndRender() {
try {
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this.apiKey = result.api_key;
this.localInstanceId = result.local_instance_id || "";
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
this._storeResult(result);
if (!this.apiKey) {
this.state.error = _t("Google Maps API key not configured. Go to Settings > Fusion Claims.");
@@ -345,7 +357,11 @@ export class FusionTaskMapController extends Component {
return;
}
await loadGoogleMaps(this.apiKey);
if (this.mapRef.el) this._initMap();
if (this.map) {
this._renderMarkers();
} else if (this.mapRef.el) {
this._initMap();
}
this.state.loading = false;
} catch (e) {
console.error("FusionTaskMap load error:", e);
@@ -354,17 +370,33 @@ export class FusionTaskMapController extends Component {
}
}
async _softRefresh() {
if (!this.map) return;
try {
const center = this.map.getCenter();
const zoom = this.map.getZoom();
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this._storeResult(result);
this._placeMarkers();
if (center && zoom != null) {
this.map.setCenter(center);
this.map.setZoom(zoom);
}
} catch (e) {
console.error("FusionTaskMap soft refresh error:", e);
}
}
async _onModelUpdate() {
if (!this.map) return;
try {
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this.localInstanceId = result.local_instance_id || this.localInstanceId || "";
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
this._storeResult(result);
this._renderMarkers();
} catch (e) {
console.error("FusionTaskMap update error:", e);
@@ -407,12 +439,27 @@ export class FusionTaskMapController extends Component {
this.techMarkers = [];
}
_renderMarkers() {
this._clearMarkers();
_clearRoute() {
if (this.routeAnimFrameId) {
cancelAnimationFrame(this.routeAnimFrameId);
this.routeAnimFrameId = null;
}
for (const l of this.routeLines) l.setMap(null);
this.routeLines = [];
for (const lb of this.routeLabels) lb.setMap(null);
this.routeLabels = [];
}
_placeMarkers() {
for (const m of this.taskMarkers) m.setMap(null);
for (const m of this.techMarkers) m.setMap(null);
this.taskMarkers = [];
this.taskMarkerMap = {};
this.techMarkers = [];
const bounds = new google.maps.LatLngBounds();
let hasBounds = false;
// Task pins: only show groups that are enabled in the day filter
if (this.state.showTasks) {
for (const group of this.state.groups) {
const groupVisible = this.state.visibleGroups[group.key] !== false;
@@ -444,7 +491,6 @@ export class FusionTaskMapController extends Component {
}
}
// Technician markers
if (this.state.showTechnicians) {
for (const loc of this.locationsData) {
if (!loc.latitude || !loc.longitude) continue;
@@ -485,45 +531,410 @@ export class FusionTaskMapController extends Component {
}
}
const starts = this.techStartLocations || {};
for (const uid of Object.keys(starts)) {
const sl = starts[uid];
if (sl && sl.lat && sl.lng) {
bounds.extend({ lat: sl.lat, lng: sl.lng });
hasBounds = true;
}
}
return { bounds, hasBounds };
}
_renderMarkers() {
this._clearRoute();
const { bounds, hasBounds } = this._placeMarkers();
if (this.state.showRoute && this.state.showTasks) {
this._renderRoute();
}
if (hasBounds) {
this.map.fitBounds(bounds);
if (this.taskMarkers.length + this.techMarkers.length === 1) {
this.map.setZoom(14);
try {
this.map.fitBounds(bounds);
if (this.taskMarkers.length + this.techMarkers.length === 1) {
this.map.setZoom(14);
}
} catch (_e) {
// bounds not ready yet
}
}
}
_renderRoute() {
this._clearRoute();
const routeSegments = {};
for (const group of this.state.groups) {
if (this.state.visibleGroups[group.key] === false) continue;
for (const task of group.tasks) {
if (!task._hasCoords) continue;
const techId = task.technician_id ? task.technician_id[0] : 0;
if (!techId) continue;
const dayKey = task.scheduled_date || "none";
const segKey = `${techId}_${dayKey}`;
if (!routeSegments[segKey]) {
routeSegments[segKey] = {
name: task._techName, day: dayKey,
techId, tasks: [],
};
}
routeSegments[segKey].tasks.push(task);
}
}
const LEG_COLORS = [
"#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899",
"#f97316", "#0ea5e9", "#d946ef", "#06b6d4",
"#a855f7", "#6366f1", "#eab308", "#0284c7",
"#c026d3", "#7c3aed", "#2563eb", "#db2777",
"#9333ea", "#0891b2", "#4f46e5", "#be185d",
];
let globalLegIdx = 0;
if (!this._directionsService) {
this._directionsService = new google.maps.DirectionsService();
}
const allAnimLines = [];
const starts = this.techStartLocations || {};
for (const segKey of Object.keys(routeSegments)) {
const seg = routeSegments[segKey];
const tasks = seg.tasks;
tasks.sort((a, b) => (a.time_start || 0) - (b.time_start || 0));
const startLoc = starts[seg.techId];
const hasStart = startLoc && startLoc.lat && startLoc.lng;
if (tasks.length < 2 && !hasStart) continue;
if (tasks.length < 1) continue;
const segBaseColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length];
let origin, destination, waypoints, hasStartLeg;
if (hasStart) {
origin = { lat: startLoc.lat, lng: startLoc.lng };
destination = {
lat: tasks[tasks.length - 1].address_lat,
lng: tasks[tasks.length - 1].address_lng,
};
waypoints = tasks.slice(0, -1).map(t => ({
location: { lat: t.address_lat, lng: t.address_lng },
stopover: true,
}));
hasStartLeg = true;
} else {
origin = { lat: tasks[0].address_lat, lng: tasks[0].address_lng };
destination = {
lat: tasks[tasks.length - 1].address_lat,
lng: tasks[tasks.length - 1].address_lng,
};
waypoints = tasks.slice(1, -1).map(t => ({
location: { lat: t.address_lat, lng: t.address_lng },
stopover: true,
}));
hasStartLeg = false;
}
if (hasStart) {
const startSvg =
`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
`<circle cx="18" cy="18" r="16" fill="${segBaseColor}" stroke="#fff" stroke-width="3"/>` +
`<text x="18" y="23" text-anchor="middle" fill="#fff" font-size="16" font-family="Arial,sans-serif">&#x2302;</text>` +
`</svg>`;
const startMarker = new google.maps.Marker({
position: origin,
map: this.map,
title: `${seg.name} - Start`,
icon: {
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(startSvg),
scaledSize: new google.maps.Size(32, 32),
anchor: new google.maps.Point(16, 16),
},
zIndex: 5,
});
startMarker.addListener("click", () => {
this.infoWindow.setContent(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:180px;">
<div style="background:${segBaseColor};color:#fff;padding:8px 12px;border-radius:6px 6px 0 0;">
<strong>${seg.name} - Start</strong>
</div>
<div style="padding:8px 12px;font-size:13px;">
${startLoc.address || 'Start location'}
<div style="color:#6b7280;margin-top:4px;font-size:11px;">${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}</div>
</div>
</div>`);
this.infoWindow.open(this.map, startMarker);
});
this.routeLines.push(startMarker);
}
this._directionsService.route({
origin,
destination,
waypoints,
optimizeWaypoints: false,
travelMode: google.maps.TravelMode.DRIVING,
avoidTolls: true,
drivingOptions: {
departureTime: new Date(),
trafficModel: "bestguess",
},
}, (result, status) => {
if (status !== "OK" || !result.routes || !result.routes[0]) return;
const route = result.routes[0];
for (let li = 0; li < route.legs.length; li++) {
const leg = route.legs[li];
const legColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length];
globalLegIdx++;
const legPath = [];
for (const step of leg.steps) {
for (const pt of step.path) legPath.push(pt);
}
if (legPath.length < 2) continue;
const baseLine = new google.maps.Polyline({
path: legPath, map: this.map,
strokeColor: legColor, strokeOpacity: 0.25, strokeWeight: 6,
zIndex: 1,
});
this.routeLines.push(baseLine);
const animLine = new google.maps.Polyline({
path: legPath, map: this.map,
strokeOpacity: 0, strokeWeight: 0, zIndex: 2,
icons: [{
icon: {
path: "M 0,-0.5 0,0.5",
strokeOpacity: 0.8, strokeColor: legColor,
strokeWeight: 3, scale: 4,
},
offset: "0%", repeat: "16px",
}],
});
this.routeLines.push(animLine);
allAnimLines.push(animLine);
const arrowLine = new google.maps.Polyline({
path: legPath, map: this.map,
strokeOpacity: 0, strokeWeight: 0, zIndex: 3,
icons: [{
icon: {
path: google.maps.SymbolPath.FORWARD_OPEN_ARROW,
scale: 3, strokeColor: legColor,
strokeOpacity: 0.9, strokeWeight: 2.5,
},
offset: "0%", repeat: "80px",
}],
});
this.routeLines.push(arrowLine);
allAnimLines.push(arrowLine);
const dur = leg.duration_in_traffic || leg.duration;
const dist = leg.distance;
if (dur) {
const totalMins = Math.round(dur.value / 60);
const totalKm = dist ? (dist.value / 1000).toFixed(1) : null;
const destIdx = hasStartLeg ? li : li + 1;
const destTask = destIdx < tasks.length ? tasks[destIdx] : tasks[tasks.length - 1];
const etaFloat = destTask.time_start || 0;
const etaStr = etaFloat ? floatToTime12(etaFloat) : "";
const techName = seg.name;
this.routeLabels.push(this._createTravelLabel(
legPath, totalMins, totalKm, legColor, techName, etaStr,
));
}
}
if (!this.routeAnimFrameId) {
this._startRouteAnimation(allAnimLines);
}
});
}
}
_pointAlongLeg(leg, fraction) {
const points = [];
for (const step of leg.steps) {
for (const pt of step.path) {
points.push(pt);
}
}
if (points.length < 2) return leg.start_location;
const segDists = [];
let totalDist = 0;
for (let i = 1; i < points.length; i++) {
const d = google.maps.geometry
? google.maps.geometry.spherical.computeDistanceBetween(points[i - 1], points[i])
: this._haversine(points[i - 1], points[i]);
segDists.push(d);
totalDist += d;
}
const target = totalDist * fraction;
let acc = 0;
for (let i = 0; i < segDists.length; i++) {
if (acc + segDists[i] >= target) {
const remain = target - acc;
const ratio = segDists[i] > 0 ? remain / segDists[i] : 0;
return new google.maps.LatLng(
points[i].lat() + (points[i + 1].lat() - points[i].lat()) * ratio,
points[i].lng() + (points[i + 1].lng() - points[i].lng()) * ratio,
);
}
acc += segDists[i];
}
return points[points.length - 1];
}
_haversine(a, b) {
const R = 6371000;
const dLat = (b.lat() - a.lat()) * Math.PI / 180;
const dLng = (b.lng() - a.lng()) * Math.PI / 180;
const s = Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat() * Math.PI / 180) * Math.cos(b.lat() * Math.PI / 180) *
Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
_createTravelLabel(legPath, mins, km, color, techName, eta) {
if (!this._TravelLabel) {
this._TravelLabel = class extends google.maps.OverlayView {
constructor(path, html) {
super();
this._path = path;
this._html = html;
this._div = null;
}
onAdd() {
this._div = document.createElement("div");
this._div.style.position = "absolute";
this._div.style.whiteSpace = "nowrap";
this._div.style.pointerEvents = "none";
this._div.style.zIndex = "50";
this._div.style.transition = "left .3s ease, top .3s ease";
this._div.innerHTML = this._html;
this.getPanes().floatPane.appendChild(this._div);
}
draw() {
const proj = this.getProjection();
if (!proj || !this._div) return;
const map = this.getMap();
if (!map) return;
const bounds = map.getBounds();
if (!bounds) return;
const visible = this._path.filter(p => bounds.contains(p));
if (visible.length === 0) {
this._div.style.display = "none";
return;
}
this._div.style.display = "";
const anchor = visible[Math.floor(visible.length / 2)];
const px = proj.fromLatLngToDivPixel(anchor);
if (px) {
this._div.style.left = (px.x - this._div.offsetWidth / 2) + "px";
this._div.style.top = (px.y - this._div.offsetHeight - 8) + "px";
}
}
onRemove() {
if (this._div && this._div.parentNode) {
this._div.parentNode.removeChild(this._div);
}
this._div = null;
}
};
}
const timeStr = mins < 60
? `${mins} min`
: `${Math.floor(mins / 60)}h ${mins % 60}m`;
const distStr = km ? `${km} km` : "";
const firstName = techName ? techName.split(" ")[0] : "";
const html = `<div style="
display:inline-flex;align-items:center;gap:5px;
background:#fff;border:2px solid ${color};
border-radius:16px;padding:3px 10px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
font-size:11px;font-weight:700;color:#1f2937;
box-shadow:0 2px 8px rgba(0,0,0,.18);
">${firstName ? `<span style="color:${color};font-weight:600;">${firstName}</span><span style="color:#d1d5db;">|</span>` : ""}<span style="color:${color};">&#x1F697;</span><span>${timeStr}</span>${distStr ? `<span style="color:#9ca3af;font-weight:500;">&#183; ${distStr}</span>` : ""}${eta ? `<span style="color:#d1d5db;">|</span><span style="color:#059669;font-weight:700;">ETA ${eta}</span>` : ""}</div>`;
const label = new this._TravelLabel(legPath, html);
label.setMap(this.map);
return label;
}
_startRouteAnimation(animLines) {
let off = 0;
let last = 0;
const animate = (ts) => {
this.routeAnimFrameId = requestAnimationFrame(animate);
if (ts - last < 50) return;
last = ts;
off = (off + 0.08) % 100;
const pct = off + "%";
for (const line of animLines) {
const icons = line.get("icons");
if (icons && icons.length > 0) {
icons[0].offset = pct;
line.set("icons", icons);
}
}
};
this.routeAnimFrameId = requestAnimationFrame(animate);
}
_openTaskPopup(task, marker) {
const c = task._dayColor;
const sc = task._statusColor;
const navDest = task.address_lat && task.address_lng
? `${task.address_lat},${task.address_lng}`
: encodeURIComponent(task.address_display || "");
const html = `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:270px;max-width:360px;color:#1f2937;position:relative;">
<div style="background:${c};color:#fff;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:14px;">#${task._scheduleNum} &nbsp;${task.name}</strong>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:10px;">${task._statusLabel}</span>
<button onclick="document.querySelector('.gm-ui-hover-effect')?.click()" title="Close"
style="background:rgba(255,255,255,.2);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;">
&times;
</button>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:290px;max-width:360px;color:#1f2937;">
<div style="background:${c};padding:14px 16px 12px;border-radius:0;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<span style="color:rgba(255,255,255,.75);font-size:11px;font-weight:600;letter-spacing:.3px;">#${task._scheduleNum} ${task.name}</span>
<span style="font-size:10px;font-weight:600;background:${sc};color:#fff;padding:2px 10px;border-radius:10px;">${task._statusLabel}</span>
</div>
<div style="color:#fff;font-size:16px;font-weight:700;line-height:1.25;">${task._clientName}</div>
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.9;color:#1f2937;">
<div><strong style="color:#374151;">Client:</strong> <span style="color:#111827;">${task._clientName}</span></div>
<div><strong style="color:#374151;">Type:</strong> <span style="color:#111827;">${task._typeLbl}</span></div>
<div><strong style="color:#374151;">Technician:</strong> <span style="color:#111827;">${task._techName}</span></div>
<div><strong style="color:#374151;">Date:</strong> <span style="color:#111827;">${task.scheduled_date || ""}</span></div>
<div><strong style="color:#374151;">Time:</strong> <span style="color:#111827;">${task._timeRange}</span></div>
${task.address_display ? `<div><strong style="color:#374151;">Address:</strong> <span style="color:#111827;">${task.address_display}</span></div>` : ""}
${task.travel_time_minutes ? `<div><strong style="color:#374151;">Travel:</strong> <span style="color:#111827;">${task.travel_time_minutes} min</span></div>` : ""}
<div style="padding:10px 16px 6px;display:flex;gap:6px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
<span style="opacity:.5;">&#xf02b;</span>${task._typeLbl}
</span>
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
<span style="opacity:.5;">&#xf017;</span>${task._timeRange}
</span>
${task.travel_time_minutes ? `<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;"><span style="opacity:.5;">&#xf1b9;</span>${task.travel_time_minutes} min</span>` : ""}
</div>
<div style="padding:8px 14px 12px;border-top:1px solid #e5e7eb;display:flex;gap:10px;">
<div style="padding:8px 16px 12px;font-size:12px;line-height:1.7;color:#374151;">
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F464;</span><span>${task._techName}</span></div>
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F4C5;</span><span>${task.scheduled_date || "No date"}</span></div>
${task.address_display ? `<div style="display:flex;align-items:flex-start;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;flex-shrink:0;">&#x1F4CD;</span><span>${task.address_display}</span></div>` : ""}
</div>
<div style="padding:6px 16px 14px;display:flex;gap:8px;align-items:center;">
<button onclick="window.__fusionMapOpenTask(${task.id})"
style="background:${c};color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">
style="background:${c};color:#fff;border:none;padding:7px 20px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:opacity .15s;">
Open Task
</button>
<a href="https://www.google.com/maps/dir/?api=1&destination=${task.address_lat && task.address_lng ? task.address_lat + ',' + task.address_lng : encodeURIComponent(task.address_display || "")}"
target="_blank" style="color:${c};text-decoration:none;font-size:13px;font-weight:600;line-height:32px;">
Navigate &rarr;
<a href="https://www.google.com/maps/dir/?api=1&destination=${navDest}"
target="_blank" style="color:${c};text-decoration:none;font-size:12px;font-weight:600;padding:7px 4px;">
Navigate &#x2192;
</a>
</div>
</div>`;
@@ -605,26 +1016,69 @@ export class FusionTaskMapController extends Component {
this.state.showTechnicians = !this.state.showTechnicians;
this._renderMarkers();
}
toggleRoute() {
this.state.showRoute = !this.state.showRoute;
if (this.state.showRoute) {
this._renderRoute();
} else {
this._clearRoute();
}
}
onRefresh() {
this.state.loading = true;
this._loadAndRender();
}
openTask(taskId) {
this.actionService.switchView("form", { resId: taskId });
async openTask(taskId) {
if (!taskId) return;
try {
await this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
res_id: taskId,
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: { dialog_size: "extra-large" },
},
{ onClose: () => this._softRefresh() },
);
} catch (e) {
console.error("[FusionMap] openTask failed:", e);
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
res_id: taskId,
view_mode: "form",
views: [[false, "form"]],
target: "current",
});
}
}
createNewTask() {
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
views: [[false, "form"]],
target: "new",
context: { default_task_type: "delivery", dialog_size: "extra-large" },
}, {
onClose: () => {
// Refresh map data after dialog closes (task may have been created)
this.onRefresh();
},
});
async createNewTask() {
try {
await this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: { default_task_type: "delivery", dialog_size: "extra-large" },
},
{ onClose: () => this._softRefresh() },
);
} catch (e) {
console.error("[FusionMap] createNewTask failed:", e);
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
view_mode: "form",
views: [[false, "form"]],
target: "current",
context: { default_task_type: "delivery" },
});
}
}
}

View File

@@ -113,6 +113,11 @@
<i class="fa fa-building-o me-1"/>
<t t-esc="task._sourceLabel"/>
</span>
<span class="fc_task_edit_btn"
t-on-click.stop="() => this.openTask(task.id)"
title="Edit task">
<i class="fa fa-pencil me-1"/>Edit
</span>
</div>
</div>
</t>
@@ -170,6 +175,11 @@
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
<span class="flex-grow-1"/>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showRoute ? 'btn-info' : 'btn-outline-secondary'"
t-on-click="toggleRoute" title="Toggle route animation">
<i class="fa fa-road"/>Route
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
t-on-click="toggleTraffic" title="Toggle traffic layer">

View File

@@ -41,15 +41,26 @@
<field name="x_fc_client_type" string="Client Type"
invisible="x_fc_sale_type not in ('adp', 'adp_odsp')"/>
<!-- Delivery Status -->
<field name="x_fc_show_delivery_datetime" invisible="1"/>
<field name="x_fc_delivery_status" string="Delivery Status"/>
<field name="x_fc_delivery_datetime" string="Delivery Date/Time"
invisible="not x_fc_show_delivery_datetime"/>
</xpath>
</field>
</record>
<!-- ===================================================================== -->
<!-- SALE ORDER FORM: Move Salesperson to header (after Quotation Template) -->
<!-- ===================================================================== -->
<record id="view_order_form_fusion_claims_salesperson" model="ir.ui.view">
<field name="name">sale.order.form.fusion.central.salesperson</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">51</field>
<field name="arch" type="xml">
<field name="sale_order_template_id" position="after">
<field name="user_id" widget="many2one_avatar_user"/>
</field>
<xpath expr="//page[@name='other_information']//field[@name='user_id']" position="replace"/>
</field>
</record>
<!-- ===================================================================== -->
<!-- SALE ORDER FORM: March of Dimes Case Details -->
<!-- ===================================================================== -->
@@ -1183,12 +1194,12 @@
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'quotation'"
help="Move to Assessment Scheduled status"/>
<!-- Assessment Scheduled -> Complete Assessment -->
<!-- Assessment Scheduled (or Quotation override) -> Complete Assessment -->
<button name="action_complete_assessment" type="object"
string="Complete Assessment" class="btn-info"
icon="fa-check-square-o"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'assessment_scheduled'"
help="Mark assessment as completed"/>
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('quotation', 'assessment_scheduled')"
help="Mark assessment as completed (override available from Quotation stage)"/>
<!-- Waiting for Application -> Application Received -->
<button name="action_application_received" type="object"
@@ -1260,13 +1271,13 @@
<button name="%(fusion_claims.action_set_status_on_hold)d"
type="action" string="Put On Hold" class="btn-warning"
icon="fa-pause"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('approved', 'approved_deduction', 'ready_delivery', 'ready_bill')"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('submitted', 'resubmitted', 'needs_correction', 'accepted', 'approved', 'approved_deduction', 'ready_delivery', 'ready_bill')"
help="Put this application on hold"/>
<button name="%(fusion_claims.action_set_status_withdrawn)d"
type="action" string="Withdraw" class="btn-secondary"
icon="fa-undo"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('approved', 'approved_deduction', 'ready_bill')"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('submitted', 'resubmitted', 'needs_correction', 'accepted', 'approved', 'approved_deduction', 'ready_bill')"
help="Withdraw this application"/>
<!-- ============================================================ -->
@@ -1277,14 +1288,14 @@
<button name="action_open_submission_verification_wizard" type="object"
string="Review Submission" class="fc-btn-status-good"
icon="fa-check-circle"
invisible="not x_fc_is_adp_sale or not x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'denied', 'withdrawn', 'cancelled', 'expired')"
invisible="not x_fc_is_adp_sale or not x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'needs_correction', 'approved', 'approved_deduction', 'ready_delivery', 'ready_bill', 'billed', 'case_closed', 'on_hold', 'denied', 'withdrawn', 'cancelled', 'expired')"
help="Submission verified - click to review"/>
<!-- Review Submission: LIGHT RED when not yet verified -->
<button name="action_open_submission_verification_wizard" type="object"
string="Review Submission" class="fc-btn-status-bad"
icon="fa-exclamation-triangle"
invisible="not x_fc_is_adp_sale or x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'denied', 'withdrawn', 'cancelled', 'expired')"
invisible="not x_fc_is_adp_sale or x_fc_submission_verified or x_fc_adp_application_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'needs_correction', 'approved', 'approved_deduction', 'ready_delivery', 'ready_bill', 'billed', 'case_closed', 'on_hold', 'denied', 'withdrawn', 'cancelled', 'expired')"
help="Submission not yet verified - click to review"/>
<!-- Review Approval: GREEN when all devices approved -->
@@ -1549,7 +1560,7 @@
<!-- Application Details - Show after Ready for Submission stage -->
<group string="Application Details" invisible="not x_fc_stage_after_ready_submission">
<group>
<field name="x_fc_client_ref_1" placeholder="e.g., DOJO"
<field name="x_fc_client_ref_1" placeholder="e.g., JODO"
required="x_fc_stage_after_ready_submission"
readonly="x_fc_case_locked"/>
<field name="x_fc_client_ref_2" placeholder="e.g., 1234"

View File

@@ -90,15 +90,6 @@
</field>
</record>
<!-- ================================================================== -->
<!-- MAP VIEW (QWeb HTML with Google Maps) -->
<!-- ================================================================== -->
<record id="action_technician_location_map" model="ir.actions.act_url">
<field name="name">Technician Map</field>
<field name="url">/my/technician/admin/map</field>
<field name="target">self</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS (under Technician Management) -->
<!-- ================================================================== -->
@@ -106,13 +97,7 @@
name="Location History"
parent="menu_technician_management"
action="action_technician_locations"
sequence="40"/>
<menuitem id="menu_technician_map"
name="Live Map"
parent="menu_technician_management"
action="action_technician_location_map"
sequence="45"/>
sequence="50"/>
<!-- CRON: Cleanup old location records (runs daily) -->
<record id="ir_cron_cleanup_technician_locations" model="ir.cron">

View File

@@ -194,10 +194,11 @@
<field name="partner_phone" widget="phone"/>
</group>
<group string="Location">
<field name="address_partner_id"/>
<field name="address_street"/>
<field name="address_street2" string="Unit/Suite #"/>
<field name="address_buzz_code"/>
<field name="is_in_store"/>
<field name="address_partner_id" invisible="is_in_store"/>
<field name="address_street" readonly="is_in_store"/>
<field name="address_street2" string="Unit/Suite #" invisible="is_in_store"/>
<field name="address_buzz_code" invisible="is_in_store"/>
<field name="address_city" invisible="1"/>
<field name="address_state_id" invisible="1"/>
<field name="address_zip" invisible="1"/>
@@ -505,35 +506,35 @@
sequence="5"
groups="fusion_claims.group_fusion_claims_user,fusion_claims.group_field_technician"/>
<menuitem id="menu_technician_tasks_today"
name="Today's Tasks"
parent="menu_technician_management"
action="action_technician_tasks_today"
sequence="10"/>
<menuitem id="menu_technician_schedule"
name="Schedule"
parent="menu_technician_management"
action="action_technician_schedule"
sequence="10"/>
<menuitem id="menu_technician_tasks"
name="Tasks"
parent="menu_technician_management"
action="action_technician_tasks"
sequence="20"/>
sequence="15"/>
<menuitem id="menu_technician_tasks_pending"
name="Pending Tasks"
parent="menu_technician_management"
action="action_technician_tasks_pending"
sequence="13"/>
sequence="20"/>
<menuitem id="menu_technician_tasks_today"
name="Today's Tasks"
<menuitem id="menu_technician_tasks"
name="All Tasks"
parent="menu_technician_management"
action="action_technician_tasks_today"
sequence="15"/>
action="action_technician_tasks"
sequence="30"/>
<menuitem id="menu_technician_my_tasks"
name="My Tasks"
parent="menu_technician_management"
action="action_technician_my_tasks"
sequence="25"
sequence="35"
groups="fusion_claims.group_field_technician"/>

View File

@@ -11,7 +11,6 @@ _logger = logging.getLogger(__name__)
class AssessmentCompletedWizard(models.TransientModel):
"""Wizard to record assessment completion date."""
_name = 'fusion_claims.assessment.completed.wizard'
_description = 'Assessment Completed Wizard'
@@ -21,18 +20,49 @@ class AssessmentCompletedWizard(models.TransientModel):
required=True,
readonly=True,
)
is_override = fields.Boolean(
string='Scheduling Override',
compute='_compute_is_override',
store=False,
)
assessment_start_date = fields.Date(
string='Assessment Start Date',
required=True,
help='Date the assessment was conducted',
)
completion_date = fields.Date(
string='Assessment Completion Date',
required=True,
default=fields.Date.context_today,
)
notes = fields.Text(
string='Assessment Notes',
help='Any notes from the assessment',
string='Notes',
help='Notes from the assessment',
)
override_reason = fields.Text(
string='Override Reason',
help='Mandatory when skipping the scheduling step. Explain why the assessment was completed without scheduling through the system.',
)
notify_authorizer = fields.Boolean(
string='Notify Authorizer',
default=True,
help='Send email to the authorizer about assessment completion',
)
@api.depends('sale_order_id')
def _compute_is_override(self):
for rec in self:
rec.is_override = (
rec.sale_order_id
and rec.sale_order_id.x_fc_adp_application_status == 'quotation'
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
@@ -40,43 +70,174 @@ class AssessmentCompletedWizard(models.TransientModel):
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
if order.x_fc_assessment_start_date:
res['assessment_start_date'] = order.x_fc_assessment_start_date
else:
res['assessment_start_date'] = fields.Date.context_today(self)
return res
def action_complete(self):
"""Mark assessment as completed."""
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
# Validate completion date is not before start date
if order.x_fc_assessment_start_date and self.completion_date < order.x_fc_assessment_start_date:
current_status = order.x_fc_adp_application_status
is_override = current_status == 'quotation'
if current_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
f"Completion date ({self.completion_date}) cannot be before "
f"assessment start date ({order.x_fc_assessment_start_date})."
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
# Update sale order
order.with_context(skip_status_validation=True).write({
if is_override and not (self.override_reason or '').strip():
raise UserError(
_("Override Reason is mandatory when skipping the assessment scheduling step. "
"Please explain why this assessment was completed without being scheduled through the system.")
)
if self.completion_date < self.assessment_start_date:
raise UserError(
_("Completion date (%s) cannot be before assessment start date (%s).")
% (self.completion_date, self.assessment_start_date)
)
write_vals = {
'x_fc_adp_application_status': 'assessment_completed',
'x_fc_assessment_end_date': self.completion_date,
})
# Post to chatter
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-check-square-o"/> Assessment Completed</h4>'
f'<p style="margin: 0;"><strong>Completion Date:</strong> {self.completion_date.strftime("%B %d, %Y")}</p>'
f'{notes_html}'
}
if is_override or not order.x_fc_assessment_start_date:
write_vals['x_fc_assessment_start_date'] = self.assessment_start_date
order.with_context(skip_status_validation=True).write(write_vals)
if is_override:
override_html = Markup(
'<div style="background:#fff3cd;border-left:4px solid #ffc107;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#856404;margin:0 0 8px 0;">'
'<i class="fa fa-exclamation-triangle"/> Assessment Scheduling Override</h4>'
'<p style="margin:0;"><strong>Override by:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Reason:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Assessment Date:</strong> %s to %s</p>'
'%s'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
) % (
self.env.user.name,
self.override_reason.strip(),
self.assessment_start_date.strftime("%B %d, %Y"),
self.completion_date.strftime("%B %d, %Y"),
Markup('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes if self.notes else Markup(''),
)
order.message_post(
body=override_html,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
else:
notes_html = (
Markup('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes
) if self.notes else Markup('')
order.message_post(
body=Markup(
'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#28a745;margin:0 0 8px 0;">'
'<i class="fa fa-check-square-o"/> Assessment Completed</h4>'
'<p style="margin:0;"><strong>Completion Date:</strong> %s</p>'
'%s'
'</div>'
) % (self.completion_date.strftime("%B %d, %Y"), notes_html),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
if self.notify_authorizer:
self._send_backend_completion_email(order, is_override)
return {'type': 'ir.actions.act_window_close'}
def _send_backend_completion_email(self, order, is_override):
"""Send assessment completion email when done from backend."""
self.ensure_one()
if not order._email_is_enabled():
return
authorizer = order.x_fc_authorizer_id
if not authorizer or not authorizer.email:
_logger.info("No authorizer email for %s, skipping notification", order.name)
return
to_email = authorizer.email
cc_emails = []
if order.user_id and order.user_id.email:
cc_emails.append(order.user_id.email)
company = self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
cc_emails.extend([p.email for p in office_partners if p.email])
client_name = order.partner_id.name or 'Client'
override_note = ''
if is_override:
override_note = (
'<div style="background:#fff3cd;border-left:3px solid #ffc107;padding:8px 12px;'
'margin:12px 0;border-radius:4px;">'
'<strong>Note:</strong> This assessment was completed without being scheduled '
'through the system. '
f'<strong>Reason:</strong> {self.override_reason.strip()}'
'</div>'
)
sections = [
('Assessment Details', [
('Client', client_name),
('Case', order.name),
('Assessment Date', f"{self.assessment_start_date.strftime('%B %d, %Y')} to {self.completion_date.strftime('%B %d, %Y')}"),
('Completed by', self.env.user.name),
]),
]
if self.notes:
sections.append(('Notes', [('', self.notes)]))
summary = (
f'The assessment for <strong>{client_name}</strong> ({order.name}) '
f'has been completed on {self.completion_date.strftime("%B %d, %Y")}.'
)
if is_override:
summary += f' {override_note}'
email_body = order._email_build(
title='Assessment Completed',
summary=summary,
email_type='success',
sections=sections,
note='<strong>Next step:</strong> Please submit the ADP application '
'(including pages 11-12 signed by the client) so we can proceed.',
button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form',
button_text='View Case',
sender_name=order.user_id.name if order.user_id else 'The Team',
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Assessment Completed - {client_name} - {order.name}',
'body_html': email_body,
'email_to': to_email,
'email_cc': ', '.join(cc_emails) if cc_emails else False,
'model': 'sale.order',
'res_id': order.id,
'auto_delete': True,
}).send()
order.message_post(
body=Markup(
'<div class="alert alert-info" role="alert">'
'<strong>Assessment Completed email sent</strong>'
'<ul class="mb-0 mt-1"><li>To: %s</li>'
'<li>CC: %s</li></ul></div>'
) % (to_email, ', '.join(cc_emails) or 'None'),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
_logger.info("Sent backend assessment completed email for %s", order.name)
except Exception as e:
_logger.error("Failed to send assessment completed email for %s: %s", order.name, e)

View File

@@ -1,18 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Assessment Completed Wizard Form View -->
<record id="view_assessment_completed_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.assessment.completed.wizard.form</field>
<field name="model">fusion_claims.assessment.completed.wizard</field>
<field name="arch" type="xml">
<form string="Assessment Completed">
<field name="sale_order_id" invisible="1"/>
<field name="is_override" invisible="1"/>
<div invisible="not is_override"
class="alert alert-warning mb-3" role="alert">
<strong>Scheduling Override:</strong>
This assessment was not scheduled through the system.
A reason is required to proceed.
</div>
<group>
<field name="sale_order_id" invisible="1"/>
<field name="completion_date"/>
<field name="notes" placeholder="Enter any notes from the assessment..."/>
<group string="Assessment Dates">
<field name="assessment_start_date"/>
<field name="completion_date"/>
</group>
<group string="Details">
<field name="notes"
placeholder="Enter any notes from the assessment..."/>
<field name="notify_authorizer"/>
</group>
</group>
<group invisible="not is_override">
<field name="override_reason"
required="is_override"
placeholder="e.g., Authorizer completed the assessment independently and sent us the application directly..."
widget="text"/>
</group>
<footer>
<button name="action_complete" type="object"
<button name="action_complete" type="object"
string="Mark Complete" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
@@ -21,7 +44,6 @@
</field>
</record>
<!-- Action for the wizard -->
<record id="action_assessment_completed_wizard" model="ir.actions.act_window">
<field name="name">Assessment Completed</field>
<field name="res_model">fusion_claims.assessment.completed.wizard</field>

View File

@@ -34,11 +34,11 @@ class ReadyForSubmissionWizard(models.TransientModel):
# Client References (may already be filled)
client_ref_1 = fields.Char(
string='Client Reference 1',
help='First client reference number (e.g., PO number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
client_ref_2 = fields.Char(
string='Client Reference 2',
help='Second client reference number',
help='Last four digits of the client\'s health card number. Example: 1234',
)
# Reason for Application

View File

@@ -59,7 +59,7 @@
<field name="claim_authorization_date"/>
</group>
<group string="Client References">
<field name="client_ref_1" placeholder="e.g., DOJO"/>
<field name="client_ref_1" placeholder="e.g., JODO"/>
<field name="client_ref_2" placeholder="e.g., 1234"/>
</group>
</group>

View File

@@ -5,24 +5,33 @@
{
'name': 'Fusion Clock',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Human Resources/Attendances',
'summary': 'Geofenced Clock-In/Out with Portal UI, Auto Clock-Out, Penalties, and Pay Period Reporting',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
Fusion Clock - Geofenced Employee Attendance
=============================================
Fusion Clock - Complete Employee Time & Attendance
====================================================
A modern, mobile-first attendance module that provides:
* **Geofenced Clock-In/Out** - Employees can only clock in/out within configured location radiuses
* **Google Maps Integration** - Visual location management with geocoding and radius preview
* **Dark-Themed Portal** - Beautiful mobile-first portal page with animations and sound effects
* **Auto Clock-Out** - Automatic clock-out after shift + grace period expires
* **Break Deduction** - Configurable unpaid break auto-deduction
* **Late/Early Penalties** - Tracks late clock-in and early clock-out with grace periods
* **Pay Period Reports** - Weekly, bi-weekly, semi-monthly, or monthly report generation
* **Email Automation** - Batch reports to managers, individual reports to employees
* **Persistent State** - Clock stays active even when browser is closed
* **Geofenced Clock-In/Out** - GPS and IP whitelist verification
* **Shift Scheduling** - Per-employee shift assignment with break management
* **Auto Clock-Out** - Automatic clock-out after shift + grace period
* **Penalty Tracking** - Auto-deduction for late clock-in and early clock-out
* **Overtime Tracking** - Daily and weekly overtime with configurable thresholds
* **Activity Logging** - Comprehensive audit trail of every attendance event
* **Absence Tracking** - Automatic detection with monthly/yearly counters
* **Leave Requests** - Portal-based leave requests with auto-approval
* **Timesheet Corrections** - Employee correction requests with approval workflow
* **Manager Dashboard** - Live status, alerts, and team overview
* **Kiosk Mode** - Shared-device clock-in/out with PIN verification
* **Photo Verification** - Optional selfie capture at clock-in
* **On-Time Streak** - Gamification with milestone tracking
* **CSV Export** - Configurable payroll-compatible export
* **Team Lead Views** - Filtered read-only access for direct reports
* **Pay Period Reports** - PDF reports with email automation
* **Employee Notifications** - Clock-in/out reminders and weekly summaries
* **Portal UI** - Dark-themed mobile-first portal with FAB and modals
* **Systray Widget** - Backend users can clock in/out from any Odoo page
Integrates natively with Odoo's hr.attendance module for full payroll compatibility.
@@ -35,6 +44,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'hr',
'portal',
'mail',
'resource',
],
'data': [
# Security
@@ -54,22 +64,35 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_report_views.xml',
'views/clock_penalty_views.xml',
'views/res_config_settings_views.xml',
'views/clock_activity_log_views.xml',
'views/clock_leave_request_views.xml',
'views/clock_shift_views.xml',
'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
'views/clock_menus.xml',
# Views - Portal
'views/portal_clock_templates.xml',
'views/portal_timesheet_templates.xml',
'views/portal_report_templates.xml',
'views/kiosk_templates.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css',
'fusion_clock/static/src/js/fusion_clock_portal.js',
'fusion_clock/static/src/js/fusion_clock_portal_fab.js',
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
],
'web.assets_backend': [
'fusion_clock/static/src/scss/fusion_clock.scss',
'fusion_clock/static/src/js/fusion_clock_systray.js',
'fusion_clock/static/src/xml/systray_clock.xml',
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
'fusion_clock/static/src/js/fusion_clock_location_map.js',
'fusion_clock/static/src/js/fusion_clock_location_places.js',
'fusion_clock/static/src/xml/fusion_clock_location.xml',
],
},
'installable': True,

View File

@@ -2,3 +2,4 @@
from . import portal_clock
from . import clock_api
from . import clock_kiosk

View File

@@ -2,6 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import math
import logging
from datetime import datetime, timedelta
@@ -10,10 +11,12 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
STREAK_MILESTONES = [5, 10, 20, 50, 100]
def haversine_distance(lat1, lon1, lat2, lon2):
"""Calculate the great-circle distance between two points on Earth (in meters)."""
R = 6371000 # Earth radius in meters
R = 6371000
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
@@ -29,40 +32,35 @@ class FusionClockAPI(http.Controller):
"""JSON API endpoints for Fusion Clock operations."""
def _get_employee(self):
"""Get the current user's employee record."""
user = request.env.user
employee = request.env['hr.employee'].sudo().search([
return request.env['hr.employee'].sudo().search([
('user_id', '=', user.id),
], limit=1)
return employee
def _get_locations_for_employee(self, employee):
"""Get all clock locations available to this employee."""
Location = request.env['fusion.clock.location'].sudo()
locations = Location.search([
('active', '=', True),
('company_id', '=', employee.company_id.id),
])
# Filter: all_employees OR employee in employee_ids
result = locations.filtered(
return locations.filtered(
lambda loc: loc.all_employees or employee.id in loc.employee_ids.ids
)
return result
def _verify_location(self, latitude, longitude, employee):
"""Verify if GPS coordinates are within any allowed geofence.
Returns (location_record, distance, error_detail) tuple.
def _verify_location(self, latitude, longitude, employee, client_ip=None):
"""Verify GPS coordinates or IP against allowed geofences.
Returns (location_record, distance, error_detail, method) tuple.
method is 'gps' or 'ip'.
"""
locations = self._get_locations_for_employee(employee)
if not locations:
return False, 0, 'no_locations'
return False, 0, 'no_locations', 'gps'
geocoded = locations.filtered(lambda l: l.latitude is not None and l.longitude is not None
geocoded = locations.filtered(lambda l: l.latitude and l.longitude
and not (l.latitude == 0.0 and l.longitude == 0.0))
if not geocoded:
return False, 0, 'no_geocoded'
return False, 0, 'no_geocoded', 'gps'
nearest_location = False
nearest_distance = float('inf')
@@ -70,47 +68,41 @@ class FusionClockAPI(http.Controller):
for loc in geocoded:
dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude)
if dist <= loc.radius:
return loc, dist, None
return loc, dist, None, 'gps'
if dist < nearest_distance:
nearest_distance = dist
nearest_location = loc
return False, nearest_distance, 'outside'
# IP fallback check
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_ip_fallback', 'False') == 'True' and client_ip:
for loc in locations:
if loc.check_ip_whitelist(client_ip):
return loc, 0, None, 'ip'
return False, nearest_distance, 'outside', 'gps'
def _location_error_message(self, error_type, distance=0):
"""Return a user-friendly error message based on the location check result."""
if error_type == 'no_locations':
return 'No clock locations configured. Ask your manager to set up locations in Fusion Clock > Locations.'
elif error_type == 'no_geocoded':
return 'Clock locations exist but have no GPS coordinates. Ask your manager to geocode them.'
else:
dist_str = f"{int(distance)}m" if distance < 1000 else f"{distance/1000:.1f}km"
return f'You are {dist_str} away from the nearest clock location.'
return f'You are {dist_str} away from the nearest clock location. Please clock in/out within the allowed area.'
def _get_scheduled_times(self, employee, date):
"""Get scheduled clock-in and clock-out datetime for an employee on a date."""
ICP = request.env['ir.config_parameter'].sudo()
clock_in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
# Convert float hours to time
in_h = int(clock_in_hour)
in_m = int((clock_in_hour - in_h) * 60)
out_h = int(clock_out_hour)
out_m = int((clock_out_hour - out_h) * 60)
scheduled_in = datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
scheduled_out = datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
return scheduled_in, scheduled_out
"""Get scheduled clock-in and clock-out datetime using employee shift."""
return employee._get_fclk_scheduled_times(date)
def _check_and_create_penalty(self, employee, attendance, penalty_type, scheduled_dt, actual_dt):
"""Check if a penalty should be created and create it if needed."""
"""Check if a penalty should be created and deduct minutes."""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
diff_minutes = abs((actual_dt - scheduled_dt).total_seconds()) / 60.0
should_penalize = False
@@ -126,22 +118,65 @@ class FusionClockAPI(http.Controller):
'penalty_type': penalty_type,
'scheduled_time': scheduled_dt,
'actual_time': actual_dt,
'penalty_minutes': deduction,
'date': actual_dt.date() if isinstance(actual_dt, datetime) else fields.Date.today(),
})
# Deduct penalty minutes from attendance (adds to break deduction)
current_break = attendance.x_fclk_break_minutes or 0.0
attendance.sudo().write({
'x_fclk_break_minutes': current_break + deduction,
})
# Log penalty
log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
request.env['fusion.clock.activity.log'].sudo().create({
'employee_id': employee.id,
'log_type': log_type,
'description': f"{'Late' if penalty_type == 'late_in' else 'Early'} by "
f"{diff_minutes:.0f} min. {deduction:.0f} min deducted.",
'attendance_id': attendance.id,
'source': 'system',
})
# Reset streak on late clock-in
if penalty_type == 'late_in':
employee.sudo().write({'x_fclk_ontime_streak': 0})
def _apply_break_deduction(self, attendance, employee):
"""Apply automatic break deduction if configured."""
ICP = request.env['ir.config_parameter'].sudo()
auto_deduct = ICP.get_param('fusion_clock.auto_deduct_break', 'True')
if auto_deduct != 'True':
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
return
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
worked = attendance.worked_hours or 0.0
if worked >= threshold:
break_min = employee._get_fclk_break_minutes()
attendance.sudo().write({'x_fclk_break_minutes': break_min})
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
if new_val != current:
attendance.sudo().write({'x_fclk_break_minutes': new_val})
def _log_activity(self, employee, log_type, description, attendance=None,
location=None, latitude=0, longitude=0, distance=0, source='portal'):
"""Create an activity log entry."""
try:
request.env['fusion.clock.activity.log'].sudo().create({
'employee_id': employee.id,
'log_type': log_type,
'description': description,
'attendance_id': attendance.id if attendance else False,
'location_id': location.id if location else False,
'latitude': latitude,
'longitude': longitude,
'distance': distance,
'source': source,
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create activity log: %s", e)
# -------------------------------------------------------------------------
# API Endpoints
@@ -149,12 +184,15 @@ class FusionClockAPI(http.Controller):
@http.route('/fusion_clock/verify_location', type='jsonrpc', auth='user', methods=['POST'])
def verify_location(self, latitude=0, longitude=0, accuracy=0, **kw):
"""Verify if the user's GPS position is within a valid geofence."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
location, distance, err = self._verify_location(latitude, longitude, employee)
client_ip = request.httprequest.environ.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
if not client_ip:
client_ip = request.httprequest.remote_addr
location, distance, err, method = self._verify_location(latitude, longitude, employee, client_ip)
if location:
return {
@@ -164,6 +202,7 @@ class FusionClockAPI(http.Controller):
'location_address': location.address or '',
'distance': round(distance, 1),
'radius': location.radius,
'method': method,
}
else:
msg = self._location_error_message(err, distance)
@@ -175,8 +214,7 @@ class FusionClockAPI(http.Controller):
}
@http.route('/fusion_clock/clock_action', type='jsonrpc', auth='user', methods=['POST'])
def clock_action(self, latitude=0, longitude=0, accuracy=0, source='portal', **kw):
"""Perform clock-in or clock-out action."""
def clock_action(self, latitude=0, longitude=0, accuracy=0, source='portal', photo=None, **kw):
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
@@ -184,17 +222,41 @@ class FusionClockAPI(http.Controller):
if not employee.x_fclk_enable_clock:
return {'error': 'Fusion Clock is not enabled for your account.'}
# Check pending reason before clock-in
is_checked_in = employee.attendance_state == 'checked_in'
if not is_checked_in and employee.x_fclk_pending_reason:
return {
'requires_reason': True,
'message': 'Please provide a reason for your missed clock-out before clocking in.',
}
# Server-side location verification
location, distance, err = self._verify_location(latitude, longitude, employee)
client_ip = request.httprequest.environ.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
if not client_ip:
client_ip = request.httprequest.remote_addr
location, distance, err, method = self._verify_location(latitude, longitude, employee, client_ip)
if not location:
# Log geofence violation
self._log_activity(
employee, 'outside_geofence',
self._location_error_message(err, distance),
latitude=latitude, longitude=longitude, distance=distance, source=source,
)
return {
'error': self._location_error_message(err, distance),
'allowed': False,
'error_type': err,
}
# Determine if clocking in or out
is_checked_in = employee.attendance_state == 'checked_in'
# Log IP fallback usage
if method == 'ip':
self._log_activity(
employee, 'ip_fallback',
f"IP-based verification used (IP: {client_ip}) at {location.name}",
location=location, source=source,
)
now = fields.Datetime.now()
today = now.date()
@@ -202,23 +264,70 @@ class FusionClockAPI(http.Controller):
'latitude': latitude,
'longitude': longitude,
'browser': kw.get('browser', ''),
'ip_address': kw.get('ip_address', ''),
'ip_address': client_ip,
}
ICP = request.env['ir.config_parameter'].sudo()
try:
if not is_checked_in:
# CLOCK IN
attendance = employee.sudo()._attendance_action_change(geo_info)
attendance.sudo().write({
write_vals = {
'x_fclk_location_id': location.id,
'x_fclk_in_distance': round(distance, 1),
'x_fclk_clock_source': source,
})
}
# Photo verification
if photo and location.require_photo:
try:
write_vals['x_fclk_checkin_photo'] = photo
except Exception:
pass
attendance.sudo().write(write_vals)
# Log clock-in
self._log_activity(
employee, 'clock_in',
f"Clocked in at {location.name} ({method.upper()})",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
)
# Check for late clock-in penalty
scheduled_in, _ = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
# Update on-time streak
diff_min = (now - scheduled_in).total_seconds() / 60.0 if now > scheduled_in else 0
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
if diff_min <= grace:
new_streak = (employee.x_fclk_ontime_streak or 0) + 1
employee.sudo().write({'x_fclk_ontime_streak': new_streak})
if new_streak in STREAK_MILESTONES:
self._log_activity(
employee, 'streak_milestone',
f"On-time streak reached {new_streak} days!",
attendance=attendance, source='system',
)
# Very late notification
very_late = float(ICP.get_param('fusion_clock.very_late_threshold_minutes', '15'))
if diff_min > very_late:
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id:
request.env['hr.attendance'].sudo()._fclk_notify_office(
office_user_id,
f"Very Late Clock-In: {employee.name}",
f"{employee.name} clocked in {int(diff_min)} minutes late.",
'hr.attendance',
attendance.id,
)
return {
'success': True,
'action': 'clock_in',
@@ -226,6 +335,7 @@ class FusionClockAPI(http.Controller):
'check_in': fields.Datetime.to_string(attendance.check_in),
'location_name': location.name,
'message': f'Clocked in at {location.name}',
'streak': employee.x_fclk_ontime_streak,
}
else:
@@ -242,6 +352,23 @@ class FusionClockAPI(http.Controller):
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
# Log clock-out
self._log_activity(
employee, 'clock_out',
f"Clocked out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
)
# Log overtime
if attendance.x_fclk_is_overtime and attendance.x_fclk_overtime_hours > 0:
self._log_activity(
employee, 'overtime',
f"Overtime: {attendance.x_fclk_overtime_hours:.1f}h beyond scheduled shift",
attendance=attendance, source='system',
)
return {
'success': True,
'action': 'clock_out',
@@ -251,6 +378,7 @@ class FusionClockAPI(http.Controller):
'worked_hours': round(attendance.worked_hours or 0, 2),
'net_hours': round(attendance.x_fclk_net_hours or 0, 2),
'break_minutes': attendance.x_fclk_break_minutes,
'overtime_hours': round(attendance.x_fclk_overtime_hours or 0, 2),
'location_name': location.name,
'message': f'Clocked out from {location.name}',
}
@@ -259,9 +387,113 @@ class FusionClockAPI(http.Controller):
_logger.error("Fusion Clock error: %s", str(e))
return {'error': str(e)}
@http.route('/fusion_clock/submit_reason', type='jsonrpc', auth='user', methods=['POST'])
def submit_reason(self, reason='', departure_time='', **kw):
"""Submit a reason for missed clock-out."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
if not reason:
return {'error': 'Please provide a reason.'}
# Find the last unclosed attendance or last auto-closed
last_att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '!=', False),
], order='check_out desc', limit=1)
if last_att and departure_time:
try:
dep_dt = fields.Datetime.from_string(departure_time)
if dep_dt and dep_dt > last_att.check_in:
last_att.sudo().write({'check_out': dep_dt})
except Exception:
pass
# Log the reason
self._log_activity(
employee, 'reason_provided',
f"Reason for missed clock-out: {reason}. "
f"Reported departure: {departure_time or 'not specified'}",
attendance=last_att if last_att else None,
source='portal',
)
employee.sudo().write({'x_fclk_pending_reason': False})
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
def request_leave(self, leave_date='', reason='', **kw):
"""Submit a leave request from the portal."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
if not leave_date or not reason:
return {'error': 'Please provide both a date and a reason.'}
try:
date_obj = fields.Date.from_string(leave_date)
except Exception:
return {'error': 'Invalid date format. Use YYYY-MM-DD.'}
existing = request.env['fusion.clock.leave.request'].sudo().search([
('employee_id', '=', employee.id),
('leave_date', '=', date_obj),
], limit=1)
if existing:
return {'error': 'A leave request already exists for this date.'}
request.env['fusion.clock.leave.request'].sudo().create({
'employee_id': employee.id,
'leave_date': date_obj,
'reason': reason,
'created_from': 'portal',
})
return {'success': True, 'message': f'Leave request for {leave_date} submitted.'}
@http.route('/fusion_clock/request_correction', type='jsonrpc', auth='user', methods=['POST'])
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):
"""Submit a timesheet correction request from the portal."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_correction_requests', 'True') != 'True':
return {'error': 'Correction requests are not enabled.'}
if not attendance_id or not reason:
return {'error': 'Please provide the attendance record and a reason.'}
attendance = request.env['hr.attendance'].sudo().browse(attendance_id)
if not attendance.exists() or attendance.employee_id.id != employee.id:
return {'error': 'Attendance record not found.'}
vals = {
'employee_id': employee.id,
'attendance_id': attendance_id,
'reason': reason,
}
if check_in:
try:
vals['requested_check_in'] = fields.Datetime.from_string(check_in)
except Exception:
pass
if check_out:
try:
vals['requested_check_out'] = fields.Datetime.from_string(check_out)
except Exception:
pass
request.env['fusion.clock.correction'].sudo().create(vals)
return {'success': True, 'message': 'Correction request submitted for review.'}
@http.route('/fusion_clock/get_status', type='jsonrpc', auth='user', methods=['POST'])
def get_status(self, **kw):
"""Get current clock status for the authenticated user."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
@@ -272,10 +504,11 @@ class FusionClockAPI(http.Controller):
'is_checked_in': is_checked_in,
'employee_name': employee.name,
'enable_clock': employee.x_fclk_enable_clock,
'pending_reason': employee.x_fclk_pending_reason,
'ontime_streak': employee.x_fclk_ontime_streak,
}
if is_checked_in:
# Find the open attendance
att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '=', False),
@@ -288,7 +521,6 @@ class FusionClockAPI(http.Controller):
'location_id': att.x_fclk_location_id.id or False,
})
# Today's stats
today_start = fields.Datetime.to_string(
datetime.combine(fields.Date.today(), datetime.min.time())
)
@@ -299,7 +531,6 @@ class FusionClockAPI(http.Controller):
])
result['today_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2)
# This week stats
today = fields.Date.today()
week_start = today - timedelta(days=today.weekday())
week_start_dt = fields.Datetime.to_string(
@@ -312,7 +543,6 @@ class FusionClockAPI(http.Controller):
])
result['week_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2)
# Recent activity (last 10)
recent = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '!=', False),
@@ -330,7 +560,6 @@ class FusionClockAPI(http.Controller):
@http.route('/fusion_clock/get_locations', type='jsonrpc', auth='user', methods=['POST'])
def get_locations(self, **kw):
"""Get all available clock locations for the current user."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
@@ -347,13 +576,13 @@ class FusionClockAPI(http.Controller):
'longitude': loc.longitude,
'radius': loc.radius,
'is_default': loc.id == default_id,
'require_photo': loc.require_photo,
} for loc in locations],
'default_location_id': default_id,
}
@http.route('/fusion_clock/get_settings', type='jsonrpc', auth='user', methods=['POST'])
def get_settings(self, **kw):
"""Get Fusion Clock settings for the frontend."""
ICP = request.env['ir.config_parameter'].sudo()
return {
'enable_sounds': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
@@ -362,4 +591,82 @@ class FusionClockAPI(http.Controller):
'default_clock_out': float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')),
'target_daily_hours': 8.0,
'target_weekly_hours': 40.0,
'enable_photo': ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True',
'enable_corrections': ICP.get_param('fusion_clock.enable_correction_requests', 'True') == 'True',
}
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
def dashboard_data(self, **kw):
"""Return dashboard data for managers."""
user = request.env.user
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
if not is_manager and not is_team_lead:
return {'error': 'Access denied.'}
now = fields.Datetime.now()
today = fields.Date.today()
today_start = datetime.combine(today, datetime.min.time())
Attendance = request.env['hr.attendance'].sudo()
Employee = request.env['hr.employee'].sudo()
# Filter employees by access
if is_manager:
employees = Employee.search([('x_fclk_enable_clock', '=', True)])
else:
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found.'}
employees = Employee.search([
('parent_id', '=', employee.id),
('x_fclk_enable_clock', '=', True),
])
emp_ids = employees.ids
# Currently clocked in
open_atts = Attendance.search([
('employee_id', 'in', emp_ids),
('check_out', '=', False),
])
clocked_in = [{
'employee': a.employee_id.name,
'check_in': fields.Datetime.to_string(a.check_in),
'location': a.x_fclk_location_id.name or '',
} for a in open_atts]
# Today stats
today_atts = Attendance.search([
('employee_id', 'in', emp_ids),
('check_in', '>=', today_start),
])
present_ids = set(a.employee_id.id for a in today_atts)
ActivityLog = request.env['fusion.clock.activity.log'].sudo()
late_count = ActivityLog.search_count([
('employee_id', 'in', emp_ids),
('log_type', '=', 'late_clock_in'),
('log_date', '>=', today_start),
])
# Pending alerts
pending_reasons = Employee.search_count([
('id', 'in', emp_ids),
('x_fclk_pending_reason', '=', True),
])
pending_corrections = request.env['fusion.clock.correction'].sudo().search_count([
('employee_id', 'in', emp_ids),
('state', '=', 'pending'),
])
return {
'clocked_in': clocked_in,
'total_employees': len(emp_ids),
'present_count': len(present_ids),
'absent_count': len(emp_ids) - len(present_ids),
'late_count': late_count,
'pending_reasons': pending_reasons,
'pending_corrections': pending_corrections,
}

View File

@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import http, fields, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockKiosk(http.Controller):
"""Kiosk mode controller for shared-device clock-in/out."""
@http.route('/fusion_clock/kiosk', type='http', auth='user', website=True)
def kiosk_page(self, **kw):
"""Kiosk clock-in/out page for shared tablets."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return request.redirect('/my')
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_kiosk', 'False') != 'True':
return request.redirect('/my')
values = {
'pin_required': ICP.get_param('fusion_clock.kiosk_pin_required', 'True') == 'True',
'page_name': 'kiosk',
}
return request.render('fusion_clock.kiosk_page', values)
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_search(self, query='', **kw):
"""Search employees for kiosk identification."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
employees = request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('name', 'ilike', query),
], limit=20)
return {
'employees': [{
'id': emp.id,
'name': emp.name,
'department': emp.department_id.name or '',
'is_checked_in': emp.attendance_state == 'checked_in',
} for emp in employees],
}
@http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
"""Verify employee PIN for kiosk mode."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
employee = request.env['hr.employee'].sudo().browse(employee_id)
if not employee.exists():
return {'error': 'Employee not found.'}
if employee.x_fclk_kiosk_pin and employee.x_fclk_kiosk_pin != pin:
return {'error': 'Invalid PIN.'}
return {
'success': True,
'employee_name': employee.name,
'is_checked_in': employee.attendance_state == 'checked_in',
}
@http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
"""Perform clock action from kiosk on behalf of an employee."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
employee = request.env['hr.employee'].sudo().browse(employee_id)
if not employee.exists() or not employee.x_fclk_enable_clock:
return {'error': 'Employee not found or clock not enabled.'}
from .clock_api import FusionClockAPI, haversine_distance
api = FusionClockAPI()
location, distance, err, method = api._verify_location(latitude, longitude, employee)
if not location:
return {
'error': api._location_error_message(err, distance),
'allowed': False,
}
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
geo_info = {
'latitude': latitude,
'longitude': longitude,
'browser': 'kiosk',
'ip_address': request.httprequest.remote_addr or '',
}
try:
attendance = employee.sudo()._attendance_action_change(geo_info)
if not is_checked_in:
attendance.sudo().write({
'x_fclk_location_id': location.id,
'x_fclk_in_distance': round(distance, 1),
'x_fclk_clock_source': 'kiosk',
})
api._log_activity(
employee, 'clock_in',
f"Kiosk clock-in at {location.name}",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
'action': 'clock_in',
'employee_name': employee.name,
'message': f'{employee.name} clocked in at {location.name}',
}
else:
attendance.sudo().write({
'x_fclk_out_distance': round(distance, 1),
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',
)
return {
'success': True,
'action': 'clock_out',
'employee_name': employee.name,
'message': f'{employee.name} clocked out from {location.name}',
'net_hours': round(attendance.x_fclk_net_hours or 0, 2),
}
except Exception as e:
_logger.error("Fusion Clock kiosk error: %s", str(e))
return {'error': str(e)}

View File

@@ -22,7 +22,7 @@
</record>
<record id="config_break_threshold_hours" model="ir.config_parameter">
<field name="key">fusion_clock.break_threshold_hours</field>
<field name="value">5.0</field>
<field name="value">4.0</field>
</record>
<!-- Grace Period & Auto Clock-Out -->
@@ -48,6 +48,80 @@
<field name="key">fusion_clock.penalty_grace_minutes</field>
<field name="value">5</field>
</record>
<record id="config_penalty_deduction_minutes" model="ir.config_parameter">
<field name="key">fusion_clock.penalty_deduction_minutes</field>
<field name="value">15</field>
</record>
<!-- Office User & Notifications -->
<record id="config_office_user_id" model="ir.config_parameter">
<field name="key">fusion_clock.office_user_id</field>
<field name="value">0</field>
</record>
<record id="config_very_late_threshold" model="ir.config_parameter">
<field name="key">fusion_clock.very_late_threshold_minutes</field>
<field name="value">15</field>
</record>
<record id="config_max_monthly_absences" model="ir.config_parameter">
<field name="key">fusion_clock.max_monthly_absences</field>
<field name="value">3</field>
</record>
<record id="config_enable_employee_notifications" model="ir.config_parameter">
<field name="key">fusion_clock.enable_employee_notifications</field>
<field name="value">True</field>
</record>
<record id="config_reminder_before_shift" model="ir.config_parameter">
<field name="key">fusion_clock.reminder_before_shift_minutes</field>
<field name="value">30</field>
</record>
<record id="config_reminder_before_end" model="ir.config_parameter">
<field name="key">fusion_clock.reminder_before_end_minutes</field>
<field name="value">15</field>
</record>
<record id="config_send_weekly_summary" model="ir.config_parameter">
<field name="key">fusion_clock.send_weekly_summary</field>
<field name="value">True</field>
</record>
<!-- Overtime -->
<record id="config_enable_overtime" model="ir.config_parameter">
<field name="key">fusion_clock.enable_overtime</field>
<field name="value">True</field>
</record>
<record id="config_daily_overtime_threshold" model="ir.config_parameter">
<field name="key">fusion_clock.daily_overtime_threshold</field>
<field name="value">8.0</field>
</record>
<record id="config_weekly_overtime_threshold" model="ir.config_parameter">
<field name="key">fusion_clock.weekly_overtime_threshold</field>
<field name="value">40.0</field>
</record>
<!-- Location & Verification -->
<record id="config_enable_ip_fallback" model="ir.config_parameter">
<field name="key">fusion_clock.enable_ip_fallback</field>
<field name="value">False</field>
</record>
<record id="config_enable_photo_verification" model="ir.config_parameter">
<field name="key">fusion_clock.enable_photo_verification</field>
<field name="value">False</field>
</record>
<!-- Kiosk -->
<record id="config_enable_kiosk" model="ir.config_parameter">
<field name="key">fusion_clock.enable_kiosk</field>
<field name="value">False</field>
</record>
<record id="config_kiosk_pin_required" model="ir.config_parameter">
<field name="key">fusion_clock.kiosk_pin_required</field>
<field name="value">True</field>
</record>
<!-- Corrections -->
<record id="config_enable_corrections" model="ir.config_parameter">
<field name="key">fusion_clock.enable_correction_requests</field>
<field name="value">True</field>
</record>
<!-- Pay Period -->
<record id="config_pay_period_type" model="ir.config_parameter">

View File

@@ -25,4 +25,40 @@
<field name="priority">60</field>
</record>
<!-- Absence Check Cron: runs daily at midnight -->
<record id="cron_check_absences" model="ir.cron">
<field name="name">Fusion Clock: Daily Absence Check</field>
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
<field name="state">code</field>
<field name="code">model._cron_fusion_check_absences()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="priority">55</field>
</record>
<!-- Employee Reminder Cron: runs every 15 minutes -->
<record id="cron_employee_reminders" model="ir.cron">
<field name="name">Fusion Clock: Employee Reminders</field>
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
<field name="state">code</field>
<field name="code">model._cron_fusion_employee_reminders()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
<field name="priority">70</field>
</record>
<!-- Weekly Summary Cron: runs daily (checks if Monday internally) -->
<record id="cron_weekly_summary" model="ir.cron">
<field name="name">Fusion Clock: Weekly Summary</field>
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
<field name="state">code</field>
<field name="code">model._cron_fusion_weekly_summary()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="priority">80</field>
</record>
</odoo>

View File

@@ -82,4 +82,86 @@
<field name="auto_delete" eval="False"/>
</record>
<!-- Weekly Summary Email -->
<record id="mail_template_weekly_summary" model="mail.template">
<field name="name">Fusion Clock: Weekly Summary</field>
<field name="model_id" ref="hr.model_hr_employee"/>
<field name="subject">Your Weekly Attendance Summary</field>
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
<field name="email_to">{{ object.work_email or '' }}</field>
<field name="body_html"><![CDATA[
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;">
<tr>
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>
<p style="color:#9ca3af;margin:4px 0 0;">Weekly Summary</p>
</td>
</tr>
<tr>
<td style="padding:24px 32px;">
<p>Hello <strong>{{ object.name }}</strong>,</p>
<p>Here is your attendance summary for the past week:</p>
<table width="100%" style="margin:16px 0;border-collapse:collapse;">
<tr style="background:#f8f9fa;">
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Hours</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('total_hours', 0) }}h</td>
</tr>
<tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Overtime</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('overtime_hours', 0) }}h</td>
</tr>
<tr style="background:#f8f9fa;">
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Penalties</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('penalty_count', 0) }}</td>
</tr>
<tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Absences</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('absence_count', 0) }}</td>
</tr>
<tr style="background:#f8f9fa;">
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>On-Time Streak</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('streak', 0) }} days</td>
</tr>
</table>
<p>Log in to <a href="/my/clock">your portal</a> to view details.</p>
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
</td>
</tr>
</table>
</div>
]]></field>
<field name="auto_delete" eval="False"/>
</record>
<!-- Correction Request Notification -->
<record id="mail_template_correction_request" model="mail.template">
<field name="name">Fusion Clock: Correction Request</field>
<field name="model_id" ref="fusion_clock.model_fusion_clock_correction"/>
<field name="subject">Timesheet Correction Request: {{ object.employee_id.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
<field name="body_html"><![CDATA[
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;">
<tr>
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>
<p style="color:#9ca3af;margin:4px 0 0;">Correction Request</p>
</td>
</tr>
<tr>
<td style="padding:24px 32px;">
<p><strong>{{ object.employee_id.name }}</strong> has submitted a timesheet correction request.</p>
<p><strong>Reason:</strong> {{ object.reason }}</p>
<p>Please review and approve/reject from the Fusion Clock backend.</p>
</td>
</tr>
</table>
</div>
]]></field>
<field name="auto_delete" eval="False"/>
</record>
</odoo>

View File

@@ -6,3 +6,7 @@ from . import hr_employee
from . import clock_penalty
from . import clock_report
from . import res_config_settings
from . import clock_activity_log
from . import clock_leave_request
from . import clock_shift
from . import clock_correction

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionClockActivityLog(models.Model):
_name = 'fusion.clock.activity.log'
_description = 'Clock Activity Log'
_order = 'log_date desc, id desc'
_rec_name = 'display_name'
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
log_type = fields.Selection(
[
('clock_in', 'Clock In'),
('clock_out', 'Clock Out'),
('late_clock_in', 'Late Clock-In'),
('early_clock_out', 'Early Clock-Out'),
('outside_geofence', 'Outside Geofence'),
('auto_clock_out', 'Auto Clock-Out'),
('missed_clock_out', 'Missed Clock-Out'),
('absent', 'Absent'),
('leave_request', 'Leave Request'),
('reason_provided', 'Reason Provided'),
('overtime', 'Overtime'),
('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'),
],
string='Log Type',
required=True,
index=True,
)
log_date = fields.Datetime(
string='Date/Time',
required=True,
default=fields.Datetime.now,
index=True,
)
description = fields.Text(string='Description')
attendance_id = fields.Many2one(
'hr.attendance',
string='Attendance',
ondelete='set null',
index=True,
)
location_id = fields.Many2one(
'fusion.clock.location',
string='Location',
ondelete='set null',
)
latitude = fields.Float(string='Latitude', digits=(10, 7))
longitude = fields.Float(string='Longitude', digits=(10, 7))
distance = fields.Float(
string='Distance (m)',
digits=(10, 2),
help="Distance from location center in meters.",
)
source = fields.Selection(
[
('portal', 'Portal'),
('portal_fab', 'Portal FAB'),
('systray', 'Systray'),
('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'),
('system', 'System (Cron)'),
],
string='Source',
)
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
)
attempt_map_url = fields.Char(
string='Map Preview',
compute='_compute_attempt_map_url',
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
LOG_TYPE_LABELS = {
'clock_in': 'Clock In',
'clock_out': 'Clock Out',
'late_clock_in': 'Late Clock-In',
'early_clock_out': 'Early Clock-Out',
'outside_geofence': 'Outside Geofence',
'auto_clock_out': 'Auto Clock-Out',
'missed_clock_out': 'Missed Clock-Out',
'absent': 'Absent',
'leave_request': 'Leave Request',
'reason_provided': 'Reason Provided',
'overtime': 'Overtime',
'correction_request': 'Correction Request',
'ip_fallback': 'IP Fallback Used',
'streak_milestone': 'Streak Milestone',
}
@api.depends('latitude', 'longitude')
def _compute_attempt_map_url(self):
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_clock.google_maps_api_key', ''
)
for rec in self:
if rec.latitude and rec.longitude and api_key:
lat, lng = rec.latitude, rec.longitude
rec.attempt_map_url = (
f"https://maps.googleapis.com/maps/api/staticmap?"
f"center={lat},{lng}&zoom=16&size=600x250&maptype=roadmap"
f"&markers=color:red%7C{lat},{lng}"
f"&key={api_key}"
)
else:
rec.attempt_map_url = False
@api.depends('employee_id', 'log_type', 'log_date')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
ltype = self.LOG_TYPE_LABELS.get(rec.log_type, rec.log_type or '')
date_str = rec.log_date.strftime('%Y-%m-%d %H:%M') if rec.log_date else ''
rec.display_name = f"{emp} - {ltype} ({date_str})"

View File

@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionClockCorrection(models.Model):
_name = 'fusion.clock.correction'
_description = 'Timesheet Correction Request'
_order = 'create_date desc, id desc'
_rec_name = 'display_name'
_inherit = ['mail.thread']
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
attendance_id = fields.Many2one(
'hr.attendance',
string='Attendance Record',
required=True,
ondelete='cascade',
)
original_check_in = fields.Datetime(
string='Original Clock-In',
related='attendance_id.check_in',
)
original_check_out = fields.Datetime(
string='Original Clock-Out',
related='attendance_id.check_out',
)
requested_check_in = fields.Datetime(
string='Corrected Clock-In',
)
requested_check_out = fields.Datetime(
string='Corrected Clock-Out',
)
reason = fields.Text(
string='Reason for Correction',
required=True,
)
state = fields.Selection(
[
('pending', 'Pending'),
('approved', 'Approved'),
('rejected', 'Rejected'),
],
string='Status',
default='pending',
tracking=True,
index=True,
)
reviewed_by = fields.Many2one(
'res.users',
string='Reviewed By',
)
reviewed_date = fields.Datetime(string='Reviewed Date')
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('employee_id', 'attendance_id', 'state')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = rec.attendance_id.check_in.strftime('%Y-%m-%d') if rec.attendance_id and rec.attendance_id.check_in else ''
rec.display_name = f"{emp} - Correction ({date_str}) [{rec.state}]"
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
rec._notify_office_user()
rec._create_activity_log('pending')
return records
def action_approve(self):
"""Approve the correction and update the attendance record."""
self.ensure_one()
if self.state != 'pending':
raise UserError(_("Only pending corrections can be approved."))
vals = {}
if self.requested_check_in:
vals['check_in'] = self.requested_check_in
if self.requested_check_out:
vals['check_out'] = self.requested_check_out
if vals:
self.attendance_id.sudo().write(vals)
self.write({
'state': 'approved',
'reviewed_by': self.env.uid,
'reviewed_date': fields.Datetime.now(),
})
self._create_activity_log('approved')
def action_reject(self):
"""Reject the correction request."""
self.ensure_one()
if self.state != 'pending':
raise UserError(_("Only pending corrections can be rejected."))
self.write({
'state': 'rejected',
'reviewed_by': self.env.uid,
'reviewed_date': fields.Datetime.now(),
})
self._create_activity_log('rejected')
def _notify_office_user(self):
"""Schedule a mail.activity for the office user."""
ICP = self.env['ir.config_parameter'].sudo()
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if not office_user_id:
return
office_user = self.env['res.users'].sudo().browse(office_user_id)
if not office_user.exists():
return
try:
date_str = self.attendance_id.check_in.strftime('%Y-%m-%d') if self.attendance_id.check_in else 'unknown'
self.env['mail.activity'].sudo().create({
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': f"Timesheet Correction: {self.employee_id.name} ({date_str})",
'note': f"Reason: {self.reason}",
'user_id': office_user.id,
'res_model_id': self.env['ir.model']._get_id('fusion.clock.correction'),
'res_id': self.id,
'date_deadline': fields.Date.today(),
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create correction activity: %s", e)
def _create_activity_log(self, action):
"""Log the correction event."""
try:
desc = f"Correction {action} for attendance on "
if self.attendance_id.check_in:
desc += self.attendance_id.check_in.strftime('%Y-%m-%d')
desc += f": {self.reason}"
self.env['fusion.clock.activity.log'].sudo().create({
'employee_id': self.employee_id.id,
'log_type': 'correction_request',
'description': desc,
'attendance_id': self.attendance_id.id,
'source': 'system',
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create correction log: %s", e)

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionClockLeaveRequest(models.Model):
_name = 'fusion.clock.leave.request'
_description = 'Clock Leave Request'
_order = 'leave_date desc, id desc'
_rec_name = 'display_name'
_inherit = ['mail.thread']
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
leave_date = fields.Date(
string='Leave Date',
required=True,
index=True,
)
reason = fields.Text(
string='Reason',
required=True,
)
state = fields.Selection(
[
('auto_approved', 'Auto-Approved'),
('reviewed', 'Reviewed'),
],
string='Status',
default='auto_approved',
tracking=True,
)
created_from = fields.Selection(
[
('portal', 'Portal'),
('backend', 'Backend'),
],
string='Created From',
default='portal',
)
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('employee_id', 'leave_date')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = str(rec.leave_date) if rec.leave_date else ''
rec.display_name = f"{emp} - Leave ({date_str})"
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
rec._notify_office_user()
rec._create_activity_log()
return records
def _notify_office_user(self):
"""Schedule a mail.activity for the office user."""
ICP = self.env['ir.config_parameter'].sudo()
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if not office_user_id:
return
office_user = self.env['res.users'].sudo().browse(office_user_id)
if not office_user.exists():
return
try:
self.env['mail.activity'].sudo().create({
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}",
'note': f"Reason: {self.reason}",
'user_id': office_user.id,
'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'),
'res_id': self.id,
'date_deadline': self.leave_date,
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create leave activity: %s", e)
def _create_activity_log(self):
"""Log the leave request in the activity log."""
try:
self.env['fusion.clock.activity.log'].sudo().create({
'employee_id': self.employee_id.id,
'log_type': 'leave_request',
'description': f"Leave requested for {self.leave_date}: {self.reason}",
'source': 'portal' if self.created_from == 'portal' else 'system',
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create leave activity log: %s", e)
def action_mark_reviewed(self):
"""Mark the leave request as reviewed by the office user."""
self.write({'state': 'reviewed'})

View File

@@ -2,6 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import ipaddress
import json
import logging
import requests
@@ -54,6 +55,19 @@ class FusionClockLocation(models.Model):
default=lambda self: self.env.user.tz or 'UTC',
)
# IP whitelist fallback
ip_whitelist = fields.Text(
string='IP Whitelist',
help="One IP address or CIDR per line. Used as fallback when GPS is unavailable.",
)
# Photo verification
require_photo = fields.Boolean(
string='Require Photo on Clock-In',
default=False,
help="If enabled, employees must take a selfie when clocking in at this location.",
)
# Computed
attendance_count = fields.Integer(
string='Total Attendances',
@@ -89,6 +103,28 @@ class FusionClockLocation(models.Model):
('x_fclk_location_id', '=', rec.id),
])
def check_ip_whitelist(self, client_ip):
"""Check if a client IP matches this location's whitelist.
Returns True if matched, False otherwise.
"""
if not self.ip_whitelist or not client_ip:
return False
try:
client = ipaddress.ip_address(client_ip)
for line in self.ip_whitelist.strip().split('\n'):
line = line.strip()
if not line or line.startswith('#'):
continue
try:
network = ipaddress.ip_network(line, strict=False)
if client in network:
return True
except ValueError:
continue
except ValueError:
return False
return False
def action_geocode_address(self):
"""Geocode the address to get lat/lng using Google Geocoding API.
Falls back to Nominatim (OpenStreetMap) if Google fails.
@@ -97,7 +133,6 @@ class FusionClockLocation(models.Model):
if not self.address:
raise UserError(_("Please enter an address first."))
# Try Google first
api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '')
if api_key:
try:
@@ -126,13 +161,12 @@ class FusionClockLocation(models.Model):
},
}
elif data.get('status') == 'REQUEST_DENIED':
_logger.warning("Google Geocoding API denied. Enable the Geocoding API in Google Cloud Console. Falling back to Nominatim.")
_logger.warning("Google Geocoding API denied. Falling back to Nominatim.")
else:
_logger.warning("Google geocoding returned: %s. Trying Nominatim fallback.", data.get('status'))
_logger.warning("Google geocoding returned: %s. Trying Nominatim.", data.get('status'))
except requests.exceptions.RequestException as e:
_logger.warning("Google geocoding network error: %s. Trying Nominatim fallback.", e)
_logger.warning("Google geocoding network error: %s. Trying Nominatim.", e)
# Fallback: Nominatim (OpenStreetMap) - free, no API key needed
try:
url = 'https://nominatim.openstreetmap.org/search'
params = {
@@ -165,7 +199,7 @@ class FusionClockLocation(models.Model):
},
}
else:
raise UserError(_("Could not geocode address. No results found. Try a more specific address."))
raise UserError(_("Could not geocode address. No results found."))
except requests.exceptions.RequestException as e:
raise UserError(_("Network error during geocoding: %s") % str(e))

View File

@@ -38,6 +38,11 @@ class FusionClockPenalty(models.Model):
compute='_compute_difference',
store=True,
)
penalty_minutes = fields.Float(
string='Deducted (min)',
default=0.0,
help="Minutes deducted from worked hours as penalty.",
)
date = fields.Date(string='Date', required=True, index=True)
company_id = fields.Many2one(
'res.company',

View File

@@ -182,6 +182,84 @@ class FusionClockReport(models.Model):
else:
_logger.warning("Fusion Clock: Mail template not found for report %s", self.id)
def action_export_csv(self):
"""Export the report data as a CSV file for payroll."""
import csv
import io
self.ensure_one()
if not self.attendance_ids:
self._collect_attendance_records()
ICP = self.env['ir.config_parameter'].sudo()
mapping_raw = ICP.get_param('fusion_clock.csv_column_mapping', '')
import json as json_mod
try:
col_map = json_mod.loads(mapping_raw) if mapping_raw else {}
except Exception:
col_map = {}
default_cols = {
'employee': 'Employee',
'date': 'Date',
'clock_in': 'Clock In',
'clock_out': 'Clock Out',
'worked_hours': 'Worked Hours',
'net_hours': 'Net Hours',
'break_min': 'Break (min)',
'overtime': 'Overtime (h)',
'penalties': 'Penalties',
'location': 'Location',
}
for k in default_cols:
if k in col_map:
default_cols[k] = col_map[k]
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(list(default_cols.values()))
for att in self.attendance_ids.sorted(key=lambda a: a.check_in):
date_str = att.check_in.strftime('%Y-%m-%d') if att.check_in else ''
in_str = att.check_in.strftime('%H:%M') if att.check_in else ''
out_str = att.check_out.strftime('%H:%M') if att.check_out else ''
penalties = self.env['fusion.clock.penalty'].search_count([
('attendance_id', '=', att.id),
])
writer.writerow([
att.employee_id.name or '',
date_str,
in_str,
out_str,
round(att.worked_hours or 0, 2),
round(att.x_fclk_net_hours or 0, 2),
round(att.x_fclk_break_minutes or 0, 0),
round(att.x_fclk_overtime_hours or 0, 2),
penalties,
att.x_fclk_location_id.name or '',
])
csv_data = output.getvalue().encode('utf-8')
output.close()
filename = f"clock_export_{self.date_start}_{self.date_end}"
if self.employee_id:
filename += f"_{self.employee_id.name.replace(' ', '_')}"
filename += ".csv"
attachment = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(csv_data),
'mimetype': 'text/csv',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}/{filename}?download=true',
'target': 'new',
}
@api.model
def _cron_generate_period_reports(self):
"""Cron: Generate reports when a pay period ends."""

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionClockShift(models.Model):
_name = 'fusion.clock.shift'
_description = 'Clock Shift Schedule'
_order = 'sequence, name'
_rec_name = 'name'
name = fields.Char(
string='Shift Name',
required=True,
help="E.g. 'Morning Shift', 'Evening Shift'.",
)
start_time = fields.Float(
string='Start Time',
required=True,
default=9.0,
help="Shift start in 24h float (e.g. 7.0 = 7:00 AM).",
)
end_time = fields.Float(
string='End Time',
required=True,
default=17.0,
help="Shift end in 24h float (e.g. 15.0 = 3:00 PM).",
)
break_minutes = fields.Float(
string='Break Duration (min)',
default=30.0,
help="Unpaid break duration in minutes for this shift.",
)
sequence = fields.Integer(default=10)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
required=True,
)
active = fields.Boolean(default=True)
color = fields.Char(string='Color', default='#3B82F6')
employee_ids = fields.One2many(
'hr.employee',
'x_fclk_shift_id',
string='Assigned Employees',
)
employee_count = fields.Integer(
string='Employees',
compute='_compute_employee_count',
)
def _compute_employee_count(self):
for rec in self:
rec.employee_count = len(rec.employee_ids)
@property
def scheduled_hours(self):
"""Return the scheduled work hours for this shift (excluding break)."""
raw = self.end_time - self.start_time
return max(raw - (self.break_minutes / 60.0), 0.0)

View File

@@ -21,12 +21,15 @@ class HrAttendance(models.Model):
x_fclk_clock_source = fields.Selection(
[
('portal', 'Portal'),
('portal_fab', 'Portal FAB'),
('systray', 'Systray'),
('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'),
('manual', 'Manual'),
('auto', 'Auto Clock-Out'),
],
string='Clock Source',
tracking=True,
help="How this attendance was recorded.",
)
x_fclk_in_distance = fields.Float(
@@ -42,12 +45,14 @@ class HrAttendance(models.Model):
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,
tracking=True,
help="Break duration in minutes to deduct from worked hours.",
)
x_fclk_net_hours = fields.Float(
string='Net Hours',
compute='_compute_net_hours',
store=True,
tracking=True,
help="Worked hours minus break deduction.",
)
x_fclk_penalty_ids = fields.One2many(
@@ -66,6 +71,26 @@ class HrAttendance(models.Model):
help="Whether the grace period was consumed before auto clock-out.",
)
# Overtime
x_fclk_overtime_hours = fields.Float(
string='Overtime (h)',
compute='_compute_overtime_hours',
store=True,
help="Hours beyond the scheduled shift for this day.",
)
x_fclk_is_overtime = fields.Boolean(
string='Has Overtime',
compute='_compute_overtime_hours',
store=True,
)
# Photo verification
x_fclk_checkin_photo = fields.Binary(
string='Check-In Photo',
attachment=True,
help="Selfie captured at clock-in for verification.",
)
@api.depends('worked_hours', 'x_fclk_break_minutes')
def _compute_net_hours(self):
for att in self:
@@ -73,51 +98,61 @@ class HrAttendance(models.Model):
raw = att.worked_hours or 0.0
att.x_fclk_net_hours = max(raw - break_hours, 0.0)
@api.depends('x_fclk_net_hours')
def _compute_overtime_hours(self):
ICP = self.env['ir.config_parameter'].sudo()
enable_ot = ICP.get_param('fusion_clock.enable_overtime', 'True') == 'True'
daily_threshold = float(ICP.get_param('fusion_clock.daily_overtime_threshold', '8.0'))
for att in self:
if not enable_ot or not att.check_out:
att.x_fclk_overtime_hours = 0.0
att.x_fclk_is_overtime = False
continue
employee = att.employee_id
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
net = att.x_fclk_net_hours or 0.0
if net > scheduled_hours:
att.x_fclk_overtime_hours = round(net - scheduled_hours, 2)
att.x_fclk_is_overtime = True
else:
att.x_fclk_overtime_hours = 0.0
att.x_fclk_is_overtime = False
@api.model
def _cron_fusion_auto_clock_out(self):
"""Cron job: auto clock-out employees after shift + grace period.
Runs every 15 minutes. Finds open attendances that have exceeded
the maximum shift length plus grace period, and closes them.
"""
"""Cron job: auto clock-out employees after shift + grace period."""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
return
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
now = fields.Datetime.now()
# Find all open attendances (no check_out)
open_attendances = self.sudo().search([
('check_out', '=', False),
])
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
for att in open_attendances:
check_in = att.check_in
if not check_in:
continue
# Calculate the scheduled end + grace for this attendance
check_in_date = check_in.date()
out_h = int(clock_out_hour)
out_m = int((clock_out_hour - out_h) * 60)
scheduled_end = datetime.combine(
check_in_date,
datetime.min.time().replace(hour=out_h, minute=out_m),
)
deadline = scheduled_end + timedelta(minutes=grace_min)
employee = att.employee_id
_, scheduled_out = employee._get_fclk_scheduled_times(check_in.date())
# Also check max shift safety net
deadline = scheduled_out + timedelta(minutes=grace_min)
max_deadline = check_in + timedelta(hours=max_shift)
# Use the earlier of the two deadlines
effective_deadline = min(deadline, max_deadline)
if now > effective_deadline:
# Auto clock-out at the deadline time (not now)
clock_out_time = min(effective_deadline, now)
try:
att.sudo().write({
@@ -128,19 +163,42 @@ class HrAttendance(models.Model):
})
# Apply break deduction
employee = att.employee_id
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
if (att.worked_hours or 0) >= threshold:
break_min = employee._get_fclk_break_minutes()
att.sudo().write({'x_fclk_break_minutes': break_min})
# Post chatter message
att.sudo().message_post(
body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} "
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
message_type='comment',
subtype_xmlid='mail.mt_note',
)
# Log to activity log
ActivityLog.create({
'employee_id': employee.id,
'log_type': 'auto_clock_out',
'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. "
f"Net hours: {att.x_fclk_net_hours:.1f}h",
'attendance_id': att.id,
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
'source': 'system',
})
# Set pending reason
employee.sudo().write({'x_fclk_pending_reason': True})
# Notify office user
self._fclk_notify_office(
office_user_id,
f"Auto Clock-Out: {employee.name}",
f"{employee.name} was auto-clocked out at {clock_out_time.strftime('%H:%M')}. "
f"Please review and correct if needed.",
'hr.attendance',
att.id,
)
_logger.info(
"Fusion Clock: Auto clocked out %s (attendance %s)",
employee.name, att.id,
@@ -150,3 +208,242 @@ class HrAttendance(models.Model):
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
att.id, str(e),
)
@api.model
def _cron_fusion_check_absences(self):
"""Cron job: check for absent employees (no attendance on workday)."""
ICP = self.env['ir.config_parameter'].sudo()
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3'))
yesterday = fields.Date.today() - timedelta(days=1)
# Skip weekends
if yesterday.weekday() >= 5:
return
# Skip public holidays
holidays = self.env['resource.calendar.leaves'].sudo().search([
('resource_id', '=', False),
('date_from', '<=', datetime.combine(yesterday, datetime.max.time())),
('date_to', '>=', datetime.combine(yesterday, datetime.min.time())),
])
if holidays:
return
employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
])
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
for emp in employees:
# Check for attendance yesterday
att_count = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(yesterday, datetime.min.time())),
('check_in', '<', datetime.combine(yesterday + timedelta(days=1), datetime.min.time())),
])
if att_count > 0:
continue
# Check for approved leave
leave = LeaveRequest.search([
('employee_id', '=', emp.id),
('leave_date', '=', yesterday),
], limit=1)
if leave:
continue
# Mark absent
ActivityLog.create({
'employee_id': emp.id,
'log_type': 'absent',
'log_date': datetime.combine(yesterday, datetime.min.time().replace(hour=9)),
'description': f"No attendance recorded for {yesterday}",
'source': 'system',
})
emp.sudo().write({'x_fclk_pending_reason': True})
# Check monthly threshold
month_start = yesterday.replace(day=1)
absence_count = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
])
if absence_count >= max_absences:
self._fclk_notify_office(
office_user_id,
f"Excessive Absences: {emp.name}",
f"{emp.name} has {absence_count} absences this month "
f"(threshold: {max_absences}). Please review.",
'hr.employee',
emp.id,
)
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
@api.model
def _cron_fusion_employee_reminders(self):
"""Cron job: send clock-in/out reminders to employees."""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
return
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
now = fields.Datetime.now()
today = fields.Date.today()
# Skip weekends
if today.weekday() >= 5:
return
employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
])
for emp in employees:
if emp.x_fclk_last_reminder_date == today:
continue
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
is_checked_in = emp.attendance_state == 'checked_in'
# Missed clock-in reminder
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
if not is_checked_in and now > reminder_deadline:
has_attendance = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(today, datetime.min.time())),
])
if has_attendance == 0:
self._fclk_send_employee_reminder(
emp,
"Clock-In Reminder",
f"Hi {emp.name}, you haven't clocked in yet today. "
f"Your shift started at {scheduled_in.strftime('%I:%M %p')}.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
# Clock-out reminder
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
if is_checked_in and now > reminder_before_end and now < scheduled_out:
self._fclk_send_employee_reminder(
emp,
"Clock-Out Reminder",
f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. "
f"Don't forget to clock out.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
@api.model
def _cron_fusion_weekly_summary(self):
"""Cron job: send weekly summary email to employees (Monday 8 AM)."""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True':
return
today = fields.Date.today()
if today.weekday() != 0:
return
week_start = today - timedelta(days=7)
week_end = today - timedelta(days=1)
employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
])
template = self.env.ref('fusion_clock.mail_template_weekly_summary', raise_if_not_found=False)
for emp in employees:
if not emp.work_email:
continue
atts = self.sudo().search([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
('check_in', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())),
('check_out', '!=', False),
])
total_net = sum(a.x_fclk_net_hours or 0 for a in atts)
total_ot = sum(a.x_fclk_overtime_hours or 0 for a in atts)
penalties = self.env['fusion.clock.penalty'].sudo().search_count([
('employee_id', '=', emp.id),
('date', '>=', week_start),
('date', '<=', week_end),
])
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
absences = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(week_start, datetime.min.time())),
('log_date', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())),
])
if template:
try:
template.with_context(
week_start=week_start,
week_end=week_end,
total_hours=round(total_net, 1),
overtime_hours=round(total_ot, 1),
penalty_count=penalties,
absence_count=absences,
streak=emp.x_fclk_ontime_streak,
).send_mail(emp.id, force_send=False)
except Exception as e:
_logger.error("Fusion Clock: Failed to send weekly summary to %s: %s", emp.name, e)
@api.model
def _fclk_notify_office(self, office_user_id, summary, note, res_model, res_id):
"""Create a mail.activity for the office user."""
if not office_user_id:
return
office_user = self.env['res.users'].sudo().browse(office_user_id)
if not office_user.exists():
return
try:
self.env['mail.activity'].sudo().create({
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': summary,
'note': note,
'user_id': office_user_id,
'res_model_id': self.env['ir.model']._get_id(res_model),
'res_id': res_id,
'date_deadline': fields.Date.today(),
})
except Exception as e:
_logger.error("Fusion Clock: Failed to create office activity: %s", e)
@api.model
def _fclk_send_employee_reminder(self, employee, subject, body):
"""Send a notification to an employee via internal note."""
try:
if employee.user_id:
employee.user_id.sudo().notify_info(
message=body,
title=subject,
sticky=False,
)
except Exception:
pass
try:
if employee.work_email:
mail_values = {
'subject': f"Fusion Clock: {subject}",
'body_html': f"<p>{body}</p>",
'email_to': employee.work_email,
'auto_delete': True,
}
self.env['mail.mail'].sudo().create(mail_values).send()
except Exception as e:
_logger.error("Fusion Clock: Failed to send reminder to %s: %s", employee.name, e)

View File

@@ -2,6 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo import models, fields, api
@@ -21,16 +22,167 @@ class HrEmployee(models.Model):
x_fclk_break_minutes = fields.Float(
string='Custom Break (min)',
default=0.0,
help="Override default break duration for this employee. 0 = use company default.",
help="Override default break duration for this employee. 0 = use shift or company default.",
)
# Shift scheduling
x_fclk_shift_id = fields.Many2one(
'fusion.clock.shift',
string='Work Shift',
help="Assigned shift schedule. Leave empty to use global defaults.",
)
# Pending reason enforcement
x_fclk_pending_reason = fields.Boolean(
string='Pending Reason Required',
default=False,
help="If set, employee must explain a missed clock-out before clocking in again.",
)
# Kiosk PIN
x_fclk_kiosk_pin = fields.Char(
string='Kiosk PIN',
help="PIN code for kiosk clock-in/out identification.",
groups="fusion_clock.group_fusion_clock_manager",
)
# On-time streak
x_fclk_ontime_streak = fields.Integer(
string='On-Time Streak',
default=0,
help="Consecutive workdays clocked in on time.",
)
# Absence tracking (computed)
x_fclk_absences_this_month = fields.Integer(
string='Absences This Month',
compute='_compute_absence_counts',
)
x_fclk_absences_this_year = fields.Integer(
string='Absences This Year',
compute='_compute_absence_counts',
)
# Overtime tracking (computed)
x_fclk_overtime_this_week = fields.Float(
string='Overtime This Week (h)',
compute='_compute_overtime',
)
x_fclk_overtime_this_month = fields.Float(
string='Overtime This Month (h)',
compute='_compute_overtime',
)
# Activity log relation
x_fclk_activity_log_ids = fields.One2many(
'fusion.clock.activity.log',
'employee_id',
string='Activity Logs',
)
# Leave request relation
x_fclk_leave_request_ids = fields.One2many(
'fusion.clock.leave.request',
'employee_id',
string='Leave Requests',
)
# Correction request relation
x_fclk_correction_ids = fields.One2many(
'fusion.clock.correction',
'employee_id',
string='Correction Requests',
)
# Reminder tracking
x_fclk_last_reminder_date = fields.Date(
string='Last Reminder Date',
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _get_fclk_break_minutes(self):
"""Return effective break minutes for this employee."""
"""Return effective break minutes for this employee.
Priority: employee override > shift > global setting.
"""
self.ensure_one()
if self.x_fclk_break_minutes > 0:
return self.x_fclk_break_minutes
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
return self.x_fclk_shift_id.break_minutes
return float(
self.env['ir.config_parameter'].sudo().get_param(
'fusion_clock.default_break_minutes', '30'
)
)
def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
Uses employee shift if assigned, otherwise global settings.
"""
self.ensure_one()
if self.x_fclk_shift_id:
in_hour = self.x_fclk_shift_id.start_time
out_hour = self.x_fclk_shift_id.end_time
else:
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
in_h = int(in_hour)
in_m = int((in_hour - in_h) * 60)
out_h = int(out_hour)
out_m = int((out_hour - out_h) * 60)
scheduled_in = datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
scheduled_out = datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self):
"""Return the expected work hours for this employee's shift."""
self.ensure_one()
if self.x_fclk_shift_id:
return self.x_fclk_shift_id.scheduled_hours
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_hrs = self._get_fclk_break_minutes() / 60.0
return max((out_hour - in_hour) - break_hrs, 0.0)
def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
today = fields.Date.today()
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
for emp in self:
emp.x_fclk_absences_this_month = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
])
emp.x_fclk_absences_this_year = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(year_start, datetime.min.time())),
])
def _compute_overtime(self):
Attendance = self.env['hr.attendance'].sudo()
today = fields.Date.today()
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
for emp in self:
week_atts = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
('check_out', '!=', False),
])
emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts)
month_atts = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(month_start, datetime.min.time())),
('check_out', '!=', False),
])
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)

View File

@@ -38,7 +38,7 @@ class ResConfigSettings(models.TransientModel):
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
default=5.0,
default=4.0,
help="Only deduct break if shift is longer than this many hours.",
)
@@ -73,6 +73,116 @@ class ResConfigSettings(models.TransientModel):
default=5.0,
help="Minutes of grace before a late/early penalty is recorded.",
)
fclk_penalty_deduction_minutes = fields.Float(
string='Penalty Deduction (min)',
config_parameter='fusion_clock.penalty_deduction_minutes',
default=15.0,
help="Minutes deducted from worked hours per penalty occurrence.",
)
# -- Office User & Notifications --
fclk_office_user_id = fields.Many2one(
'res.users',
string='Office User',
help="User who receives activity notifications for attendance issues.",
)
fclk_very_late_threshold_minutes = fields.Float(
string='Very Late Threshold (min)',
config_parameter='fusion_clock.very_late_threshold_minutes',
default=15.0,
help="Minutes late before an activity is scheduled for the office user.",
)
fclk_max_monthly_absences = fields.Integer(
string='Max Monthly Absences',
config_parameter='fusion_clock.max_monthly_absences',
default=3,
help="Alert office user when an employee reaches this many absences in a month.",
)
fclk_enable_employee_notifications = fields.Boolean(
string='Enable Employee Notifications',
config_parameter='fusion_clock.enable_employee_notifications',
default=True,
help="Send clock-in/out reminders to employees.",
)
fclk_reminder_before_shift_minutes = fields.Float(
string='Remind After Shift Start (min)',
config_parameter='fusion_clock.reminder_before_shift_minutes',
default=30.0,
help="Send reminder if employee hasn't clocked in this many minutes after shift start.",
)
fclk_reminder_before_end_minutes = fields.Float(
string='Remind Before Shift End (min)',
config_parameter='fusion_clock.reminder_before_end_minutes',
default=15.0,
help="Send clock-out reminder this many minutes before shift end.",
)
fclk_send_weekly_summary = fields.Boolean(
string='Send Weekly Summary',
config_parameter='fusion_clock.send_weekly_summary',
default=True,
help="Send weekly attendance summary to each employee on Monday.",
)
# -- Overtime --
fclk_enable_overtime = fields.Boolean(
string='Enable Overtime Tracking',
config_parameter='fusion_clock.enable_overtime',
default=True,
)
fclk_daily_overtime_threshold = fields.Float(
string='Daily OT Threshold (hours)',
config_parameter='fusion_clock.daily_overtime_threshold',
default=8.0,
help="Net hours beyond this threshold count as daily overtime.",
)
fclk_weekly_overtime_threshold = fields.Float(
string='Weekly OT Threshold (hours)',
config_parameter='fusion_clock.weekly_overtime_threshold',
default=40.0,
help="Net hours beyond this threshold count as weekly overtime.",
)
# -- Location --
fclk_enable_ip_fallback = fields.Boolean(
string='Enable IP Fallback',
config_parameter='fusion_clock.enable_ip_fallback',
default=False,
help="Allow IP-based location verification when GPS is unavailable.",
)
fclk_enable_photo_verification = fields.Boolean(
string='Enable Photo Verification',
config_parameter='fusion_clock.enable_photo_verification',
default=False,
help="Global toggle for selfie verification on clock-in (per-location control).",
)
# -- Kiosk --
fclk_enable_kiosk = fields.Boolean(
string='Enable Kiosk Mode',
config_parameter='fusion_clock.enable_kiosk',
default=False,
)
fclk_kiosk_pin_required = fields.Boolean(
string='Require PIN for Kiosk',
config_parameter='fusion_clock.kiosk_pin_required',
default=True,
help="Require employees to enter a PIN when using kiosk mode.",
)
# -- Corrections --
fclk_enable_correction_requests = fields.Boolean(
string='Enable Correction Requests',
config_parameter='fusion_clock.enable_correction_requests',
default=True,
help="Allow employees to request timesheet corrections from the portal.",
)
# -- CSV Export --
fclk_csv_column_mapping = fields.Char(
string='CSV Column Mapping',
config_parameter='fusion_clock.csv_column_mapping',
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
)
# -- Pay Period --
fclk_pay_period_type = fields.Selection(
@@ -89,7 +199,7 @@ class ResConfigSettings(models.TransientModel):
fclk_pay_period_start = fields.Char(
string='Pay Period Anchor Date',
config_parameter='fusion_clock.pay_period_start',
help="Start date for pay period calculations (YYYY-MM-DD format, anchor for weekly/biweekly).",
help="Start date for pay period calculations (YYYY-MM-DD format).",
)
# -- Reports --
@@ -122,3 +232,20 @@ class ResConfigSettings(models.TransientModel):
config_parameter='fusion_clock.enable_sounds',
default=True,
)
def set_values(self):
super().set_values()
ICP = self.env['ir.config_parameter'].sudo()
if self.fclk_office_user_id:
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
else:
ICP.set_param('fusion_clock.office_user_id', '0')
@api.model
def get_values(self):
res = super().get_values()
ICP = self.env['ir.config_parameter'].sudo()
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id:
res['fclk_office_user_id'] = office_user_id
return res

View File

@@ -5,8 +5,20 @@ access_fusion_clock_penalty_user,fusion.clock.penalty.user,model_fusion_clock_pe
access_fusion_clock_penalty_manager,fusion.clock.penalty.manager,model_fusion_clock_penalty,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_report_user,fusion.clock.report.user,model_fusion_clock_report,group_fusion_clock_user,1,0,0,0
access_fusion_clock_report_manager,fusion.clock.report.manager,model_fusion_clock_report,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_activity_log_user,fusion.clock.activity.log.user,model_fusion_clock_activity_log,group_fusion_clock_user,1,0,0,0
access_fusion_clock_activity_log_manager,fusion.clock.activity.log.manager,model_fusion_clock_activity_log,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fusion_clock_leave_request,group_fusion_clock_user,1,0,0,0
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
access_fusion_clock_penalty_portal,fusion.clock.penalty.portal,model_fusion_clock_penalty,base.group_portal,1,0,0,0
access_fusion_clock_report_portal,fusion.clock.report.portal,model_fusion_clock_report,base.group_portal,1,0,0,0
access_fusion_clock_activity_log_portal,fusion.clock.activity.log.portal,model_fusion_clock_activity_log,base.group_portal,1,0,0,0
access_fusion_clock_leave_request_portal,fusion.clock.leave.request.portal,model_fusion_clock_leave_request,base.group_portal,1,0,0,0
access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusion_clock_correction,base.group_portal,1,0,0,0
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fusion_clock_penalty_manager fusion.clock.penalty.manager model_fusion_clock_penalty group_fusion_clock_manager 1 1 1 1
6 access_fusion_clock_report_user fusion.clock.report.user model_fusion_clock_report group_fusion_clock_user 1 0 0 0
7 access_fusion_clock_report_manager fusion.clock.report.manager model_fusion_clock_report group_fusion_clock_manager 1 1 1 1
8 access_fusion_clock_activity_log_user fusion.clock.activity.log.user model_fusion_clock_activity_log group_fusion_clock_user 1 0 0 0
9 access_fusion_clock_activity_log_manager fusion.clock.activity.log.manager model_fusion_clock_activity_log group_fusion_clock_manager 1 1 1 1
10 access_fusion_clock_leave_request_user fusion.clock.leave.request.user model_fusion_clock_leave_request group_fusion_clock_user 1 0 0 0
11 access_fusion_clock_leave_request_manager fusion.clock.leave.request.manager model_fusion_clock_leave_request group_fusion_clock_manager 1 1 1 1
12 access_fusion_clock_shift_user fusion.clock.shift.user model_fusion_clock_shift group_fusion_clock_user 1 0 0 0
13 access_fusion_clock_shift_manager fusion.clock.shift.manager model_fusion_clock_shift group_fusion_clock_manager 1 1 1 1
14 access_fusion_clock_correction_user fusion.clock.correction.user model_fusion_clock_correction group_fusion_clock_user 1 0 0 0
15 access_fusion_clock_correction_manager fusion.clock.correction.manager model_fusion_clock_correction group_fusion_clock_manager 1 1 1 1
16 access_fusion_clock_location_portal fusion.clock.location.portal model_fusion_clock_location base.group_portal 1 0 0 0
17 access_fusion_clock_penalty_portal fusion.clock.penalty.portal model_fusion_clock_penalty base.group_portal 1 0 0 0
18 access_fusion_clock_report_portal fusion.clock.report.portal model_fusion_clock_report base.group_portal 1 0 0 0
19 access_fusion_clock_activity_log_portal fusion.clock.activity.log.portal model_fusion_clock_activity_log base.group_portal 1 0 0 0
20 access_fusion_clock_leave_request_portal fusion.clock.leave.request.portal model_fusion_clock_leave_request base.group_portal 1 0 0 0
21 access_fusion_clock_correction_portal fusion.clock.correction.portal model_fusion_clock_correction base.group_portal 1 0 0 0
22 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
23 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
24 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0

View File

@@ -8,20 +8,27 @@
<field name="comment">Can clock in/out and view own attendance</field>
</record>
<record id="group_fusion_clock_team_lead" model="res.groups">
<field name="name">Fusion Clock / Team Lead</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="comment">Can view direct reports attendance (read-only)</field>
</record>
<record id="group_fusion_clock_manager" model="res.groups">
<field name="name">Fusion Clock / Manager</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="comment">Can manage locations, view all attendance, generate reports</field>
</record>
<!-- Auto-assign admin to Manager group -->
<record id="base.user_admin" model="res.users">
<field name="groups_id" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<function model="res.users" name="write">
<value eval="[ref('base.user_admin')]"/>
<value eval="{'group_ids': [(4, ref('group_fusion_clock_manager'))]}"/>
</function>
<!-- Record Rules -->
<!-- Clock Location: Managers see all, Users see active ones for their company -->
<!-- ================================================================
Record Rules - Clock Location
================================================================ -->
<record id="rule_clock_location_user" model="ir.rule">
<field name="name">Clock Location: User sees active company locations</field>
<field name="model_id" ref="model_fusion_clock_location"/>
@@ -40,7 +47,9 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- Clock Penalty: Users see own, Managers see all -->
<!-- ================================================================
Record Rules - Clock Penalty
================================================================ -->
<record id="rule_clock_penalty_user" model="ir.rule">
<field name="name">Clock Penalty: User sees own penalties</field>
<field name="model_id" ref="model_fusion_clock_penalty"/>
@@ -52,6 +61,17 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_clock_penalty_team_lead" model="ir.rule">
<field name="name">Clock Penalty: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_penalty"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_clock_penalty_manager" model="ir.rule">
<field name="name">Clock Penalty: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_penalty"/>
@@ -59,7 +79,9 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- Clock Report: Users see own, Managers see all -->
<!-- ================================================================
Record Rules - Clock Report
================================================================ -->
<record id="rule_clock_report_user" model="ir.rule">
<field name="name">Clock Report: User sees own reports</field>
<field name="model_id" ref="model_fusion_clock_report"/>
@@ -78,7 +100,115 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- Portal access for attendance records -->
<!-- ================================================================
Record Rules - Activity Log
================================================================ -->
<record id="rule_activity_log_user" model="ir.rule">
<field name="name">Activity Log: User sees own logs</field>
<field name="model_id" ref="model_fusion_clock_activity_log"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_activity_log_team_lead" model="ir.rule">
<field name="name">Activity Log: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_activity_log"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_activity_log_manager" model="ir.rule">
<field name="name">Activity Log: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_activity_log"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Record Rules - Leave Request
================================================================ -->
<record id="rule_leave_request_user" model="ir.rule">
<field name="name">Leave Request: User sees own</field>
<field name="model_id" ref="model_fusion_clock_leave_request"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_leave_request_manager" model="ir.rule">
<field name="name">Leave Request: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_leave_request"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Record Rules - Shift
================================================================ -->
<record id="rule_shift_user" model="ir.rule">
<field name="name">Shift: User reads active</field>
<field name="model_id" ref="model_fusion_clock_shift"/>
<field name="domain_force">[('active', '=', True)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_shift_manager" model="ir.rule">
<field name="name">Shift: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_shift"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Record Rules - Correction Request
================================================================ -->
<record id="rule_correction_user" model="ir.rule">
<field name="name">Correction: User sees own</field>
<field name="model_id" ref="model_fusion_clock_correction"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_correction_team_lead" model="ir.rule">
<field name="name">Correction: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_correction"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_correction_manager" model="ir.rule">
<field name="name">Correction: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_correction"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Portal Access
================================================================ -->
<record id="rule_hr_attendance_portal" model="ir.rule">
<field name="name">HR Attendance: Portal user sees own</field>
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
@@ -90,7 +220,6 @@
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal access for clock locations -->
<record id="rule_clock_location_portal" model="ir.rule">
<field name="name">Clock Location: Portal user sees active</field>
<field name="model_id" ref="model_fusion_clock_location"/>
@@ -102,7 +231,6 @@
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal access for clock reports -->
<record id="rule_clock_report_portal" model="ir.rule">
<field name="name">Clock Report: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_report"/>
@@ -114,7 +242,6 @@
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal access for clock penalties -->
<record id="rule_clock_penalty_portal" model="ir.rule">
<field name="name">Clock Penalty: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_penalty"/>
@@ -126,4 +253,37 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_activity_log_portal" model="ir.rule">
<field name="name">Activity Log: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_activity_log"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_leave_request_portal" model="ir.rule">
<field name="name">Leave Request: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_leave_request"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_correction_portal" model="ir.rule">
<field name="name">Correction: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_correction"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

View File

@@ -357,6 +357,52 @@ html.o_dark .fclk-app,
font-size: 12px;
}
/* ---- Request Leave Button ---- */
.fclk-leave-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 28px;
color: var(--fclk-text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--fclk-shadow);
text-align: left;
font-family: inherit;
}
.fclk-leave-btn svg:first-child {
color: var(--fclk-green);
flex-shrink: 0;
}
.fclk-leave-btn-arrow {
margin-left: auto;
color: var(--fclk-text-dim);
flex-shrink: 0;
transition: transform 0.2s ease;
}
.fclk-leave-btn:hover {
background: var(--fclk-hover-bg);
border-color: rgba(16, 185, 129, 0.3);
}
.fclk-leave-btn:hover .fclk-leave-btn-arrow {
transform: translateX(2px);
}
.fclk-leave-btn:active {
transform: scale(0.99);
}
/* ---- Recent Activity ---- */
.fclk-recent-section {
margin-bottom: 24px;
@@ -486,7 +532,7 @@ html.o_dark .fclk-app,
text-decoration: none;
}
/* ---- Modal ---- */
/* ---- Legacy Modal (location picker still uses this) ---- */
.fclk-modal {
position: fixed;
top: 0;
@@ -533,6 +579,300 @@ html.o_dark .fclk-app,
to { transform: translateY(0); }
}
/* ============================================================
Wizard Dialogs - Professional modals for reasons, confirmations
Theme-aware, works in both light and dark mode
============================================================ */
/* Standalone fallbacks for wizard modals rendered outside .fclk-app */
.fclk-wizard-overlay {
--fclk-card: var(--fclk-card, #ffffff);
--fclk-card-border: var(--fclk-card-border, #e5e7eb);
--fclk-bg: var(--fclk-bg, #f3f4f6);
--fclk-text: var(--fclk-text, #1f2937);
--fclk-text-muted: var(--fclk-text-muted, #6b7280);
--fclk-text-dim: var(--fclk-text-dim, #9ca3af);
--fclk-green: var(--fclk-green, #10B981);
--fclk-green-glow: var(--fclk-green-glow, rgba(16, 185, 129, 0.25));
--fclk-hover-bg: var(--fclk-hover-bg, #f9fafb);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@media (prefers-color-scheme: dark) {
.fclk-wizard-overlay {
--fclk-card: #1a1d23;
--fclk-card-border: #2a2d35;
--fclk-bg: #0f1117;
--fclk-text: #ffffff;
--fclk-text-muted: #9ca3af;
--fclk-text-dim: #6b7280;
--fclk-green-glow: rgba(16, 185, 129, 0.3);
--fclk-hover-bg: #1e2128;
}
}
html.o_dark .fclk-wizard-overlay {
--fclk-card: #1a1d23;
--fclk-card-border: #2a2d35;
--fclk-bg: #0f1117;
--fclk-text: #ffffff;
--fclk-text-muted: #9ca3af;
--fclk-text-dim: #6b7280;
--fclk-green-glow: rgba(16, 185, 129, 0.3);
--fclk-hover-bg: #1e2128;
}
.fclk-wizard-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.fclk-wizard-dialog {
position: relative;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 20px;
width: 100%;
max-width: 440px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
animation: fclk-wizard-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.fclk-wizard-dialog--compact {
max-width: 380px;
}
@keyframes fclk-wizard-enter {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.fclk-wizard-header {
padding: 28px 24px 20px;
text-align: center;
border-bottom: 1px solid var(--fclk-card-border);
}
.fclk-wizard-header-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.fclk-wizard-header--warning .fclk-wizard-header-icon {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.fclk-wizard-header--danger .fclk-wizard-header-icon {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.fclk-wizard-header--info .fclk-wizard-header-icon {
background: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
.fclk-wizard-title {
color: var(--fclk-text);
font-size: 20px;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: -0.3px;
}
.fclk-wizard-subtitle {
color: var(--fclk-text-muted);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.fclk-wizard-body {
padding: 24px;
}
.fclk-wizard-field {
margin-bottom: 20px;
}
.fclk-wizard-field:last-child {
margin-bottom: 0;
}
.fclk-wizard-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--fclk-text);
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.fclk-wizard-label svg {
color: var(--fclk-text-muted);
flex-shrink: 0;
}
.fclk-wizard-required {
color: #ef4444;
font-weight: 700;
}
.fclk-wizard-input {
width: 100%;
background: var(--fclk-bg);
border: 1.5px solid var(--fclk-card-border);
border-radius: 12px;
padding: 12px 14px;
font-size: 14px;
color: var(--fclk-text);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
font-family: inherit;
}
.fclk-wizard-input:focus {
border-color: var(--fclk-green);
box-shadow: 0 0 0 3px var(--fclk-green-glow);
}
.fclk-wizard-input::placeholder {
color: var(--fclk-text-dim);
}
.fclk-wizard-textarea {
resize: vertical;
min-height: 80px;
}
.fclk-wizard-hint {
display: block;
color: var(--fclk-text-dim);
font-size: 11px;
margin-top: 6px;
}
.fclk-wizard-footer {
padding: 16px 24px 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
border-top: 1px solid var(--fclk-card-border);
}
.fclk-wizard-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
letter-spacing: 0.2px;
}
.fclk-wizard-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fclk-wizard-btn--primary {
background: linear-gradient(135deg, #10B981, #059669);
color: #fff;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.fclk-wizard-btn--primary:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
transform: translateY(-1px);
}
.fclk-wizard-btn--danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #fff;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
.fclk-wizard-btn--danger:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
transform: translateY(-1px);
}
.fclk-wizard-btn--secondary {
background: var(--fclk-bg);
color: var(--fclk-text-muted);
border: 1px solid var(--fclk-card-border);
}
.fclk-wizard-btn--secondary:hover:not(:disabled) {
background: var(--fclk-hover-bg);
color: var(--fclk-text);
}
/* Clock-out confirmation summary card */
.fclk-clockout-summary {
background: var(--fclk-bg);
border: 1px solid var(--fclk-card-border);
border-radius: 12px;
padding: 16px;
}
.fclk-clockout-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.fclk-clockout-summary-row + .fclk-clockout-summary-row {
border-top: 1px solid var(--fclk-card-border);
}
.fclk-clockout-summary-label {
color: var(--fclk-text-muted);
font-size: 13px;
}
.fclk-clockout-summary-value {
color: var(--fclk-text);
font-size: 14px;
font-weight: 600;
}
.fclk-modal-list {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,67 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class FusionClockDashboard extends Component {
static template = "fusion_clock.Dashboard";
static props = { "*": true };
setup() {
this.action = useService("action");
this.state = useState({
loading: true,
clocked_in: [],
total_employees: 0,
present_count: 0,
absent_count: 0,
late_count: 0,
pending_reasons: 0,
pending_corrections: 0,
error: "",
});
onWillStart(async () => {
await this._fetchData();
});
}
async _fetchData() {
this.state.loading = true;
try {
const data = await rpc("/fusion_clock/dashboard_data", {});
if (data.error) {
this.state.error = data.error;
} else {
Object.assign(this.state, data);
}
} catch (e) {
this.state.error = "Failed to load dashboard data.";
}
this.state.loading = false;
}
async onRefresh() {
await this._fetchData();
}
onViewAttendances() {
this.action.doAction("hr_attendance.hr_attendance_action");
}
onViewCorrections() {
this.action.doAction("fusion_clock.action_fusion_clock_correction");
}
onViewActivityLogs() {
this.action.doAction("fusion_clock.action_fusion_clock_activity_log");
}
onViewPenalties() {
this.action.doAction("fusion_clock.action_fusion_clock_penalty");
}
}
registry.category("actions").add("fusion_clock.Dashboard", FusionClockDashboard);

View File

@@ -0,0 +1,228 @@
/** @odoo-module **/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
export class FusionClockKiosk extends Interaction {
static selector = "#fclk-kiosk";
setup() {
this.selectedEmployeeId = 0;
this.resetTimer = null;
this.searchTimeout = null;
const pinAttr = this.el.dataset.pinRequired;
this.pinRequired = pinAttr === "true" || pinAttr === "True";
this._startClock();
this._bindEvents();
}
_startClock() {
const el = document.getElementById("fclk-kiosk-time");
if (!el) return;
const update = () => {
el.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
update();
setInterval(update, 1000);
}
_bindEvents() {
const queryInput = document.getElementById("fclk-kiosk-query");
if (queryInput) {
queryInput.addEventListener("input", (e) => this._onSearch(e.target.value));
}
const backBtn = document.getElementById("fclk-kiosk-back-btn");
if (backBtn) {
backBtn.addEventListener("click", () => this._resetKiosk());
}
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
if (clockBtn) {
clockBtn.addEventListener("click", () => this._onClock());
}
}
_resetKiosk() {
const search = document.getElementById("fclk-kiosk-search");
const pin = document.getElementById("fclk-kiosk-pin");
const result = document.getElementById("fclk-kiosk-result");
const error = document.getElementById("fclk-kiosk-error");
const query = document.getElementById("fclk-kiosk-query");
const results = document.getElementById("fclk-kiosk-results");
const pinInput = document.getElementById("fclk-kiosk-pin-input");
if (search) search.style.display = "";
if (pin) pin.style.display = "none";
if (result) result.style.display = "none";
if (error) error.style.display = "none";
if (query) query.value = "";
if (results) results.innerHTML = "";
if (pinInput) pinInput.value = "";
this.selectedEmployeeId = 0;
if (this.resetTimer) clearTimeout(this.resetTimer);
}
_showError(msg) {
const el = document.getElementById("fclk-kiosk-error");
if (el) {
el.textContent = msg;
el.style.display = "";
}
}
_onSearch(value) {
if (this.searchTimeout) clearTimeout(this.searchTimeout);
const q = value.trim();
if (q.length < 2) {
const container = document.getElementById("fclk-kiosk-results");
if (container) container.innerHTML = "";
return;
}
this.searchTimeout = setTimeout(async () => {
try {
const resp = await fetch("/fusion_clock/kiosk/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { query: q } }),
});
const data = await resp.json();
const employees = (data.result || {}).employees || [];
const container = document.getElementById("fclk-kiosk-results");
if (!container) return;
container.innerHTML = "";
for (const emp of employees) {
const item = document.createElement("a");
item.href = "#";
item.className = "list-group-item list-group-item-action d-flex justify-content-between";
const statusBadge = emp.is_checked_in ? "bg-success" : "bg-secondary";
const statusText = emp.is_checked_in ? "In" : "Out";
item.innerHTML =
`<span>${emp.name} <small class="text-muted">${emp.department}</small></span>` +
`<span class="badge ${statusBadge}">${statusText}</span>`;
item.addEventListener("click", (e) => {
e.preventDefault();
this._selectEmployee(emp);
});
container.appendChild(item);
}
} catch {
this._showError("Search failed.");
}
}, 300);
}
_selectEmployee(emp) {
this.selectedEmployeeId = emp.id;
const nameEl = document.getElementById("fclk-kiosk-emp-name");
if (nameEl) nameEl.textContent = emp.name;
const searchEl = document.getElementById("fclk-kiosk-search");
const pinEl = document.getElementById("fclk-kiosk-pin");
const errorEl = document.getElementById("fclk-kiosk-error");
if (searchEl) searchEl.style.display = "none";
if (pinEl) pinEl.style.display = "";
if (errorEl) errorEl.style.display = "none";
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
if (clockBtn) {
clockBtn.textContent = emp.is_checked_in ? "Clock Out" : "Clock In";
clockBtn.className = "btn btn-lg " + (emp.is_checked_in ? "btn-danger" : "btn-success");
}
}
async _onClock() {
if (!this.selectedEmployeeId) return;
const btn = document.getElementById("fclk-kiosk-clock-btn");
if (btn) btn.disabled = true;
const pinInput = document.getElementById("fclk-kiosk-pin-input");
const pin = pinInput ? pinInput.value : "";
if (this.pinRequired && pin.length === 0) {
this._showError("Please enter your PIN.");
if (btn) btn.disabled = false;
return;
}
try {
if (this.pinRequired) {
const vResp = await fetch("/fusion_clock/kiosk/verify_pin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "call",
params: { employee_id: this.selectedEmployeeId, pin },
}),
});
const vData = await vResp.json();
if (vData.result && vData.result.error) {
this._showError(vData.result.error);
if (btn) btn.disabled = false;
return;
}
}
let lat = 0;
let lng = 0;
try {
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
timeout: 10000,
enableHighAccuracy: true,
});
});
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch {
// GPS unavailable on kiosk device
}
const resp = await fetch("/fusion_clock/kiosk/clock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "call",
params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng },
}),
});
const data = await resp.json();
const result = data.result || {};
if (result.error) {
this._showError(result.error);
if (btn) btn.disabled = false;
return;
}
const pinEl = document.getElementById("fclk-kiosk-pin");
const resultEl = document.getElementById("fclk-kiosk-result");
if (pinEl) pinEl.style.display = "none";
if (resultEl) resultEl.style.display = "";
const msgEl = document.getElementById("fclk-kiosk-result-msg");
if (msgEl) {
const icon = result.action === "clock_in" ? "fa-check-circle text-success" : "fa-hand-paper-o text-warning";
let html = `<div style="font-size:3rem"><i class="fa ${icon}"></i></div>`;
html += `<div class="mt-2">${result.message || "Done"}</div>`;
if (result.net_hours !== undefined) {
html += `<div class="text-muted mt-1">Net hours: ${result.net_hours}h</div>`;
}
msgEl.innerHTML = html;
}
this.resetTimer = setTimeout(() => this._resetKiosk(), 10000);
} catch {
this._showError("Operation failed.");
}
if (btn) btn.disabled = false;
}
}
registry.category("public.interactions").add("fusion_clock.kiosk", FusionClockKiosk);

View File

@@ -0,0 +1,247 @@
/** @odoo-module **/
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { rpc } from "@web/core/network/rpc";
export class FusionClockLocationMap extends Component {
static template = "fusion_clock.LocationMap";
static props = { ...standardFieldProps };
setup() {
this.mapRef = useRef("mapContainer");
this.map = null;
this.marker = null;
this.circle = null;
this._suppress = false;
this._interval = null;
this._AdvancedMarkerElement = null;
this.state = useState({
loading: true,
error: "",
mapVisible: false,
});
onMounted(() => this._init());
onWillUnmount(() => this._cleanup());
}
get lat() { return this.props.record.data.latitude || 0; }
get lng() { return this.props.record.data.longitude || 0; }
get radius() { return this.props.record.data.radius || 100; }
get color() { return this.props.record.data.color || "#10B981"; }
get hasCoords() { return this.lat !== 0 || this.lng !== 0; }
async _init() {
const apiKey = await this._getApiKey();
if (!apiKey) {
this.state.loading = false;
this.state.error = "Google Maps API key not configured. Set it in Fusion Clock Settings.";
return;
}
try {
await this._loadScript(apiKey);
} catch {
this.state.loading = false;
this.state.error = "Failed to load Google Maps API.";
return;
}
try {
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
this._AdvancedMarkerElement = AdvancedMarkerElement;
} catch {
this.state.loading = false;
this.state.error = "Failed to load marker library.";
return;
}
this.state.loading = false;
if (!this.hasCoords) {
this._startWatcher();
return;
}
this.state.mapVisible = true;
await new Promise((r) => requestAnimationFrame(r));
await new Promise((r) => requestAnimationFrame(r));
this._buildMap();
}
_buildMap() {
const el = this.mapRef.el;
if (!el || !window.google || !this._AdvancedMarkerElement) return;
const center = { lat: this.lat, lng: this.lng };
this.map = new google.maps.Map(el, {
center,
zoom: 17,
mapId: "DEMO_MAP_ID",
mapTypeControl: true,
mapTypeControlOptions: {
style: google.maps.MapTypeControlStyle.DROPDOWN_MENU,
position: google.maps.ControlPosition.TOP_RIGHT,
mapTypeIds: ["roadmap", "satellite", "hybrid"],
},
streetViewControl: false,
fullscreenControl: true,
zoomControl: true,
gestureHandling: "greedy",
});
this._placeMarker(center);
this._drawCircle(center);
if (!this.props.readonly) {
this.map.addListener("click", (e) => {
const pos = { lat: e.latLng.lat(), lng: e.latLng.lng() };
this._placeMarker(pos);
this._drawCircle(pos);
this._suppress = true;
this._saveCoords(pos.lat, pos.lng);
});
}
this._startWatcher();
}
_placeMarker(pos) {
if (this.marker) {
this.marker.position = pos;
return;
}
this.marker = new this._AdvancedMarkerElement({
map: this.map,
position: pos,
gmpDraggable: !this.props.readonly,
title: "Drag to fine-tune location",
});
if (!this.props.readonly) {
this.marker.addListener("dragend", () => {
const p = this.marker.position;
const newPos = { lat: p.lat, lng: p.lng };
this._drawCircle(newPos);
this._suppress = true;
this._saveCoords(newPos.lat, newPos.lng);
});
}
}
_drawCircle(center) {
if (this.circle) {
this.circle.setCenter(center);
this.circle.setRadius(this.radius);
this.circle.setOptions({ fillColor: this.color, strokeColor: this.color });
} else {
this.circle = new google.maps.Circle({
map: this.map,
center,
radius: this.radius,
fillColor: this.color,
fillOpacity: 0.15,
strokeColor: this.color,
strokeOpacity: 0.6,
strokeWeight: 2,
clickable: false,
});
}
}
async _saveCoords(lat, lng) {
if (this.props.readonly) return;
await this.props.record.update({
latitude: Math.round(lat * 10000000) / 10000000,
longitude: Math.round(lng * 10000000) / 10000000,
});
}
_startWatcher() {
if (this._interval) return;
this._lastLat = this.lat;
this._lastLng = this.lng;
this._lastRadius = this.radius;
this._interval = setInterval(() => {
const lat = this.lat;
const lng = this.lng;
const r = this.radius;
const moved = Math.abs(this._lastLat - lat) > 0.0000001
|| Math.abs(this._lastLng - lng) > 0.0000001;
const resized = Math.abs(this._lastRadius - r) > 0.5;
if (moved && this.map) {
this._lastLat = lat;
this._lastLng = lng;
if (this._suppress) { this._suppress = false; return; }
const pos = { lat, lng };
this._placeMarker(pos);
this._drawCircle(pos);
this.map.panTo(pos);
}
if (resized && this.circle) {
this._lastRadius = r;
this.circle.setRadius(r);
}
if (!this.map && this.hasCoords && !this.state.error && this._AdvancedMarkerElement) {
this.state.mapVisible = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this._buildMap();
});
});
}
}, 500);
}
async _getApiKey() {
try {
return await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fusion_clock.google_maps_api_key", ""],
kwargs: {},
}) || "";
} catch { return ""; }
}
async _loadScript(apiKey) {
if (window.google && window.google.maps) return;
return new Promise((resolve, reject) => {
if (document.querySelector('script[src*="maps.googleapis.com"]')) {
const t = setInterval(() => {
if (window.google && window.google.maps) { clearInterval(t); resolve(); }
}, 100);
setTimeout(() => { clearInterval(t); resolve(); }, 5000);
return;
}
const s = document.createElement("script");
s.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkMapCb`;
s.async = true;
s.defer = true;
window.__fclkMapCb = () => { delete window.__fclkMapCb; resolve(); };
s.onerror = () => reject(new Error("script load failed"));
document.head.appendChild(s);
});
}
_cleanup() {
if (this._interval) clearInterval(this._interval);
if (this.marker) { this.marker.map = null; this.marker = null; }
if (this.circle) { this.circle.setMap(null); this.circle = null; }
this.map = null;
}
}
registry.category("fields").add("fclk_location_map", {
component: FusionClockLocationMap,
supportedTypes: ["char"],
});

View File

@@ -0,0 +1,150 @@
/** @odoo-module **/
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { rpc } from "@web/core/network/rpc";
/**
* Google Places Autocomplete widget for the address field.
* Automatically geocodes the selected place and updates lat/lng on the record.
*/
export class FusionClockPlacesAutocomplete extends Component {
static template = "fusion_clock.PlacesAutocomplete";
static props = { ...standardFieldProps };
setup() {
this.inputRef = useRef("addressInput");
this.autocomplete = null;
this._apiReady = false;
this.state = useState({
value: this.props.record.data[this.props.name] || "",
});
onMounted(() => this._init());
onWillUnmount(() => this._cleanup());
}
get isReadonly() {
return this.props.readonly;
}
async _getApiKey() {
try {
return await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fusion_clock.google_maps_api_key", ""],
kwargs: {},
}) || "";
} catch (e) {
return "";
}
}
async _waitForGoogleMaps() {
if (window.google && window.google.maps && window.google.maps.places) {
return true;
}
return new Promise((resolve) => {
let attempts = 0;
const check = setInterval(() => {
attempts++;
if (window.google && window.google.maps && window.google.maps.places) {
clearInterval(check);
resolve(true);
}
if (attempts > 50) {
clearInterval(check);
resolve(false);
}
}, 100);
});
}
async _loadGoogleMaps(apiKey) {
if (window.google && window.google.maps) return;
if (document.querySelector('script[src*="maps.googleapis.com"]')) {
await this._waitForGoogleMaps();
return;
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkPlacesInit`;
script.async = true;
script.defer = true;
window.__fclkPlacesInit = () => {
delete window.__fclkPlacesInit;
resolve();
};
script.onerror = () => reject(new Error("Failed to load Google Maps"));
document.head.appendChild(script);
});
}
async _init() {
if (this.isReadonly) return;
const apiKey = await this._getApiKey();
if (!apiKey) return;
try {
await this._loadGoogleMaps(apiKey);
} catch (e) {
return;
}
await this._waitForGoogleMaps();
if (!this.inputRef.el || !window.google || !window.google.maps.places) return;
this.autocomplete = new google.maps.places.Autocomplete(this.inputRef.el, {
types: ["geocode", "establishment"],
fields: ["formatted_address", "geometry", "name"],
});
this.autocomplete.addListener("place_changed", () => {
const place = this.autocomplete.getPlace();
if (!place || !place.geometry) return;
const lat = place.geometry.location.lat();
const lng = place.geometry.location.lng();
const address = place.formatted_address || place.name || "";
this.state.value = address;
this.props.record.update({
[this.props.name]: address,
latitude: Math.round(lat * 10000000) / 10000000,
longitude: Math.round(lng * 10000000) / 10000000,
});
});
}
onInput(ev) {
this.state.value = ev.target.value;
}
onChange(ev) {
this.props.record.update({ [this.props.name]: ev.target.value });
}
_cleanup() {
if (this.autocomplete) {
google.maps.event.clearInstanceListeners(this.autocomplete);
this.autocomplete = null;
}
const containers = document.querySelectorAll(".pac-container");
containers.forEach((c) => c.remove());
}
}
FusionClockPlacesAutocomplete.template = "fusion_clock.PlacesAutocomplete";
registry.category("fields").add("fclk_places_autocomplete", {
component: FusionClockPlacesAutocomplete,
supportedTypes: ["char"],
});

View File

@@ -79,6 +79,37 @@ export class FusionClockPortal extends Interaction {
});
}
const reasonSubmitBtn = document.getElementById("fclk-reason-submit");
if (reasonSubmitBtn) {
reasonSubmitBtn.addEventListener("click", () => this._submitReason());
}
const leaveBtn = document.getElementById("fclk-leave-btn");
if (leaveBtn) {
leaveBtn.addEventListener("click", () => {
const modal = document.getElementById("fclk-leave-modal");
if (modal) modal.style.display = "flex";
});
}
const leaveSubmitBtn = document.getElementById("fclk-leave-submit");
if (leaveSubmitBtn) {
leaveSubmitBtn.addEventListener("click", () => this._submitLeave());
}
const clockoutConfirmBtn = document.getElementById("fclk-clockout-confirm-btn");
if (clockoutConfirmBtn) {
clockoutConfirmBtn.addEventListener("click", () => this._confirmClockOut());
}
document.querySelectorAll("[data-dismiss]").forEach((btn) => {
btn.addEventListener("click", () => {
const targetId = btn.dataset.dismiss;
const modal = document.getElementById(targetId);
if (modal) modal.style.display = "none";
});
});
document.querySelectorAll(".fclk-modal-item").forEach((item) => {
item.addEventListener("click", () => {
this.selectedLocationId = parseInt(item.dataset.id);
@@ -100,9 +131,54 @@ export class FusionClockPortal extends Interaction {
e.preventDefault();
const btn = document.getElementById("fclk-clock-btn");
if (!btn || btn.disabled) return;
if (this.isCheckedIn) {
this._showClockOutConfirmation();
return;
}
this._beginClockAction();
}
_showClockOutConfirmation() {
const modal = document.getElementById("fclk-clockout-confirm-modal");
if (!modal) {
this._beginClockAction();
return;
}
const checkinEl = document.getElementById("fclk-confirm-checkin-time");
const durationEl = document.getElementById("fclk-confirm-duration");
if (checkinEl && this.checkInTime) {
const h = this.checkInTime.getHours();
const m = this.checkInTime.getMinutes();
const ampm = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 || 12;
checkinEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
}
if (durationEl && this.checkInTime) {
const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000));
const dh = Math.floor(diff / 3600);
const dm = Math.floor((diff % 3600) / 60);
durationEl.textContent = dh + "h " + dm + "m";
}
modal.style.display = "flex";
}
_confirmClockOut() {
const modal = document.getElementById("fclk-clockout-confirm-modal");
if (modal) modal.style.display = "none";
this._beginClockAction();
}
_beginClockAction() {
const btn = document.getElementById("fclk-clock-btn");
if (!btn || btn.disabled) return;
btn.disabled = true;
// Ripple effect
const ripple = btn.querySelector(".fclk-btn-ripple");
if (ripple) {
ripple.classList.remove("fclk-ripple-active");
@@ -150,6 +226,11 @@ export class FusionClockPortal extends Interaction {
this._hideGPSOverlay();
if (btn) btn.disabled = false;
if (result.requires_reason) {
this._showReasonModal();
return;
}
if (result.error) {
this._showToast(result.error, "error");
this._shakeButton();
@@ -413,6 +494,75 @@ export class FusionClockPortal extends Interaction {
} catch (e) {}
}
// =========================================================================
// Reason Modal & Leave Request
// =========================================================================
_showReasonModal() {
const modal = document.getElementById("fclk-reason-modal");
if (modal) modal.style.display = "flex";
}
async _submitReason() {
const reasonEl = document.getElementById("fclk-reason-text");
const timeEl = document.getElementById("fclk-reason-time");
const reason = reasonEl ? reasonEl.value.trim() : "";
const depTime = timeEl ? timeEl.value.trim() : "";
if (!reason) {
this._showToast("Please provide a reason.", "error");
return;
}
try {
const result = await rpc("/fusion_clock/submit_reason", {
reason: reason,
departure_time: depTime,
});
if (result.success) {
const modal = document.getElementById("fclk-reason-modal");
if (modal) modal.style.display = "none";
this._showToast(result.message, "success");
if (reasonEl) reasonEl.value = "";
if (timeEl) timeEl.value = "";
} else {
this._showToast(result.error || "Failed to submit.", "error");
}
} catch (e) {
this._showToast("Network error.", "error");
}
}
async _submitLeave() {
const dateEl = document.getElementById("fclk-leave-date");
const reasonEl = document.getElementById("fclk-leave-reason");
const leaveDate = dateEl ? dateEl.value : "";
const reason = reasonEl ? reasonEl.value.trim() : "";
if (!leaveDate || !reason) {
this._showToast("Please provide both a date and reason.", "error");
return;
}
try {
const result = await rpc("/fusion_clock/request_leave", {
leave_date: leaveDate,
reason: reason,
});
if (result.success) {
const modal = document.getElementById("fclk-leave-modal");
if (modal) modal.style.display = "none";
this._showToast(result.message, "success");
if (dateEl) dateEl.value = "";
if (reasonEl) reasonEl.value = "";
} else {
this._showToast(result.error || "Failed to submit.", "error");
}
} catch (e) {
this._showToast("Network error.", "error");
}
}
// =========================================================================
// Sync on visibility change
// =========================================================================

View File

@@ -223,6 +223,163 @@ export class FusionClockPortalFAB extends Interaction {
// =========================================================================
async _onClockAction() {
if (this.isCheckedIn) {
this._showClockOutConfirm();
return;
}
await this._executeClockAction();
}
_showClockOutConfirm() {
let modal = document.getElementById("fclk-pfab-clockout-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "fclk-pfab-clockout-modal";
modal.className = "fclk-wizard-overlay";
modal.innerHTML = `
<div class="fclk-wizard-backdrop" data-pfab-dismiss="fclk-pfab-clockout-modal"></div>
<div class="fclk-wizard-dialog fclk-wizard-dialog--compact">
<div class="fclk-wizard-header fclk-wizard-header--danger">
<div class="fclk-wizard-header-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
</div>
<h3 class="fclk-wizard-title">Clock Out?</h3>
<p class="fclk-wizard-subtitle">Are you sure you want to end your current shift?</p>
</div>
<div class="fclk-wizard-body">
<div class="fclk-clockout-summary">
<div class="fclk-clockout-summary-row">
<span class="fclk-clockout-summary-label">Clocked in at</span>
<span class="fclk-clockout-summary-value" id="fclk-pfab-confirm-time">--</span>
</div>
<div class="fclk-clockout-summary-row">
<span class="fclk-clockout-summary-label">Duration</span>
<span class="fclk-clockout-summary-value" id="fclk-pfab-confirm-dur">--</span>
</div>
</div>
</div>
<div class="fclk-wizard-footer">
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-pfab-dismiss="fclk-pfab-clockout-modal">Cancel</button>
<button class="fclk-wizard-btn fclk-wizard-btn--danger" id="fclk-pfab-confirm-clockout-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
Confirm Clock Out
</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => {
btn.addEventListener("click", () => { modal.style.display = "none"; });
});
document.getElementById("fclk-pfab-confirm-clockout-btn").addEventListener("click", () => {
modal.style.display = "none";
this._executeClockAction();
});
}
if (this.checkInTime) {
const h = this.checkInTime.getHours();
const m = this.checkInTime.getMinutes();
const ampm = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 || 12;
const timeEl = document.getElementById("fclk-pfab-confirm-time");
if (timeEl) timeEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000));
const dh = Math.floor(diff / 3600);
const dm = Math.floor((diff % 3600) / 60);
const durEl = document.getElementById("fclk-pfab-confirm-dur");
if (durEl) durEl.textContent = dh + "h " + dm + "m";
}
modal.style.display = "flex";
}
_showReasonDialog() {
let modal = document.getElementById("fclk-pfab-reason-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "fclk-pfab-reason-modal";
modal.className = "fclk-wizard-overlay";
modal.innerHTML = `
<div class="fclk-wizard-backdrop" data-pfab-dismiss="fclk-pfab-reason-modal"></div>
<div class="fclk-wizard-dialog">
<div class="fclk-wizard-header fclk-wizard-header--warning">
<div class="fclk-wizard-header-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h3 class="fclk-wizard-title">Missed Clock-Out</h3>
<p class="fclk-wizard-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
</div>
<div class="fclk-wizard-body">
<div class="fclk-wizard-field">
<label class="fclk-wizard-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Reason <span class="fclk-wizard-required">*</span>
</label>
<textarea id="fclk-pfab-reason-text" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
placeholder="Please explain why you didn't clock out..."></textarea>
</div>
<div class="fclk-wizard-field">
<label class="fclk-wizard-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Departure Time
</label>
<input type="datetime-local" id="fclk-pfab-reason-time" class="fclk-wizard-input"/>
<span class="fclk-wizard-hint">When did you actually leave? (optional)</span>
</div>
</div>
<div class="fclk-wizard-footer">
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-pfab-dismiss="fclk-pfab-reason-modal">Cancel</button>
<button class="fclk-wizard-btn fclk-wizard-btn--primary" id="fclk-pfab-reason-submit-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
Submit Reason
</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => {
btn.addEventListener("click", () => { modal.style.display = "none"; });
});
document.getElementById("fclk-pfab-reason-submit-btn").addEventListener("click", async () => {
const reasonEl = document.getElementById("fclk-pfab-reason-text");
const timeEl = document.getElementById("fclk-pfab-reason-time");
const reason = reasonEl ? reasonEl.value.trim() : "";
if (!reason) {
this._showError("Please provide a reason.");
return;
}
const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn");
if (submitBtn) submitBtn.disabled = true;
try {
await rpc("/fusion_clock/submit_reason", {
reason: reason,
departure_time: timeEl ? timeEl.value : "",
});
modal.style.display = "none";
if (reasonEl) reasonEl.value = "";
if (timeEl) timeEl.value = "";
if (submitBtn) submitBtn.disabled = false;
await this._executeClockAction();
} catch (e) {
this._showError("Failed to submit reason.");
if (submitBtn) submitBtn.disabled = false;
}
});
}
const reasonEl = document.getElementById("fclk-pfab-reason-text");
const timeEl = document.getElementById("fclk-pfab-reason-time");
if (reasonEl) reasonEl.value = "";
if (timeEl) timeEl.value = "";
modal.style.display = "flex";
}
async _executeClockAction() {
if (this.actionBtn) this.actionBtn.disabled = true;
this._clearError();
@@ -255,6 +412,12 @@ export class FusionClockPortalFAB extends Interaction {
source: "portal_fab",
});
if (result.requires_reason) {
if (this.actionBtn) this.actionBtn.disabled = false;
this._showReasonDialog();
return;
}
if (result.error) {
this._showError(result.error);
if (this.actionBtn) this.actionBtn.disabled = false;

View File

@@ -23,6 +23,11 @@ export class FusionClockFAB extends Component {
weekHours: "0.0",
loading: false,
error: "",
showReasonDialog: false,
showClockoutConfirm: false,
reasonText: "",
reasonTime: "",
reasonSubmitting: false,
});
this._timerInterval = null;
@@ -95,6 +100,23 @@ export class FusionClockFAB extends Component {
}
async onClockAction() {
if (this.state.isCheckedIn) {
this.state.showClockoutConfirm = true;
return;
}
await this._executeClockAction();
}
async confirmClockOut() {
this.state.showClockoutConfirm = false;
await this._executeClockAction();
}
cancelClockOut() {
this.state.showClockoutConfirm = false;
}
async _executeClockAction() {
this.state.loading = true;
this.state.error = "";
@@ -126,6 +148,14 @@ export class FusionClockFAB extends Component {
source: "backend_fab",
});
if (result.requires_reason) {
this.state.loading = false;
this.state.showReasonDialog = true;
this.state.reasonText = "";
this.state.reasonTime = "";
return;
}
if (result.error) {
this.state.error = result.error;
this.state.loading = false;
@@ -153,6 +183,60 @@ export class FusionClockFAB extends Component {
this.state.loading = false;
}
onReasonTextInput(ev) {
this.state.reasonText = ev.target.value;
}
onReasonTimeInput(ev) {
this.state.reasonTime = ev.target.value;
}
cancelReason() {
this.state.showReasonDialog = false;
this.state.reasonText = "";
this.state.reasonTime = "";
}
async submitReason() {
if (!this.state.reasonText.trim()) {
this.state.error = "Please provide a reason.";
return;
}
this.state.reasonSubmitting = true;
try {
await rpc("/fusion_clock/submit_reason", {
reason: this.state.reasonText.trim(),
departure_time: this.state.reasonTime || "",
});
this.state.showReasonDialog = false;
this.state.reasonText = "";
this.state.reasonTime = "";
this.state.reasonSubmitting = false;
await this._executeClockAction();
} catch (e) {
this.state.error = "Failed to submit reason.";
this.state.reasonSubmitting = false;
}
}
get confirmCheckinDisplay() {
if (!this.state.checkInTime) return "--";
const d = this.state.checkInTime;
let h = d.getHours();
const m = d.getMinutes();
const ampm = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return h + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
}
get confirmDurationDisplay() {
if (!this.state.checkInTime) return "--";
const diff = Math.max(0, Math.floor((new Date() - this.state.checkInTime) / 1000));
const dh = Math.floor(diff / 3600);
const dm = Math.floor((diff % 3600) / 60);
return dh + "h " + dm + "m";
}
_startTimer() {
this._stopTimer();
this._updateTimer();

View File

@@ -376,3 +376,437 @@ $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
// ===========================================================
// FAB Dialog Overlays (reason, clock-out confirmation)
// ===========================================================
.fclk-fab-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.fclk-fab-dialog-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
}
.fclk-fab-dialog {
position: relative;
width: 100%;
max-width: 420px;
background: var(--fclk-fab-panel-bg);
border: 1px solid var(--fclk-fab-panel-border);
border-radius: 20px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05);
animation: fclk-dialog-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1);
max-height: 85vh;
overflow-y: auto;
&.fclk-fab-dialog--compact {
max-width: 360px;
}
}
@keyframes fclk-dialog-enter {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.fclk-fab-dialog-header {
padding: 28px 24px 20px;
text-align: center;
border-bottom: 1px solid var(--fclk-fab-panel-border);
}
.fclk-fab-dialog-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
font-size: 22px;
}
.fclk-fab-dialog-header--warning .fclk-fab-dialog-icon {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.fclk-fab-dialog-header--danger .fclk-fab-dialog-icon {
background: rgba($fclk-red, 0.12);
color: $fclk-red;
}
.fclk-fab-dialog-title {
color: var(--fclk-fab-text);
font-size: 18px;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: -0.3px;
}
.fclk-fab-dialog-subtitle {
color: var(--fclk-fab-muted);
font-size: 12px;
line-height: 1.5;
margin: 0;
}
.fclk-fab-dialog-body {
padding: 20px 24px;
}
.fclk-fab-dialog-field {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.fclk-fab-dialog-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--fclk-fab-text);
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
.fa { color: var(--fclk-fab-muted); font-size: 13px; }
}
.fclk-fab-dialog-required {
color: $fclk-red;
font-weight: 700;
}
.fclk-fab-dialog-input {
width: 100%;
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
border: 1.5px solid var(--fclk-fab-panel-border);
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
color: var(--fclk-fab-text);
font-family: inherit;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
resize: vertical;
&:focus {
border-color: $fclk-green;
box-shadow: 0 0 0 3px rgba($fclk-green, 0.15);
}
&::placeholder {
color: var(--fclk-fab-muted);
}
}
.fclk-fab-dialog-hint {
display: block;
color: var(--fclk-fab-muted);
font-size: 10px;
margin-top: 4px;
}
.fclk-fab-dialog-footer {
padding: 14px 24px 18px;
display: flex;
gap: 10px;
justify-content: flex-end;
border-top: 1px solid var(--fclk-fab-panel-border);
}
.fclk-fab-dialog-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fa { font-size: 13px; }
}
.fclk-fab-dialog-btn--cancel {
background: transparent;
color: var(--fclk-fab-muted);
border: 1px solid var(--fclk-fab-panel-border);
&:hover:not(:disabled) {
background: var(--fclk-fab-location-bg);
color: var(--fclk-fab-text);
}
}
.fclk-fab-dialog-btn--submit {
background: $fclk-gradient-active;
color: #fff;
box-shadow: 0 2px 8px rgba($fclk-green, 0.3);
&:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba($fclk-green, 0.4);
transform: translateY(-1px);
}
}
.fclk-fab-dialog-btn--danger {
background: $fclk-red;
color: #fff;
box-shadow: 0 2px 8px rgba($fclk-red, 0.3);
&:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba($fclk-red, 0.4);
transform: translateY(-1px);
}
}
// Summary card (used in clock-out confirmation)
.fclk-fab-dialog-summary {
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
border: 1px solid var(--fclk-fab-panel-border);
border-radius: 10px;
padding: 14px;
}
.fclk-fab-dialog-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
+ .fclk-fab-dialog-summary-row {
border-top: 1px solid var(--fclk-fab-panel-border);
}
}
.fclk-fab-dialog-summary-label {
color: var(--fclk-fab-muted);
font-size: 12px;
}
.fclk-fab-dialog-summary-value {
color: var(--fclk-fab-text);
font-size: 13px;
font-weight: 600;
}
// ===========================================================
// Location Map Widget
// ===========================================================
.fclk-map-widget {
width: 100%;
margin: 8px 0;
}
.fclk-map-container {
display: block;
border: 1px solid var(--fclk-fab-panel-border, #e5e7eb);
}
.fclk-map-loading,
.fclk-map-error,
.fclk-map-placeholder {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 16px;
font-size: 13px;
border-radius: 8px;
border: 1px dashed var(--fclk-fab-panel-border, #e5e7eb);
}
.fclk-map-loading {
color: var(--fclk-fab-muted, #6b7280);
background: rgba(59, 130, 246, 0.04);
}
.fclk-map-error {
color: $fclk-red;
background: rgba($fclk-red, 0.04);
}
.fclk-map-placeholder {
color: var(--fclk-fab-muted, #6b7280);
background: rgba(0, 0, 0, 0.02);
}
html.o_dark {
.fclk-map-loading { background: rgba(59, 130, 246, 0.08); }
.fclk-map-error { background: rgba($fclk-red, 0.08); }
.fclk-map-placeholder { background: rgba(255, 255, 255, 0.03); }
}
.fclk-map-hint {
text-align: center;
padding: 6px 12px;
font-size: 11px;
color: var(--fclk-fab-muted, #6b7280);
.fa { margin-right: 4px; }
}
// Google Places dropdown z-index fix
.pac-container {
z-index: 2100 !important;
border-radius: 8px;
margin-top: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
font-family: inherit;
}
.fclk-places-input {
width: 100%;
}
// ===========================================================
// Dashboard Summary Cards
// ===========================================================
.fclk-dash-card {
position: relative;
border-radius: 12px;
padding: 20px;
text-align: center;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
}
.fclk-dash-card-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 12px;
}
.fclk-dash-card-value {
font-size: 32px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.fclk-dash-card-label {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.2px;
}
// -- Total (blue/slate) --
.fclk-dash-card--total {
background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%);
border: 1px solid #bfdbfe;
.fclk-dash-card-icon { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
.fclk-dash-card-value { color: #1e3a5f; }
.fclk-dash-card-label { color: #3b82f6; }
}
// -- Present (green) --
.fclk-dash-card--present {
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border: 1px solid #a7f3d0;
.fclk-dash-card-icon { background: rgba(16, 185, 129, 0.15); color: #059669; }
.fclk-dash-card-value { color: #064e3b; }
.fclk-dash-card-label { color: #10b981; }
}
// -- Absent (red) --
.fclk-dash-card--absent {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border: 1px solid #fecaca;
.fclk-dash-card-icon { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
.fclk-dash-card-value { color: #7f1d1d; }
.fclk-dash-card-label { color: #ef4444; }
}
// -- Late (amber) --
.fclk-dash-card--late {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid #fde68a;
.fclk-dash-card-icon { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.fclk-dash-card-value { color: #78350f; }
.fclk-dash-card-label { color: #f59e0b; }
}
// -- Dark mode overrides --
html.o_dark {
.fclk-dash-card--total {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%);
border-color: rgba(59, 130, 246, 0.25);
.fclk-dash-card-value { color: #93c5fd; }
.fclk-dash-card-label { color: #60a5fa; }
.fclk-dash-card-icon { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
}
.fclk-dash-card--present {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(52, 211, 153, 0.08) 100%);
border-color: rgba(16, 185, 129, 0.25);
.fclk-dash-card-value { color: #6ee7b7; }
.fclk-dash-card-label { color: #34d399; }
.fclk-dash-card-icon { background: rgba(16, 185, 129, 0.2); color: #34d399; }
}
.fclk-dash-card--absent {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(248, 113, 113, 0.08) 100%);
border-color: rgba(239, 68, 68, 0.25);
.fclk-dash-card-value { color: #fca5a5; }
.fclk-dash-card-label { color: #f87171; }
.fclk-dash-card-icon { background: rgba(239, 68, 68, 0.18); color: #f87171; }
}
.fclk-dash-card--late {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(251, 191, 36, 0.08) 100%);
border-color: rgba(245, 158, 11, 0.25);
.fclk-dash-card-value { color: #fcd34d; }
.fclk-dash-card-label { color: #fbbf24; }
.fclk-dash-card-icon { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
}
.fclk-dash-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
}

View File

@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.Dashboard">
<div class="o_action">
<div class="container-fluid py-3">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Fusion Clock Dashboard</h2>
<button class="btn btn-outline-primary" t-on-click="onRefresh">
<i class="fa fa-refresh"/> Refresh
</button>
</div>
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading dashboard...</p>
</div>
</t>
<t t-if="state.error">
<div class="alert alert-danger">
<t t-esc="state.error"/>
</div>
</t>
<t t-if="!state.loading and !state.error">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--total">
<div class="fclk-dash-card-icon">
<i class="fa fa-users"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.total_employees"/></div>
<div class="fclk-dash-card-label">Total Employees</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--present">
<div class="fclk-dash-card-icon">
<i class="fa fa-check-circle"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.present_count"/></div>
<div class="fclk-dash-card-label">Present Today</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--absent">
<div class="fclk-dash-card-icon">
<i class="fa fa-times-circle"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.absent_count"/></div>
<div class="fclk-dash-card-label">Absent Today</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--late">
<div class="fclk-dash-card-icon">
<i class="fa fa-clock-o"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.late_count"/></div>
<div class="fclk-dash-card-label">Late Today</div>
</div>
</div>
</div>
<div class="row">
<!-- Currently Clocked In -->
<div class="col-md-8 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Currently Clocked In</h5>
<span class="badge bg-success"><t t-esc="state.clocked_in.length"/> active</span>
</div>
<div class="card-body p-0">
<t t-if="state.clocked_in.length === 0">
<div class="text-center py-4 text-muted">
No employees currently clocked in
</div>
</t>
<t t-else="">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Employee</th>
<th>Clock-In</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.clocked_in" t-as="emp" t-key="emp_index">
<tr>
<td><t t-esc="emp.employee"/></td>
<td><t t-esc="emp.check_in"/></td>
<td><t t-esc="emp.location"/></td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
</div>
<!-- Alerts Panel -->
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alerts</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 cursor-pointer"
t-on-click="onViewActivityLogs">
<span><i class="fa fa-exclamation-circle text-warning me-2"/>Pending Reasons</span>
<span class="badge bg-warning"><t t-esc="state.pending_reasons"/></span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 cursor-pointer"
t-on-click="onViewCorrections">
<span><i class="fa fa-edit text-info me-2"/>Pending Corrections</span>
<span class="badge bg-info"><t t-esc="state.pending_corrections"/></span>
</div>
<div class="d-flex justify-content-between align-items-center cursor-pointer"
t-on-click="onViewPenalties">
<span><i class="fa fa-clock-o text-danger me-2"/>Late Today</span>
<span class="badge bg-danger"><t t-esc="state.late_count"/></span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<button class="btn btn-outline-primary w-100 mb-2" t-on-click="onViewAttendances">
<i class="fa fa-list me-1"/> View All Attendances
</button>
<button class="btn btn-outline-secondary w-100" t-on-click="onViewActivityLogs">
<i class="fa fa-history me-1"/> Activity Logs
</button>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<!-- Interactive Map Widget -->
<t t-name="fusion_clock.LocationMap">
<div class="fclk-map-widget">
<div t-if="state.loading" class="fclk-map-loading">
<i class="fa fa-circle-o-notch fa-spin"/> Loading map...
</div>
<div t-if="state.error" class="fclk-map-error">
<i class="fa fa-exclamation-triangle"/> <t t-esc="state.error"/>
</div>
<!-- ALWAYS in the DOM so the ref is available at mount time.
Hidden via inline display:none until the map is ready. -->
<div t-ref="mapContainer"
class="fclk-map-container"
t-att-style="state.mapVisible ? 'width:100%;height:400px;border-radius:8px;' : 'display:none;'"/>
<div t-if="state.mapVisible and !props.readonly" class="fclk-map-hint">
<i class="fa fa-hand-pointer-o"/> Click the map or drag the marker to fine-tune the location
</div>
</div>
</t>
<!-- Places Autocomplete Widget -->
<t t-name="fusion_clock.PlacesAutocomplete">
<t t-if="isReadonly">
<span t-esc="props.record.data[props.name] || ''"/>
</t>
<t t-else="">
<input t-ref="addressInput"
type="text"
class="o_input fclk-places-input"
t-att-value="state.value"
t-on-input="onInput"
t-on-change="onChange"
placeholder="Start typing an address..."/>
</t>
</t>
</templates>

View File

@@ -68,20 +68,91 @@
<!-- Floating Action Button -->
<button t-attf-class="fclk-fab-btn {{ state.isCheckedIn ? 'fclk-fab-btn--active' : '' }} {{ state.expanded ? 'fclk-fab-btn--open' : '' }}"
t-on-click="togglePanel">
<!-- Ripple rings (always animate) -->
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--1"/>
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--2"/>
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--3"/>
<!-- Icon -->
<span class="fclk-fab-icon">
<i t-if="!state.expanded" class="fa fa-clock-o"/>
<i t-else="" class="fa fa-times"/>
</span>
<!-- Mini timer badge -->
<span t-if="state.isCheckedIn and !state.expanded" class="fclk-fab-badge">
<t t-esc="state.timerDisplay"/>
</span>
</button>
<!-- Missed Clock-Out Reason Dialog -->
<div t-if="state.showReasonDialog" class="fclk-fab-dialog-overlay">
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelReason"/>
<div class="fclk-fab-dialog">
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--warning">
<div class="fclk-fab-dialog-icon">
<i class="fa fa-exclamation-triangle"/>
</div>
<h4 class="fclk-fab-dialog-title">Missed Clock-Out</h4>
<p class="fclk-fab-dialog-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
</div>
<div class="fclk-fab-dialog-body">
<div class="fclk-fab-dialog-field">
<label class="fclk-fab-dialog-label">
<i class="fa fa-comment-o"/> Reason <span class="fclk-fab-dialog-required">*</span>
</label>
<textarea class="fclk-fab-dialog-input" rows="3"
placeholder="Please explain why you didn't clock out..."
t-on-input="onReasonTextInput"
t-att-value="state.reasonText"/>
</div>
<div class="fclk-fab-dialog-field">
<label class="fclk-fab-dialog-label">
<i class="fa fa-clock-o"/> Departure Time
</label>
<input type="datetime-local" class="fclk-fab-dialog-input"
t-on-input="onReasonTimeInput"
t-att-value="state.reasonTime"/>
<span class="fclk-fab-dialog-hint">When did you actually leave? (optional)</span>
</div>
</div>
<div class="fclk-fab-dialog-footer">
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelReason">Cancel</button>
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--submit" t-on-click="submitReason"
t-att-disabled="state.reasonSubmitting">
<t t-if="state.reasonSubmitting"><i class="fa fa-circle-o-notch fa-spin"/> Submitting...</t>
<t t-else=""><i class="fa fa-check"/> Submit Reason</t>
</button>
</div>
</div>
</div>
<!-- Clock-Out Confirmation Dialog -->
<div t-if="state.showClockoutConfirm" class="fclk-fab-dialog-overlay">
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelClockOut"/>
<div class="fclk-fab-dialog fclk-fab-dialog--compact">
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--danger">
<div class="fclk-fab-dialog-icon">
<i class="fa fa-stop-circle"/>
</div>
<h4 class="fclk-fab-dialog-title">Clock Out?</h4>
<p class="fclk-fab-dialog-subtitle">Are you sure you want to end your current shift?</p>
</div>
<div class="fclk-fab-dialog-body">
<div class="fclk-fab-dialog-summary">
<div class="fclk-fab-dialog-summary-row">
<span class="fclk-fab-dialog-summary-label">Clocked in at</span>
<span class="fclk-fab-dialog-summary-value" t-esc="confirmCheckinDisplay"/>
</div>
<div class="fclk-fab-dialog-summary-row">
<span class="fclk-fab-dialog-summary-label">Duration</span>
<span class="fclk-fab-dialog-summary-value" t-esc="confirmDurationDisplay"/>
</div>
</div>
</div>
<div class="fclk-fab-dialog-footer">
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelClockOut">Cancel</button>
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--danger" t-on-click="confirmClockOut">
<i class="fa fa-stop-circle-o"/> Confirm Clock Out
</button>
</div>
</div>
</div>
</div>
</t>

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Activity Log List View -->
<record id="view_fusion_clock_activity_log_list" model="ir.ui.view">
<field name="name">fusion.clock.activity.log.list</field>
<field name="model">fusion.clock.activity.log</field>
<field name="arch" type="xml">
<list>
<field name="log_date"/>
<field name="employee_id"/>
<field name="log_type"/>
<field name="description"/>
<field name="location_id" optional="show"/>
<field name="source" optional="show"/>
<field name="distance" optional="hide"/>
</list>
</field>
</record>
<!-- Activity Log Form View -->
<record id="view_fusion_clock_activity_log_form" model="ir.ui.view">
<field name="name">fusion.clock.activity.log.form</field>
<field name="model">fusion.clock.activity.log</field>
<field name="arch" type="xml">
<form create="false" edit="false">
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="log_type"/>
<field name="log_date"/>
<field name="source"/>
</group>
<group>
<field name="location_id"/>
<field name="attendance_id"/>
<field name="latitude"/>
<field name="longitude"/>
<field name="distance"/>
</group>
</group>
<group>
<field name="description"/>
</group>
<!-- Clock-in attempt location map -->
<group string="Clock Attempt Location"
invisible="not latitude or not longitude">
<field name="attempt_map_url" widget="image_url" nolabel="1" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Activity Log Search View -->
<record id="view_fusion_clock_activity_log_search" model="ir.ui.view">
<field name="name">fusion.clock.activity.log.search</field>
<field name="model">fusion.clock.activity.log</field>
<field name="arch" type="xml">
<search>
<field name="employee_id"/>
<field name="log_type"/>
<separator/>
<filter name="clock_events" string="Clock Events" domain="[('log_type', 'in', ['clock_in', 'clock_out'])]"/>
<filter name="penalties" string="Penalties" domain="[('log_type', 'in', ['late_clock_in', 'early_clock_out'])]"/>
<filter name="geofence" string="Geofence" domain="[('log_type', 'in', ['outside_geofence', 'ip_fallback'])]"/>
<filter name="system" string="System" domain="[('log_type', 'in', ['auto_clock_out', 'missed_clock_out'])]"/>
<filter name="absences" string="Absences" domain="[('log_type', '=', 'absent')]"/>
<filter name="leaves" string="Leaves" domain="[('log_type', '=', 'leave_request')]"/>
<filter name="overtime" string="Overtime" domain="[('log_type', '=', 'overtime')]"/>
<separator/>
<filter name="group_employee" string="Employee" context="{'group_by': 'employee_id'}"/>
<filter name="group_type" string="Type" context="{'group_by': 'log_type'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'log_date:day'}"/>
</search>
</field>
</record>
<!-- Activity Log Action -->
<record id="action_fusion_clock_activity_log" model="ir.actions.act_window">
<field name="name">Activity Logs</field>
<field name="res_model">fusion.clock.activity.log</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_clock_activity_log_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Correction Request List View -->
<record id="view_fusion_clock_correction_list" model="ir.ui.view">
<field name="name">fusion.clock.correction.list</field>
<field name="model">fusion.clock.correction</field>
<field name="arch" type="xml">
<list>
<field name="create_date"/>
<field name="employee_id"/>
<field name="original_check_in"/>
<field name="original_check_out"/>
<field name="requested_check_in"/>
<field name="requested_check_out"/>
<field name="reason"/>
<field name="state" decoration-success="state == 'approved'"
decoration-danger="state == 'rejected'"
decoration-warning="state == 'pending'" widget="badge"/>
</list>
</field>
</record>
<!-- Correction Request Form View -->
<record id="view_fusion_clock_correction_form" model="ir.ui.view">
<field name="name">fusion.clock.correction.form</field>
<field name="model">fusion.clock.correction</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_approve" string="Approve" type="object"
class="btn-primary" invisible="state != 'pending'"
groups="fusion_clock.group_fusion_clock_manager"/>
<button name="action_reject" string="Reject" type="object"
class="btn-danger" invisible="state != 'pending'"
groups="fusion_clock.group_fusion_clock_manager"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group string="Employee">
<field name="employee_id"/>
<field name="attendance_id"/>
</group>
<group string="Review">
<field name="reviewed_by" readonly="1"/>
<field name="reviewed_date" readonly="1"/>
</group>
</group>
<group>
<group string="Original Times">
<field name="original_check_in"/>
<field name="original_check_out"/>
</group>
<group string="Requested Correction">
<field name="requested_check_in"/>
<field name="requested_check_out"/>
</group>
</group>
<group>
<field name="reason"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Correction Request Action -->
<record id="action_fusion_clock_correction" model="ir.actions.act_window">
<field name="name">Correction Requests</field>
<field name="res_model">fusion.clock.correction</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Dashboard Client Action -->
<record id="action_fusion_clock_dashboard" model="ir.actions.client">
<field name="name">Dashboard</field>
<field name="tag">fusion_clock.Dashboard</field>
</record>
</odoo>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Leave Request List View -->
<record id="view_fusion_clock_leave_request_list" model="ir.ui.view">
<field name="name">fusion.clock.leave.request.list</field>
<field name="model">fusion.clock.leave.request</field>
<field name="arch" type="xml">
<list>
<field name="leave_date"/>
<field name="employee_id"/>
<field name="reason"/>
<field name="state" decoration-success="state == 'reviewed'"
decoration-info="state == 'auto_approved'" widget="badge"/>
<field name="created_from"/>
</list>
</field>
</record>
<!-- Leave Request Form View -->
<record id="view_fusion_clock_leave_request_form" model="ir.ui.view">
<field name="name">fusion.clock.leave.request.form</field>
<field name="model">fusion.clock.leave.request</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_mark_reviewed" string="Mark Reviewed" type="object"
class="btn-primary" invisible="state == 'reviewed'"
groups="fusion_clock.group_fusion_clock_manager"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="leave_date"/>
<field name="created_from"/>
</group>
<group>
<field name="reason"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Leave Request Action -->
<record id="action_fusion_clock_leave_request" model="ir.actions.act_window">
<field name="name">Leave Requests</field>
<field name="res_model">fusion.clock.leave.request</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -29,7 +29,8 @@
</div>
<group>
<group string="Address">
<field name="address" placeholder="123 Business Ave, San Francisco, CA"/>
<field name="address" widget="fclk_places_autocomplete"
placeholder="Start typing an address..."/>
<field name="timezone"/>
</group>
<group string="Geofence">
@@ -45,6 +46,15 @@
<field name="employee_ids" widget="many2many_tags"
invisible="all_employees"/>
</group>
<group string="Verification">
<field name="require_photo"/>
</group>
</group>
<group>
<group string="IP Whitelist">
<field name="ip_whitelist" nolabel="1" colspan="2"
placeholder="One IP or CIDR per line, e.g.&#10;192.168.1.0/24&#10;10.0.0.100"/>
</group>
<group string="Other">
<field name="company_id" groups="base.group_multi_company"/>
<field name="active" invisible="1"/>
@@ -52,9 +62,9 @@
</group>
</group>
<!-- Google Maps Static Preview -->
<group string="Map Preview" invisible="not latitude or not longitude">
<field name="map_url" widget="image_url" string="Map" invisible="not map_url"/>
<!-- Interactive Map Preview -->
<group string="Map Preview">
<field name="map_url" widget="fclk_location_map" nolabel="1" colspan="2"/>
</group>
<group string="Notes">

View File

@@ -8,7 +8,15 @@
sequence="45"
groups="group_fusion_clock_user"/>
<!-- Dashboard / Attendance Sub-Menu -->
<!-- Dashboard -->
<menuitem id="menu_fusion_clock_dashboard"
name="Dashboard"
parent="menu_fusion_clock_root"
action="action_fusion_clock_dashboard"
sequence="5"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Attendance Sub-Menu -->
<menuitem id="menu_fusion_clock_attendance"
name="Attendance"
parent="menu_fusion_clock_root"
@@ -19,8 +27,23 @@
parent="menu_fusion_clock_attendance"
action="hr_attendance.hr_attendance_action"
sequence="10"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<menuitem id="menu_fusion_clock_corrections"
name="Correction Requests"
parent="menu_fusion_clock_attendance"
action="action_fusion_clock_correction"
sequence="20"
groups="group_fusion_clock_manager"/>
<!-- Activity Logs -->
<menuitem id="menu_fusion_clock_activity_logs"
name="Activity Logs"
parent="menu_fusion_clock_root"
action="action_fusion_clock_activity_log"
sequence="15"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Locations Sub-Menu -->
<menuitem id="menu_fusion_clock_locations"
name="Locations"
@@ -35,6 +58,14 @@
parent="menu_fusion_clock_root"
action="action_fusion_clock_penalty"
sequence="30"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Leave Requests -->
<menuitem id="menu_fusion_clock_leaves"
name="Leave Requests"
parent="menu_fusion_clock_root"
action="action_fusion_clock_leave_request"
sequence="35"
groups="group_fusion_clock_manager"/>
<!-- Reports Sub-Menu -->
@@ -52,13 +83,28 @@
sequence="90"
groups="group_fusion_clock_manager"/>
<record id="action_fusion_clock_config_settings" model="ir.actions.act_window">
<field name="name">Fusion Clock Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">current</field>
<field name="context">{'module': 'fusion_clock', 'bin_size': False}</field>
</record>
<menuitem id="menu_fusion_clock_settings"
name="Settings"
parent="menu_fusion_clock_config"
action="base.res_config_setting_act_window"
action="action_fusion_clock_config_settings"
sequence="10"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_shifts"
name="Shifts"
parent="menu_fusion_clock_config"
action="action_fusion_clock_shift"
sequence="15"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_locations_config"
name="Locations"
parent="menu_fusion_clock_config"

View File

@@ -13,6 +13,7 @@
<field name="scheduled_time" widget="datetime"/>
<field name="actual_time" widget="datetime"/>
<field name="difference_minutes" string="Diff (min)"/>
<field name="penalty_minutes" string="Deducted (min)"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
@@ -36,6 +37,7 @@
<field name="scheduled_time"/>
<field name="actual_time"/>
<field name="difference_minutes"/>
<field name="penalty_minutes"/>
</group>
</group>
<group>

View File

@@ -40,6 +40,10 @@
string="Send Report" class="btn-secondary"
invisible="state != 'generated'"
icon="fa-envelope"/>
<button name="action_export_csv" type="object"
string="Export CSV" class="btn-secondary"
invisible="state == 'draft'"
icon="fa-download"/>
<field name="state" widget="statusbar" statusbar_visible="draft,generated,sent"/>
</header>
<sheet>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Shift List View -->
<record id="view_fusion_clock_shift_list" model="ir.ui.view">
<field name="name">fusion.clock.shift.list</field>
<field name="model">fusion.clock.shift</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="employee_count"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<!-- Shift Form View -->
<record id="view_fusion_clock_shift_form" model="ir.ui.view">
<field name="name">fusion.clock.shift.form</field>
<field name="model">fusion.clock.shift</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
</group>
<group>
<field name="sequence"/>
<field name="active"/>
<field name="color" widget="color"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Assigned Employees">
<field name="employee_ids" nolabel="1" colspan="2">
<list>
<field name="name"/>
<field name="department_id"/>
<field name="job_title"/>
</list>
</field>
</group>
</sheet>
</form>
</field>
</record>
<!-- Shift Action -->
<record id="action_fusion_clock_shift" model="ir.actions.act_window">
<field name="name">Shifts</field>
<field name="res_model">fusion.clock.shift</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -10,9 +10,12 @@
<xpath expr="//field[@name='worked_hours']" position="after">
<field name="x_fclk_net_hours" string="Net Hours" widget="float_time" optional="show"/>
<field name="x_fclk_break_minutes" string="Break (min)" optional="show"/>
<field name="x_fclk_overtime_hours" string="Overtime (h)" widget="float_time" optional="show"
decoration-danger="x_fclk_is_overtime"/>
<field name="x_fclk_location_id" string="Location" optional="show"/>
<field name="x_fclk_clock_source" string="Source" optional="hide"/>
<field name="x_fclk_auto_clocked_out" string="Auto Out" optional="hide"/>
<field name="x_fclk_is_overtime" column_invisible="True"/>
</xpath>
</field>
</record>
@@ -30,14 +33,20 @@
<field name="x_fclk_clock_source"/>
<field name="x_fclk_break_minutes"/>
<field name="x_fclk_net_hours" widget="float_time"/>
<field name="x_fclk_overtime_hours" widget="float_time"/>
</group>
<group>
<field name="x_fclk_in_distance"/>
<field name="x_fclk_out_distance"/>
<field name="x_fclk_auto_clocked_out"/>
<field name="x_fclk_grace_used"/>
<field name="x_fclk_is_overtime"/>
</group>
</group>
<group string="Check-In Photo" name="fusion_clock_photo"
invisible="not x_fclk_checkin_photo">
<field name="x_fclk_checkin_photo" widget="image" class="oe_avatar"/>
</group>
<group string="Penalties" name="fusion_clock_penalties"
invisible="not x_fclk_penalty_ids">
<field name="x_fclk_penalty_ids" nolabel="1" colspan="2">
@@ -46,6 +55,7 @@
<field name="scheduled_time"/>
<field name="actual_time"/>
<field name="difference_minutes"/>
<field name="penalty_minutes"/>
</list>
</field>
</group>
@@ -62,11 +72,13 @@
<xpath expr="//search" position="inside">
<field name="x_fclk_location_id"/>
<separator/>
<filter name="fclk_portal" string="Portal" domain="[('x_fclk_clock_source', '=', 'portal')]"/>
<filter name="fclk_systray" string="Systray" domain="[('x_fclk_clock_source', '=', 'systray')]"/>
<filter name="fclk_portal" string="Portal" domain="[('x_fclk_clock_source', 'in', ['portal', 'portal_fab'])]"/>
<filter name="fclk_systray" string="Systray/Backend" domain="[('x_fclk_clock_source', 'in', ['systray', 'backend_fab'])]"/>
<filter name="fclk_kiosk" string="Kiosk" domain="[('x_fclk_clock_source', '=', 'kiosk')]"/>
<filter name="fclk_auto" string="Auto Clock-Out" domain="[('x_fclk_auto_clocked_out', '=', True)]"/>
<separator/>
<filter name="fclk_has_penalty" string="Has Penalty" domain="[('x_fclk_penalty_ids', '!=', False)]"/>
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
<separator/>
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
<filter name="group_source" string="Source" context="{'group_by': 'x_fclk_clock_source'}"/>

View File

@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Employee Form: Fusion Clock Tab -->
<record id="view_employee_form_fusion_clock" model="ir.ui.view">
<field name="name">hr.employee.form.fusion.clock</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Fusion Clock" name="fusion_clock_tab"
groups="fusion_clock.group_fusion_clock_manager,fusion_clock.group_fusion_clock_team_lead">
<!-- Summary Stats -->
<group>
<group string="Configuration">
<field name="x_fclk_enable_clock"/>
<field name="x_fclk_shift_id"/>
<field name="x_fclk_default_location_id"/>
<field name="x_fclk_break_minutes"/>
<field name="x_fclk_kiosk_pin" password="True"
groups="fusion_clock.group_fusion_clock_manager"/>
</group>
<group string="Status">
<field name="x_fclk_ontime_streak"/>
<field name="x_fclk_absences_this_month"/>
<field name="x_fclk_absences_this_year"/>
<field name="x_fclk_overtime_this_week" widget="float_time"/>
<field name="x_fclk_overtime_this_month" widget="float_time"/>
<field name="x_fclk_pending_reason"/>
</group>
</group>
<separator string="Activity Logs"/>
<!-- Clock Events -->
<group string="Clock Events" name="fclk_clock_events">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', 'in', ['clock_in', 'clock_out'])]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="log_type"/>
<field name="description"/>
<field name="location_id"/>
<field name="source"/>
</list>
</field>
</group>
<!-- Penalties -->
<group string="Penalties" name="fclk_penalties">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', 'in', ['late_clock_in', 'early_clock_out'])]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="log_type"/>
<field name="description"/>
</list>
</field>
</group>
<!-- Geofence Violations -->
<group string="Geofence Violations" name="fclk_geofence">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', 'in', ['outside_geofence', 'ip_fallback'])]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="log_type"/>
<field name="description"/>
<field name="latitude"/>
<field name="longitude"/>
<field name="distance"/>
</list>
</field>
</group>
<!-- System Actions -->
<group string="System Actions" name="fclk_system_actions">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', 'in', ['auto_clock_out', 'missed_clock_out'])]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="log_type"/>
<field name="description"/>
<field name="attendance_id"/>
</list>
</field>
</group>
<!-- Absences -->
<group string="Absences" name="fclk_absences">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', '=', 'absent')]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="description"/>
</list>
</field>
</group>
<!-- Leave Requests -->
<group string="Leave Requests" name="fclk_leaves">
<field name="x_fclk_leave_request_ids" nolabel="1" colspan="2">
<list create="false" delete="false" limit="20">
<field name="leave_date"/>
<field name="reason"/>
<field name="state"/>
<field name="created_from"/>
</list>
</field>
</group>
<!-- Reason Submissions -->
<group string="Reason Submissions" name="fclk_reasons">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', '=', 'reason_provided')]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="description"/>
</list>
</field>
</group>
<!-- Overtime -->
<group string="Overtime" name="fclk_overtime">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', '=', 'overtime')]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="description"/>
<field name="attendance_id"/>
</list>
</field>
</group>
<!-- Correction Requests -->
<group string="Correction Requests" name="fclk_corrections">
<field name="x_fclk_correction_ids" nolabel="1" colspan="2">
<list create="false" delete="false" limit="20">
<field name="attendance_id"/>
<field name="requested_check_in"/>
<field name="requested_check_out"/>
<field name="reason"/>
<field name="state" decoration-success="state == 'approved'"
decoration-danger="state == 'rejected'"
decoration-warning="state == 'pending'"/>
</list>
</field>
</group>
<!-- Streak Milestones -->
<group string="Streak Milestones" name="fclk_streaks">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', '=', 'streak_milestone')]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="description"/>
</list>
</field>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Kiosk Page Template -->
<template id="kiosk_page" name="Fusion Clock Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="fclk-kiosk" class="container-fluid vh-100 d-flex flex-column align-items-center justify-content-center"
style="background: var(--o-main-bg-color, #f8f9fa);"
t-att-data-pin-required="'true' if pin_required else 'false'">
<!-- Header -->
<div class="text-center mb-4">
<h1 style="font-size: 2.5rem;">Fusion Clock</h1>
<p class="text-muted" style="font-size: 1.2rem;">Kiosk Mode</p>
<div id="fclk-kiosk-time" style="font-size: 3rem; font-weight: 300;"></div>
</div>
<!-- Search / PIN Entry -->
<div class="card shadow-sm" style="width: 100%; max-width: 500px;">
<div class="card-body p-4">
<!-- Step 1: Search Employee -->
<div id="fclk-kiosk-search">
<label class="form-label fw-bold">Employee Name</label>
<input type="text" id="fclk-kiosk-query" class="form-control form-control-lg mb-3"
placeholder="Type your name..." autocomplete="off"/>
<div id="fclk-kiosk-results" class="list-group mb-3" style="max-height: 300px; overflow-y: auto;"></div>
</div>
<!-- Step 2: PIN (if required) -->
<div id="fclk-kiosk-pin" style="display: none;">
<h4 id="fclk-kiosk-emp-name" class="text-center mb-3"></h4>
<t t-if="pin_required">
<label class="form-label fw-bold">Enter PIN</label>
<input type="password" id="fclk-kiosk-pin-input" class="form-control form-control-lg text-center mb-3"
maxlength="6" placeholder="------"/>
</t>
<div class="d-grid gap-2">
<button id="fclk-kiosk-clock-btn" class="btn btn-lg btn-primary">
Clock In / Out
</button>
<button id="fclk-kiosk-back-btn" class="btn btn-outline-secondary">
Back
</button>
</div>
</div>
<!-- Step 3: Result -->
<div id="fclk-kiosk-result" style="display: none;">
<div id="fclk-kiosk-result-msg" class="text-center py-4" style="font-size: 1.3rem;"></div>
</div>
<!-- Error message -->
<div id="fclk-kiosk-error" class="alert alert-danger mt-3" style="display: none;"></div>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Clock link to portal home -->
<!-- Clock link removed from portal home - now handled by fusion_authorizer_portal -->
<template id="portal_my_home_clock" name="Portal My Home: Clock"
inherit_id="portal.portal_my_home" priority="60">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="icon" t-value="'/fusion_clock/static/description/icon.png'"/>
<t t-set="title">Clock In / Out</t>
<t t-set="url" t-value="'/my/clock'"/>
<t t-set="text">Punch in, view timesheets, and download attendance reports</t>
<t t-set="placeholder_count" t-value="'clock_count'"/>
</t>
</xpath>
</template>
@@ -22,8 +15,8 @@
<template id="portal_layout_clock_fab" name="Portal Clock FAB"
inherit_id="portal.portal_layout">
<xpath expr="//div[@id='wrap']" position="after">
<t t-set="__fclk_emp" t-value="fclk_employee if fclk_employee is defined else False"/>
<t t-if="__fclk_emp and __fclk_emp.x_fclk_enable_clock">
<t t-set="fclk_emp" t-value="fclk_employee if fclk_employee is defined else False"/>
<t t-if="fclk_emp and fclk_emp.x_fclk_enable_clock">
<div id="fclk-portal-fab"
t-att-data-checked-in="'true' if fclk_checked_in else 'false'"
t-att-data-check-in-time="fclk_check_in_time or ''"
@@ -208,6 +201,20 @@
</div>
</div>
<!-- Request Leave -->
<button id="fclk-leave-btn" class="fclk-leave-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
Request Leave
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="fclk-leave-btn-arrow">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<!-- Recent Activity -->
<div class="fclk-recent-section">
<div class="fclk-recent-header">
@@ -278,6 +285,127 @@
</div>
<!-- Reason Modal (missed clock-out) -->
<div class="fclk-wizard-overlay" id="fclk-reason-modal" style="display:none;">
<div class="fclk-wizard-backdrop" data-dismiss="fclk-reason-modal"></div>
<div class="fclk-wizard-dialog">
<div class="fclk-wizard-header fclk-wizard-header--warning">
<div class="fclk-wizard-header-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h3 class="fclk-wizard-title">Missed Clock-Out</h3>
<p class="fclk-wizard-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
</div>
<div class="fclk-wizard-body">
<div class="fclk-wizard-field">
<label class="fclk-wizard-label" for="fclk-reason-text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Reason <span class="fclk-wizard-required">*</span>
</label>
<textarea id="fclk-reason-text" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
placeholder="Please explain why you didn't clock out..."></textarea>
</div>
<div class="fclk-wizard-field">
<label class="fclk-wizard-label" for="fclk-reason-time">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Departure Time
</label>
<input type="datetime-local" id="fclk-reason-time" class="fclk-wizard-input"/>
<span class="fclk-wizard-hint">When did you actually leave? (optional)</span>
</div>
</div>
<div class="fclk-wizard-footer">
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-dismiss="fclk-reason-modal">Cancel</button>
<button id="fclk-reason-submit" class="fclk-wizard-btn fclk-wizard-btn--primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
Submit Reason
</button>
</div>
</div>
</div>
<!-- Clock-Out Confirmation Modal -->
<div class="fclk-wizard-overlay" id="fclk-clockout-confirm-modal" style="display:none;">
<div class="fclk-wizard-backdrop" data-dismiss="fclk-clockout-confirm-modal"></div>
<div class="fclk-wizard-dialog fclk-wizard-dialog--compact">
<div class="fclk-wizard-header fclk-wizard-header--danger">
<div class="fclk-wizard-header-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</div>
<h3 class="fclk-wizard-title">Clock Out?</h3>
<p class="fclk-wizard-subtitle">Are you sure you want to end your current shift?</p>
</div>
<div class="fclk-wizard-body">
<div class="fclk-clockout-summary">
<div class="fclk-clockout-summary-row">
<span class="fclk-clockout-summary-label">Clocked in at</span>
<span class="fclk-clockout-summary-value" id="fclk-confirm-checkin-time">--</span>
</div>
<div class="fclk-clockout-summary-row">
<span class="fclk-clockout-summary-label">Duration</span>
<span class="fclk-clockout-summary-value" id="fclk-confirm-duration">--</span>
</div>
</div>
</div>
<div class="fclk-wizard-footer">
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-dismiss="fclk-clockout-confirm-modal">Cancel</button>
<button id="fclk-clockout-confirm-btn" class="fclk-wizard-btn fclk-wizard-btn--danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
Confirm Clock Out
</button>
</div>
</div>
</div>
<!-- Leave Request Modal -->
<div class="fclk-wizard-overlay" id="fclk-leave-modal" style="display:none;">
<div class="fclk-wizard-backdrop" data-dismiss="fclk-leave-modal"></div>
<div class="fclk-wizard-dialog">
<div class="fclk-wizard-header fclk-wizard-header--info">
<div class="fclk-wizard-header-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<h3 class="fclk-wizard-title">Request Leave</h3>
<p class="fclk-wizard-subtitle">Submit a leave request for your manager to review.</p>
</div>
<div class="fclk-wizard-body">
<div class="fclk-wizard-field">
<label class="fclk-wizard-label" for="fclk-leave-date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Leave Date <span class="fclk-wizard-required">*</span>
</label>
<input type="date" id="fclk-leave-date" class="fclk-wizard-input"/>
</div>
<div class="fclk-wizard-field">
<label class="fclk-wizard-label" for="fclk-leave-reason">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Reason <span class="fclk-wizard-required">*</span>
</label>
<textarea id="fclk-leave-reason" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
placeholder="Please provide a reason for your leave request..."></textarea>
</div>
</div>
<div class="fclk-wizard-footer">
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-dismiss="fclk-leave-modal">Cancel</button>
<button id="fclk-leave-submit" class="fclk-wizard-btn fclk-wizard-btn--primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
Submit Leave Request
</button>
</div>
</div>
</div>
<!-- Location Picker Modal -->
<div class="fclk-modal" id="fclk-location-modal" style="display:none;">
<div class="fclk-modal-backdrop" onclick="document.getElementById('fclk-location-modal').style.display='none'"></div>

View File

@@ -62,6 +62,7 @@
<th>Break</th>
<th>Net</th>
<th>Location</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -92,6 +93,15 @@
<td style="color:#9ca3af; font-size:12px;">
<t t-esc="att.x_fclk_location_id.name or ''"/>
</td>
<td>
<a href="#" class="fclk-correction-link"
t-att-data-att-id="att.id"
t-att-data-check-in="att.check_in.strftime('%Y-%m-%d %H:%M:%S') if att.check_in else ''"
t-att-data-check-out="att.check_out.strftime('%Y-%m-%d %H:%M:%S') if att.check_out else ''"
style="font-size:11px; color:#6b7280;">
Correct
</a>
</td>
</tr>
</t>
</tbody>

View File

@@ -12,7 +12,7 @@
<!-- Work Schedule -->
<block title="Work Schedule" name="fclk_work_schedule">
<setting string="Default Clock-In Time" help="The scheduled start time for employees.">
<setting string="Default Clock-In Time" help="The scheduled start time for employees (used when no shift is assigned).">
<div class="content-group">
<div class="row mt16">
<label for="fclk_default_clock_in_time" class="col-lg-3"/>
@@ -70,7 +70,7 @@
<!-- Penalties -->
<block title="Penalty Tracking" name="fclk_penalties">
<setting string="Enable Penalties" help="Track late clock-in and early clock-out.">
<setting string="Enable Penalties" help="Track late clock-in and early clock-out with automatic deductions.">
<field name="fclk_enable_penalties"/>
<div class="content-group" invisible="not fclk_enable_penalties">
<div class="row mt16">
@@ -78,10 +78,104 @@
<field name="fclk_penalty_grace_minutes"/>
<span class="ms-1">minutes grace before penalty</span>
</div>
<div class="row mt8">
<label for="fclk_penalty_deduction_minutes" class="col-lg-3"/>
<field name="fclk_penalty_deduction_minutes"/>
<span class="ms-1">minutes deducted per penalty</span>
</div>
</div>
</setting>
</block>
<!-- Office User & Notifications -->
<block title="Office User &amp; Notifications" name="fclk_notifications">
<setting string="Office User" help="User who receives all attendance-related activity notifications.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_office_user_id" class="col-lg-3"/>
<field name="fclk_office_user_id"/>
</div>
<div class="row mt8">
<label for="fclk_very_late_threshold_minutes" class="col-lg-3"/>
<field name="fclk_very_late_threshold_minutes"/>
<span class="ms-1">minutes late before office user is notified</span>
</div>
<div class="row mt8">
<label for="fclk_max_monthly_absences" class="col-lg-3"/>
<field name="fclk_max_monthly_absences"/>
<span class="ms-1">absences before office user is alerted</span>
</div>
</div>
</setting>
<setting string="Employee Notifications" help="Send clock-in/out reminders to employees.">
<field name="fclk_enable_employee_notifications"/>
<div class="content-group" invisible="not fclk_enable_employee_notifications">
<div class="row mt16">
<label for="fclk_reminder_before_shift_minutes" class="col-lg-3"/>
<field name="fclk_reminder_before_shift_minutes"/>
<span class="ms-1">minutes after shift start to remind</span>
</div>
<div class="row mt8">
<label for="fclk_reminder_before_end_minutes" class="col-lg-3"/>
<field name="fclk_reminder_before_end_minutes"/>
<span class="ms-1">minutes before shift end to remind</span>
</div>
</div>
</setting>
<setting string="Weekly Summary" help="Send weekly attendance summary to employees on Monday.">
<field name="fclk_send_weekly_summary"/>
</setting>
</block>
<!-- Overtime -->
<block title="Overtime" name="fclk_overtime">
<setting string="Enable Overtime Tracking" help="Track hours beyond scheduled shift.">
<field name="fclk_enable_overtime"/>
<div class="content-group" invisible="not fclk_enable_overtime">
<div class="row mt16">
<label for="fclk_daily_overtime_threshold" class="col-lg-3"/>
<field name="fclk_daily_overtime_threshold" widget="float_time"/>
<span class="ms-1">daily net hours threshold</span>
</div>
<div class="row mt8">
<label for="fclk_weekly_overtime_threshold" class="col-lg-3"/>
<field name="fclk_weekly_overtime_threshold" widget="float_time"/>
<span class="ms-1">weekly net hours threshold</span>
</div>
</div>
</setting>
</block>
<!-- Location & Verification -->
<block title="Location &amp; Verification" name="fclk_location_verification">
<setting string="IP Fallback" help="Allow IP-based verification when GPS is unavailable.">
<field name="fclk_enable_ip_fallback"/>
</setting>
<setting string="Photo Verification" help="Require selfie on clock-in (controlled per location).">
<field name="fclk_enable_photo_verification"/>
</setting>
</block>
<!-- Kiosk -->
<block title="Kiosk Mode" name="fclk_kiosk">
<setting string="Enable Kiosk" help="Allow shared-device clock-in/out.">
<field name="fclk_enable_kiosk"/>
<div class="content-group" invisible="not fclk_enable_kiosk">
<div class="row mt16">
<field name="fclk_kiosk_pin_required"/>
<label for="fclk_kiosk_pin_required" class="ms-1"/>
</div>
</div>
</setting>
</block>
<!-- Corrections -->
<block title="Corrections" name="fclk_corrections">
<setting string="Enable Correction Requests" help="Allow employees to request timesheet corrections.">
<field name="fclk_enable_correction_requests"/>
</setting>
</block>
<!-- Pay Period -->
<block title="Pay Period" name="fclk_pay_period">
<setting string="Pay Period Schedule" help="Defines how often reports are generated.">
@@ -110,7 +204,15 @@
<div class="content-group">
<div class="row mt16">
<label for="fclk_report_recipient_emails" class="col-lg-3"/>
<field name="fclk_report_recipient_emails" class="o_input" placeholder="manager@company.com, hr@company.com"/>
<field name="fclk_report_recipient_emails" class="o_input" placeholder="manager@company.com"/>
</div>
</div>
</setting>
<setting string="CSV Column Mapping" help="Custom column names for CSV export (JSON format).">
<div class="content-group">
<div class="row mt16">
<label for="fclk_csv_column_mapping" class="col-lg-3"/>
<field name="fclk_csv_column_mapping" class="o_input"/>
</div>
</div>
</setting>
@@ -118,7 +220,7 @@
<!-- Clock Locations -->
<block title="Clock Locations" name="fclk_locations">
<setting string="Manage Locations" help="Configure geofenced clock-in/out locations with GPS coordinates and radius.">
<setting string="Manage Locations" help="Configure geofenced clock-in/out locations.">
<div class="content-group">
<div class="mt16">
<button name="%(fusion_clock.action_fusion_clock_location)d" type="action"

View File

@@ -1408,10 +1408,10 @@ class SaleOrder(models.Model):
pdf_content, _ = inv_report._render_qweb_pdf(
inv_report.report_name, [invoice.id]
)
inv_filename = (
f"{self.name} - {type_label} - "
f"{invoice.name or 'Draft'}.pdf"
)
name_parts = (self.partner_id.name or 'Client').strip().split()
first = name_parts[0] if name_parts else 'Client'
last = name_parts[-1] if len(name_parts) > 1 else ''
inv_filename = f"{first}_{last}_{type_label}_{invoice.name or 'Draft'}.pdf"
inv_attach = self.env['ir.attachment'].create({
'name': inv_filename,
'type': 'binary',

View File

@@ -54,10 +54,17 @@ class SaleOrderLine(models.Model):
def unlink(self):
deposit_lines = self.env['sale.order.line']
for line in self:
if not line.exists():
continue
if line.is_rental and not line.is_security_deposit:
deposit_lines |= line.order_id.order_line.filtered(
lambda l: l.rental_deposit_source_line_id == line
deps = line.order_id.order_line.filtered(
lambda l: (
l.exists()
and l.rental_deposit_source_line_id == line
)
)
deposit_lines |= deps
deposit_lines = deposit_lines.exists() - self
if deposit_lines:
deposit_lines.unlink()
return super().unlink()

View File

@@ -143,7 +143,7 @@
<field name="rental_auto_renew"/>
<field name="rental_auto_renew_off_reason"
invisible="rental_auto_renew"
required="not rental_auto_renew"
required="is_rental_order and not rental_auto_renew"
placeholder="Reason for disabling auto-renewal..."/>
<field name="rental_max_renewals"
invisible="not rental_auto_renew"/>