This commit is contained in:
gsinghpal
2026-03-16 08:14:56 -04:00
parent fdca9518ab
commit e56974d46f
196 changed files with 19739 additions and 3471 deletions

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
from . import controllers

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Clock AI',
'version': '19.0.1.0.0',
'category': 'Human Resources/Attendances',
'summary': 'AI-Powered Intelligence for Fusion Clock - GPT Reports, Anomaly Detection, Natural Language Queries & Employee Assistant',
'description': """
Fusion Clock AI - Intelligent Attendance Analytics
====================================================
AI enhancement module for Fusion Clock. Requires OpenAI API key.
* **Natural Language Dashboard** - Ask questions about attendance in plain English
* **AI Narrative Reports** - Human-readable weekly/monthly summaries
* **Payroll Anomaly Detection** - GPT flags suspicious patterns before payroll
* **Employee AI Assistant** - Portal chatbot for hours, leave requests, schedule queries
* **Attendance Coach** - Personalized weekly tips per employee
* **Correction Review Advisor** - AI context for approval/rejection decisions
* **Predictive Understaffing** - Forecast absence likelihood by day
* **Shift Optimization** - Data-driven shift reassignment suggestions
* **Compliance Checks** - Labor law violation alerts
* **Smart Configuration** - Describe policies in English, AI maps to settings
* **Geofence Tuning** - AI suggests radius adjustments from clock-in data
* **Intelligent Incident Logs** - Auto-generated explanations for every activity log
Requires: fusion_clock module and an OpenAI API key.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.io',
'license': 'OPL-1',
'depends': [
'fusion_clock',
'mail',
],
'external_dependencies': {
'python': ['openai'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'data/ai_prompt_data.xml',
'views/res_config_settings_views.xml',
'views/ai_conversation_views.xml',
'views/ai_usage_views.xml',
'views/ai_prompt_views.xml',
'views/hr_employee_views.xml',
'views/clock_report_views.xml',
'views/clock_correction_views.xml',
'views/ai_menus.xml',
'views/portal_ai_templates.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_clock_ai/static/src/css/portal_ai.css',
'fusion_clock_ai/static/src/js/ai_chat_portal.js',
],
'web.assets_backend': [
'fusion_clock_ai/static/src/scss/fusion_clock_ai.scss',
'fusion_clock_ai/static/src/js/ai_chat_backend.js',
'fusion_clock_ai/static/src/xml/ai_chat_backend.xml',
],
},
'installable': True,
'auto_install': False,
'application': False,
}

Binary file not shown.

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import ai_api
from . import portal_ai

View File

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import hashlib
import logging
from datetime import datetime, timedelta, date
from odoo import http, fields as odoo_fields, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockAIAPI(http.Controller):
def _get_employee(self):
return request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.uid),
], limit=1)
@http.route('/fusion_clock_ai/manager_chat', type='json', auth='user')
def manager_chat(self, message, conversation_id=None, date_from=None, date_to=None):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied. Manager role required.'}
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_manager_chat', 'True') != 'True':
return {'error': 'Manager AI Chat is disabled in settings.'}
AI = request.env['fusion.clock.ai.service'].sudo()
if not date_from:
date_from = (date.today() - timedelta(days=7)).isoformat()
if not date_to:
date_to = date.today().isoformat()
d_from = date.fromisoformat(date_from)
d_to = date.fromisoformat(date_to)
employees = request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', '=', request.env.company.id),
])
team_context = AI._build_team_context(employees, d_from, d_to)
Conversation = request.env['fusion.clock.ai.conversation'].sudo()
if conversation_id:
conv = Conversation.browse(int(conversation_id))
if not conv.exists() or conv.user_id.id != user.id:
conv = Conversation.create({
'user_id': user.id,
'conversation_type': 'manager_query',
})
else:
conv = Conversation.create({
'user_id': user.id,
'conversation_type': 'manager_query',
})
Message = request.env['fusion.clock.ai.message'].sudo()
Message.create({
'conversation_id': conv.id,
'role': 'user',
'content': message,
})
system_prompt = AI._get_system_prompt('manager_query')
messages = [
{'role': 'system', 'content': f"{system_prompt}\n\n--- ATTENDANCE DATA ---\n{team_context}"},
]
for msg in conv.message_ids:
messages.append({'role': msg.role, 'content': msg.content})
try:
response = AI.chat_completion(messages, feature='manager_query')
except Exception as e:
return {'error': str(e)}
Message.create({
'conversation_id': conv.id,
'role': 'assistant',
'content': response,
})
return {
'response': response,
'conversation_id': conv.id,
}
@http.route('/fusion_clock_ai/run_analysis', type='json', auth='user')
def run_analysis(self, analysis_type, date_from=None, date_to=None, employee_id=None):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
AI = request.env['fusion.clock.ai.service'].sudo()
if not date_from:
date_from = (date.today() - timedelta(days=7)).isoformat()
if not date_to:
date_to = date.today().isoformat()
d_from = date.fromisoformat(date_from)
d_to = date.fromisoformat(date_to)
prompt_map = {
'anomaly': 'anomaly_detection',
'understaffing': 'understaffing_prediction',
'shift': 'shift_optimization',
'compliance': 'compliance_check',
'geofence': 'geofence_tuning',
}
prompt_key = prompt_map.get(analysis_type)
if not prompt_key:
return {'error': f'Unknown analysis type: {analysis_type}'}
employees = request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', '=', request.env.company.id),
])
context_data = AI._build_team_context(employees, d_from, d_to)
if analysis_type == 'geofence':
context_data += "\n\n" + self._build_geofence_context(d_from, d_to)
system_prompt = AI._get_system_prompt(prompt_key)
cache_key = AI._get_cache_key(prompt_key, hashlib.sha256(
f"{d_from}:{d_to}:{request.env.company.id}".encode()
).hexdigest())
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature=analysis_type,
cache_key=cache_key,
)
except Exception as e:
return {'error': str(e)}
return {'response': response}
def _build_geofence_context(self, date_from, date_to):
Log = request.env['fusion.clock.activity.log'].sudo()
locations = request.env['fusion.clock.location'].sudo().search([
('active', '=', True),
('company_id', '=', request.env.company.id),
])
lines = ["Geofence Data:"]
for loc in locations:
clock_ins = Log.search([
('location_id', '=', loc.id),
('log_type', '=', 'clock_in'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
outside = Log.search([
('location_id', '=', loc.id),
('log_type', '=', 'outside_geofence'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
distances = [l.distance for l in clock_ins if l.distance]
avg_dist = sum(distances) / len(distances) if distances else 0
outside_distances = [l.distance for l in outside if l.distance]
avg_overshoot = sum(outside_distances) / len(outside_distances) if outside_distances else 0
lines.append(
f"\n{loc.name} (radius: {loc.radius}m):\n"
f" Successful clock-ins: {len(clock_ins)}, avg distance: {avg_dist:.0f}m\n"
f" Blocked (outside): {len(outside)}, avg overshoot: {avg_overshoot:.0f}m"
)
return "\n".join(lines)
@http.route('/fusion_clock_ai/smart_config', type='json', auth='user')
def smart_config(self, description):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
AI = request.env['fusion.clock.ai.service'].sudo()
system_prompt = AI._get_system_prompt('smart_config')
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': description},
],
feature='smart_config',
)
except Exception as e:
return {'error': str(e)}
return {'response': response}
@http.route('/fusion_clock_ai/apply_config', type='json', auth='user')
def apply_config(self, changes):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
ICP = request.env['ir.config_parameter'].sudo()
applied = []
for change in changes:
param = change.get('parameter', '')
value = change.get('value', '')
if param.startswith('fusion_clock.'):
ICP.set_param(param, str(value))
applied.append(param)
return {'applied': applied, 'count': len(applied)}

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import datetime, timedelta, date
from odoo import http, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockPortalAI(http.Controller):
def _get_employee(self):
return request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.uid),
], limit=1)
@http.route('/fusion_clock_ai/employee_chat', type='json', auth='user')
def employee_chat(self, message, conversation_id=None):
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_employee_chat', 'True') != 'True':
return {'error': 'AI Assistant is currently disabled.'}
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found.'}
AI = request.env['fusion.clock.ai.service'].sudo()
context_data = AI._build_employee_context(
employee,
date.today() - timedelta(days=30),
date.today(),
)
Conversation = request.env['fusion.clock.ai.conversation'].sudo()
if conversation_id:
conv = Conversation.browse(int(conversation_id))
if not conv.exists() or conv.user_id.id != request.env.uid:
conv = Conversation.create({
'user_id': request.env.uid,
'conversation_type': 'employee_chat',
})
else:
conv = Conversation.create({
'user_id': request.env.uid,
'conversation_type': 'employee_chat',
})
Message = request.env['fusion.clock.ai.message'].sudo()
Message.create({
'conversation_id': conv.id,
'role': 'user',
'content': message,
})
system_prompt = AI._get_system_prompt('employee_chat')
messages = [
{'role': 'system', 'content': f"{system_prompt}\n\n--- YOUR ATTENDANCE DATA ---\n{context_data}"},
]
for msg in conv.message_ids:
messages.append({'role': msg.role, 'content': msg.content})
try:
response = AI.chat_completion(messages, feature='employee_chat')
except Exception as e:
return {'error': str(e)}
Message.create({
'conversation_id': conv.id,
'role': 'assistant',
'content': response,
})
return {
'response': response,
'conversation_id': conv.id,
}
@http.route('/fusion_clock_ai/polish_reason', type='json', auth='user')
def polish_reason(self, rough_text):
AI = request.env['fusion.clock.ai.service'].sudo()
system_prompt = AI._get_system_prompt('leave_reason_writer')
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': rough_text},
],
feature='leave_reason_writer',
)
except Exception as e:
return {'error': str(e)}
return {'polished': response}
@http.route('/fusion_clock_ai/my_coach_tip', type='json', auth='user')
def my_coach_tip(self):
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found.'}
AI = request.env['fusion.clock.ai.service'].sudo()
context_data = AI._build_employee_context(
employee,
date.today() - timedelta(days=14),
date.today(),
)
system_prompt = AI._get_system_prompt('attendance_coach')
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='attendance_coach',
)
except Exception as e:
return {'error': str(e)}
return {'tip': response}

View File

@@ -0,0 +1,253 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="prompt_manager_query" model="fusion.clock.ai.prompt">
<field name="key">manager_query</field>
<field name="name">Manager Natural Language Query</field>
<field name="feature_category">manager_query</field>
<field name="description">System prompt for the manager chat. The team attendance data will be injected as context.</field>
<field name="content">You are an attendance analytics assistant for a company using Fusion Clock.
You answer questions about employee attendance, overtime, absences, penalties, and schedules.
Rules:
- Answer based ONLY on the provided data. Never invent data.
- If the data doesn't contain the answer, say so clearly.
- Format responses with clear structure: bullet points, tables when comparing employees.
- Use hours and minutes (e.g., "8h 30m"), not decimal hours.
- When mentioning dates, use the format "Mon, Mar 15".
- Be concise but thorough. Highlight concerning patterns.
- If asked about payroll impact, calculate net hours minus penalties.
- Currency amounts are NOT in the data -- never guess pay rates.</field>
</record>
<record id="prompt_employee_chat" model="fusion.clock.ai.prompt">
<field name="key">employee_chat</field>
<field name="name">Employee Clock Assistant</field>
<field name="feature_category">employee_chat</field>
<field name="content">You are a friendly attendance assistant for an employee using Fusion Clock.
You help them understand their hours, schedule, and attendance history.
Rules:
- Only share information about THIS employee. Never reference other employees.
- Be encouraging but honest about attendance issues.
- Format times in 12-hour format (e.g., "9:05 AM").
- If they ask about submitting leave, guide them to use the Leave Request form on the portal.
- If they ask about corrections, guide them to the Correction Request feature.
- Never make up data. If something isn't in the context, say you don't have that information.
- Keep responses concise and friendly.</field>
</record>
<record id="prompt_weekly_narrative" model="fusion.clock.ai.prompt">
<field name="key">weekly_narrative</field>
<field name="name">Weekly Summary Narrative</field>
<field name="feature_category">report</field>
<field name="content">Generate a concise, professional weekly attendance summary for the employee.
Structure:
1. Opening line with overall assessment (strong week / needs attention / concerning patterns)
2. Key metrics in a sentence (total hours, overtime, on-time streak)
3. Notable events (if any: penalties, absences, auto-clock-outs, late arrivals)
4. Positive note or actionable suggestion
Keep it to 3-5 sentences. Professional but warm tone. No bullet points -- write as a short paragraph.</field>
</record>
<record id="prompt_anomaly_detection" model="fusion.clock.ai.prompt">
<field name="key">anomaly_detection</field>
<field name="name">Payroll Anomaly Detection</field>
<field name="feature_category">anomaly</field>
<field name="content">You are a payroll auditor reviewing attendance data before payroll processing.
Flag these anomalies:
- Missing clock-outs without leave requests
- Extremely short shifts (under 2 hours) that might be accidental
- Overtime spikes vs previous periods
- Employees with zero hours but no absence/leave record
- Consecutive auto-clock-outs (employee may not know how to clock out)
- Clock-in/out times that are suspiciously identical day after day (possible buddy punching)
- Weekend/holiday work without overtime classification
For each anomaly found, output:
ANOMALY: [employee name] - [type] - [details] - [recommended action]
If no anomalies found, say "No anomalies detected. Payroll data looks clean."</field>
</record>
<record id="prompt_attendance_coach" model="fusion.clock.ai.prompt">
<field name="key">attendance_coach</field>
<field name="name">Personal Attendance Coach</field>
<field name="feature_category">coach</field>
<field name="content">You are a supportive attendance coach writing a brief personalized tip for an employee.
Based on their recent attendance patterns, write ONE actionable tip (1-2 sentences).
Be specific to their data -- don't give generic advice.
Examples of good tips:
- "You've been arriving 5-10 minutes late on Mondays for 3 weeks. Setting your Monday alarm 15 minutes earlier could protect your 12-day streak."
- "Great consistency this week -- 5 days on time! You're 3 days away from a 20-day streak milestone."
- "You've been auto-clocked-out 3 times this week. Remember to clock out before leaving to avoid needing corrections."
Keep it positive and actionable. Never be punitive.</field>
</record>
<record id="prompt_correction_advisor" model="fusion.clock.ai.prompt">
<field name="key">correction_advisor</field>
<field name="name">Correction Review Advisor</field>
<field name="feature_category">correction</field>
<field name="content">You are an HR advisor reviewing a timesheet correction request.
Provide context to help the manager decide:
1. Is this a one-time request or a recurring pattern?
2. Does the requested time seem reasonable given the employee's typical schedule?
3. Any red flags (e.g., adding hours on a day already marked absent)?
End with: "Recommendation: APPROVE / REVIEW FURTHER / DISCUSS WITH EMPLOYEE"
Be neutral and fact-based. 2-3 sentences max.</field>
</record>
<record id="prompt_understaffing" model="fusion.clock.ai.prompt">
<field name="key">understaffing_prediction</field>
<field name="name">Understaffing Prediction</field>
<field name="feature_category">prediction</field>
<field name="content">Based on historical attendance patterns, predict the likelihood of understaffing for the upcoming week.
Consider:
- Day-of-week absence patterns (e.g., Mondays/Fridays higher)
- Seasonal patterns if data spans multiple months
- Recent absence trends (increasing/decreasing)
- Approved upcoming leaves
For each day of the upcoming week, output:
[Day]: [Risk Level: Low/Medium/High] - [Expected attendance count] / [Total employees] - [Reasoning]
End with one overall recommendation for the manager.</field>
</record>
<record id="prompt_shift_optimization" model="fusion.clock.ai.prompt">
<field name="key">shift_optimization</field>
<field name="name">Shift Optimization</field>
<field name="feature_category">shift</field>
<field name="content">Analyze employee attendance patterns and suggest shift reassignments.
Look for:
- Employees consistently arriving early/late for their assigned shift
- Employees with high penalties who might fit a different shift
- Unbalanced shift coverage (too many on one shift, too few on another)
For each suggestion:
SUGGESTION: Move [employee] from [current shift] to [suggested shift] -- [reasoning based on their clock-in pattern]
Only suggest changes with strong data support. If no changes needed, say so.</field>
</record>
<record id="prompt_compliance" model="fusion.clock.ai.prompt">
<field name="key">compliance_check</field>
<field name="name">Labor Compliance Check</field>
<field name="feature_category">compliance</field>
<field name="content">Review attendance records for potential labor law compliance issues.
Check for:
- Shifts exceeding maximum allowed consecutive hours (flag if > 12h)
- Missing mandatory breaks (shifts > 5h without break deduction)
- Insufficient rest between shifts (less than 8 hours between clock-out and next clock-in)
- Excessive weekly hours (> 48h in a week)
- Minors working outside permitted hours (if age data available)
For each violation:
VIOLATION: [employee] - [type] - [date] - [details] - [regulation reference if known]
If compliant, say "All records are within standard labor compliance thresholds."</field>
</record>
<record id="prompt_smart_config" model="fusion.clock.ai.prompt">
<field name="key">smart_config</field>
<field name="name">Natural Language Configuration</field>
<field name="feature_category">config</field>
<field name="content">You translate natural language policy descriptions into Fusion Clock configuration parameters.
Available parameters:
- fusion_clock.default_clock_in_time (float, 24h format, e.g. 9.0)
- fusion_clock.default_clock_out_time (float, 24h format)
- fusion_clock.default_break_minutes (float, minutes)
- fusion_clock.grace_period_minutes (float)
- fusion_clock.penalty_grace_minutes (float)
- fusion_clock.penalty_deduction_minutes (float)
- fusion_clock.very_late_threshold_minutes (float)
- fusion_clock.max_monthly_absences (integer)
- fusion_clock.daily_overtime_threshold (float, hours)
- fusion_clock.weekly_overtime_threshold (float, hours)
- fusion_clock.max_shift_hours (float, hours)
- fusion_clock.break_threshold_hours (float, hours)
For each change, output JSON:
{"parameter": "key", "value": "new_value", "explanation": "why"}
If the request is ambiguous, ask for clarification instead of guessing.</field>
</record>
<record id="prompt_geofence_tuning" model="fusion.clock.ai.prompt">
<field name="key">geofence_tuning</field>
<field name="name">Geofence Tuning Suggestions</field>
<field name="feature_category">geofence</field>
<field name="content">Analyze clock-in/out distance data for geofenced locations and suggest radius adjustments.
Look for:
- High percentage of clock-ins just outside the radius (radius too tight)
- All clock-ins very close to center (radius could be tightened for security)
- Specific employees consistently outside (individual issue vs location issue)
- Different patterns by time of day
For each location:
[Location Name] (current radius: Xm):
- Clock-ins inside: X%, average distance: Xm
- Clock-ins outside (blocked): X%, average overshoot: Xm
- SUGGESTION: [Keep/Increase to Xm/Decrease to Xm] -- [reasoning]</field>
</record>
<record id="prompt_incident_explanation" model="fusion.clock.ai.prompt">
<field name="key">incident_explanation</field>
<field name="name">Incident Auto-Explanation</field>
<field name="feature_category">incident</field>
<field name="content">Generate a brief, human-readable explanation for an attendance incident.
Given the incident type and context, write 1 sentence explaining what happened and why.
Examples:
- auto_clock_out: "Automatically clocked out at 5:15 PM after the 15-minute grace period expired without a manual clock-out."
- late_clock_in: "Arrived at 9:23 AM, 23 minutes after the 9:00 AM scheduled start. 15-minute penalty applied after 5-minute grace."
- outside_geofence: "Attempted to clock in from 850m away from the Mississauga office (allowed radius: 200m)."
Be factual. Include specific times and numbers from the data.</field>
</record>
<record id="prompt_leave_reason" model="fusion.clock.ai.prompt">
<field name="key">leave_reason_writer</field>
<field name="name">Leave Reason Writer</field>
<field name="feature_category">employee_chat</field>
<field name="content">Rewrite the employee's rough leave/absence explanation into a professional, clear message suitable for their manager.
Rules:
- Keep the original meaning exactly. Do not add or change facts.
- Fix grammar, spelling, and formatting.
- Keep it concise (1-3 sentences).
- Professional but natural tone.
- If the original is already professional, return it unchanged.</field>
</record>
<record id="prompt_log_summary" model="fusion.clock.ai.prompt">
<field name="key">activity_log_summary</field>
<field name="name">Activity Log Summarizer</field>
<field name="feature_category">report</field>
<field name="content">Summarize an employee's activity logs for a given period into a narrative overview.
Structure:
1. Overall pattern (consistent, improving, declining, irregular)
2. Key stats in prose (X clock-ins, Y on-time, Z incidents)
3. Notable patterns (e.g., "Late arrivals concentrated on Mondays")
4. Comparison to previous period if data available
Write 3-5 sentences. Professional, concise, fact-based.</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="config_param_openai_model" model="ir.config_parameter">
<field name="key">fusion_clock_ai.openai_model</field>
<field name="value">gpt-4o-mini</field>
</record>
<record id="config_param_max_response_tokens" model="ir.config_parameter">
<field name="key">fusion_clock_ai.max_response_tokens</field>
<field name="value">1024</field>
</record>
<record id="config_param_monthly_budget_usd" model="ir.config_parameter">
<field name="key">fusion_clock_ai.monthly_budget_usd</field>
<field name="value">50.0</field>
</record>
<record id="config_param_cache_ttl_minutes" model="ir.config_parameter">
<field name="key">fusion_clock_ai.cache_ttl_minutes</field>
<field name="value">15</field>
</record>
</odoo>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_ai_coach_tips" model="ir.cron">
<field name="name">Fusion Clock AI: Weekly Coach Tips</field>
<field name="model_id" ref="hr.model_hr_employee"/>
<field name="state">code</field>
<field name="code">
employees = env['hr.employee'].search([('x_fclk_enable_clock', '=', True)])
employees.action_generate_coach_tip()
</field>
<field name="interval_number">1</field>
<field name="interval_type">weeks</field>
<field name="active">True</field>
<field name="priority">90</field>
</record>
<record id="cron_ai_cache_cleanup" model="ir.cron">
<field name="name">Fusion Clock AI: Cache Cleanup</field>
<field name="model_id" ref="fusion_clock_ai.model_fusion_clock_ai_cache"/>
<field name="state">code</field>
<field name="code">model._gc_expired_cache()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="priority">95</field>
</record>
<record id="cron_ai_employee_summaries" model="ir.cron">
<field name="name">Fusion Clock AI: Daily Employee Summaries</field>
<field name="model_id" ref="hr.model_hr_employee"/>
<field name="state">code</field>
<field name="code">
employees = env['hr.employee'].search([('x_fclk_enable_clock', '=', True)])
employees.action_generate_ai_summary()
</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">False</field>
<field name="priority">92</field>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import ai_service
from . import ai_prompt
from . import ai_conversation
from . import ai_usage
from . import ai_cache
from . import res_config_settings
from . import hr_employee_ai
from . import hr_attendance_ai
from . import clock_report_ai
from . import clock_correction_ai

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo import models, fields
class FusionClockAICache(models.Model):
_name = 'fusion.clock.ai.cache'
_description = 'AI Response Cache'
_order = 'created_at desc'
cache_key = fields.Char(required=True, index=True)
prompt_key = fields.Char(index=True)
response_text = fields.Text(required=True)
created_at = fields.Datetime(default=fields.Datetime.now, required=True)
def _gc_expired_cache(self):
"""Cron: delete cache entries older than 24 hours."""
cutoff = datetime.now() - timedelta(hours=24)
self.search([('created_at', '<', cutoff)]).unlink()

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionClockAIConversation(models.Model):
_name = 'fusion.clock.ai.conversation'
_description = 'AI Chat Conversation'
_order = 'create_date desc'
user_id = fields.Many2one('res.users', required=True, index=True, ondelete='cascade')
conversation_type = fields.Selection([
('manager_query', 'Manager Query'),
('employee_chat', 'Employee Chat'),
('config', 'Configuration'),
], required=True)
message_ids = fields.One2many('fusion.clock.ai.message', 'conversation_id')
title = fields.Char(compute='_compute_title', store=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
def _compute_title(self):
for rec in self:
first_msg = rec.message_ids.filtered(lambda m: m.role == 'user')[:1]
if first_msg:
text = first_msg.content[:60]
rec.title = text + ('...' if len(first_msg.content) > 60 else '')
else:
rec.title = f"Conversation #{rec.id}"
class FusionClockAIMessage(models.Model):
_name = 'fusion.clock.ai.message'
_description = 'AI Chat Message'
_order = 'create_date asc'
conversation_id = fields.Many2one('fusion.clock.ai.conversation', required=True,
ondelete='cascade', index=True)
role = fields.Selection([
('system', 'System'),
('user', 'User'),
('assistant', 'Assistant'),
], required=True)
content = fields.Text(required=True)
token_count = fields.Integer()

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionClockAIPrompt(models.Model):
_name = 'fusion.clock.ai.prompt'
_description = 'AI Prompt Template'
_order = 'key'
key = fields.Char(required=True, index=True)
name = fields.Char(required=True)
content = fields.Text(required=True)
description = fields.Text(help="Explains when this prompt is used and what variables are available.")
active = fields.Boolean(default=True)
feature_category = fields.Selection([
('manager_query', 'Manager Queries'),
('employee_chat', 'Employee Chatbot'),
('report', 'Report Narratives'),
('anomaly', 'Anomaly Detection'),
('coach', 'Attendance Coach'),
('correction', 'Correction Advisor'),
('prediction', 'Predictions'),
('shift', 'Shift Optimization'),
('compliance', 'Compliance'),
('config', 'Configuration'),
('geofence', 'Geofence Tuning'),
('incident', 'Incident Explanation'),
], required=True)

View File

@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import hashlib
import logging
from datetime import datetime, timedelta, date
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
_logger.warning("openai package not installed. Fusion Clock AI features will be unavailable.")
class FusionClockAIService(models.AbstractModel):
_name = 'fusion.clock.ai.service'
_description = 'Fusion Clock AI Service'
# ------------------------------------------------------------------
# Configuration helpers
# ------------------------------------------------------------------
def _get_client(self):
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed. Run: pip install openai"))
ICP = self.env['ir.config_parameter'].sudo()
api_key = ICP.get_param('fusion_clock_ai.openai_api_key', '')
if not api_key:
raise UserError(_("OpenAI API key not configured. Go to Fusion Clock > Configuration > AI Settings."))
return OpenAI(api_key=api_key)
def _get_model(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_clock_ai.openai_model', 'gpt-4o-mini')
def _get_max_tokens(self):
ICP = self.env['ir.config_parameter'].sudo()
return int(ICP.get_param('fusion_clock_ai.max_response_tokens', '1024'))
# ------------------------------------------------------------------
# Budget enforcement
# ------------------------------------------------------------------
def _check_budget(self):
ICP = self.env['ir.config_parameter'].sudo()
monthly_budget = float(ICP.get_param('fusion_clock_ai.monthly_budget_usd', '50.0'))
if monthly_budget <= 0:
return True
today = date.today()
month_start = today.replace(day=1)
usage_records = self.env['fusion.clock.ai.usage'].sudo().search([
('date', '>=', month_start),
('date', '<=', today),
])
total_cost = sum(r.estimated_cost_usd for r in usage_records)
if total_cost >= monthly_budget:
raise UserError(_(
"Monthly AI budget of $%(budget).2f has been reached ($%(spent).2f spent). "
"Increase the budget in AI Settings or wait until next month.",
budget=monthly_budget, spent=total_cost,
))
return True
# ------------------------------------------------------------------
# Cache helpers
# ------------------------------------------------------------------
def _get_cache_key(self, prompt_key, context_hash):
return hashlib.sha256(f"{prompt_key}:{context_hash}".encode()).hexdigest()
def _check_cache(self, cache_key, ttl_minutes=15):
cutoff = datetime.now() - timedelta(minutes=ttl_minutes)
cached = self.env['fusion.clock.ai.cache'].sudo().search([
('cache_key', '=', cache_key),
('created_at', '>=', cutoff),
], limit=1)
if cached:
return cached.response_text
return False
def _store_cache(self, cache_key, response_text, prompt_key=''):
self.env['fusion.clock.ai.cache'].sudo().create({
'cache_key': cache_key,
'prompt_key': prompt_key,
'response_text': response_text,
})
# ------------------------------------------------------------------
# Usage logging with cost estimation
# ------------------------------------------------------------------
MODEL_COST_PER_1K_INPUT = {
'gpt-4o': 0.0025,
'gpt-4o-mini': 0.00015,
'gpt-3.5-turbo': 0.0005,
}
MODEL_COST_PER_1K_OUTPUT = {
'gpt-4o': 0.01,
'gpt-4o-mini': 0.0006,
'gpt-3.5-turbo': 0.0015,
}
def _log_usage(self, feature, model_name, prompt_tokens, completion_tokens):
input_cost = (prompt_tokens / 1000) * self.MODEL_COST_PER_1K_INPUT.get(model_name, 0.001)
output_cost = (completion_tokens / 1000) * self.MODEL_COST_PER_1K_OUTPUT.get(model_name, 0.002)
today = date.today()
existing = self.env['fusion.clock.ai.usage'].sudo().search([
('date', '=', today),
('feature', '=', feature),
('model_name', '=', model_name),
], limit=1)
if existing:
existing.write({
'prompt_tokens': existing.prompt_tokens + prompt_tokens,
'completion_tokens': existing.completion_tokens + completion_tokens,
'estimated_cost_usd': existing.estimated_cost_usd + input_cost + output_cost,
'request_count': existing.request_count + 1,
})
else:
self.env['fusion.clock.ai.usage'].sudo().create({
'date': today,
'feature': feature,
'model_name': model_name,
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'estimated_cost_usd': input_cost + output_cost,
'request_count': 1,
})
# ------------------------------------------------------------------
# Prompt template retrieval
# ------------------------------------------------------------------
def _get_system_prompt(self, prompt_key):
prompt = self.env['fusion.clock.ai.prompt'].sudo().search([
('key', '=', prompt_key),
('active', '=', True),
], limit=1)
if not prompt:
return "You are a helpful attendance management assistant."
return prompt.content
# ------------------------------------------------------------------
# Main chat completion method
# ------------------------------------------------------------------
def chat_completion(self, messages, feature='general', cache_key=None, cache_ttl=15):
if cache_key:
cached = self._check_cache(cache_key, cache_ttl)
if cached:
return cached
self._check_budget()
client = self._get_client()
model_name = self._get_model()
max_tokens = self._get_max_tokens()
try:
response = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=max_tokens,
temperature=0.3,
)
except Exception as e:
_logger.error("OpenAI API error: %s", str(e))
raise UserError(_("AI service error: %s", str(e)))
result = response.choices[0].message.content
usage = response.usage
self._log_usage(feature, model_name, usage.prompt_tokens, usage.completion_tokens)
if cache_key:
self._store_cache(cache_key, result, feature)
return result
# ------------------------------------------------------------------
# Data aggregation helpers
# ------------------------------------------------------------------
def _build_employee_context(self, employee, date_from=None, date_to=None):
if not date_from:
date_from = date.today() - timedelta(days=7)
if not date_to:
date_to = date.today()
Attendance = self.env['hr.attendance'].sudo()
attendances = Attendance.search([
('employee_id', '=', employee.id),
('check_in', '>=', datetime.combine(date_from, datetime.min.time())),
('check_in', '<=', datetime.combine(date_to, datetime.max.time())),
], order='check_in asc')
shift = employee.x_fclk_shift_id
shift_info = f"Shift: {shift.name} ({shift.start_time:.1f}-{shift.end_time:.1f})" if shift else "No assigned shift"
lines = [
f"Employee: {employee.name}",
f"Department: {employee.department_id.name or 'N/A'}",
shift_info,
f"On-time streak: {employee.x_fclk_ontime_streak}",
f"Absences this month: {employee.x_fclk_absences_this_month}",
f"Absences this year: {employee.x_fclk_absences_this_year}",
f"Overtime this week: {employee.x_fclk_overtime_this_week:.1f}h",
f"Overtime this month: {employee.x_fclk_overtime_this_month:.1f}h",
"",
f"Attendance records ({date_from} to {date_to}):",
]
for att in attendances:
check_out_str = att.check_out.strftime('%Y-%m-%d %H:%M') if att.check_out else 'MISSING'
lines.append(
f" {att.check_in.strftime('%Y-%m-%d %H:%M')} -> {check_out_str} | "
f"Net: {att.x_fclk_net_hours:.1f}h | Break: {att.x_fclk_break_minutes:.0f}m | "
f"OT: {att.x_fclk_overtime_hours:.1f}h | Source: {att.x_fclk_clock_source or 'N/A'}"
)
Log = self.env['fusion.clock.activity.log'].sudo()
logs = Log.search([
('employee_id', '=', employee.id),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
('log_type', 'in', ['late_clock_in', 'early_clock_out', 'auto_clock_out',
'missed_clock_out', 'absent', 'outside_geofence']),
], order='log_date asc')
if logs:
lines.append("")
lines.append("Incidents:")
for log in logs:
lines.append(f" [{log.log_type}] {log.log_date.strftime('%Y-%m-%d %H:%M')}: {log.description or ''}")
Penalty = self.env['fusion.clock.penalty'].sudo()
penalties = Penalty.search([
('employee_id', '=', employee.id),
('date', '>=', date_from),
('date', '<=', date_to),
])
if penalties:
lines.append("")
lines.append("Penalties:")
for p in penalties:
lines.append(f" [{p.penalty_type}] {p.date}: {p.penalty_minutes:.0f}m deducted")
return "\n".join(lines)
def _build_team_context(self, employees, date_from=None, date_to=None):
if not date_from:
date_from = date.today() - timedelta(days=7)
if not date_to:
date_to = date.today()
lines = [f"Team Attendance Summary ({date_from} to {date_to})", ""]
for emp in employees:
Attendance = self.env['hr.attendance'].sudo()
attendances = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(date_from, datetime.min.time())),
('check_in', '<=', datetime.combine(date_to, datetime.max.time())),
])
total_net = sum(a.x_fclk_net_hours for a in attendances)
total_ot = sum(a.x_fclk_overtime_hours for a in attendances)
days_worked = len(set(a.check_in.date() for a in attendances))
Penalty = self.env['fusion.clock.penalty'].sudo()
penalty_count = Penalty.search_count([
('employee_id', '=', emp.id),
('date', '>=', date_from),
('date', '<=', date_to),
])
Log = self.env['fusion.clock.activity.log'].sudo()
absence_count = Log.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
late_count = Log.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'late_clock_in'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
shift_name = emp.x_fclk_shift_id.name if emp.x_fclk_shift_id else 'No shift'
lines.append(
f"- {emp.name} ({emp.department_id.name or 'N/A'}, {shift_name}): "
f"{days_worked} days, {total_net:.1f}h net, {total_ot:.1f}h OT, "
f"{penalty_count} penalties, {late_count} late, {absence_count} absences, "
f"streak: {emp.x_fclk_ontime_streak}"
)
return "\n".join(lines)
def _build_payroll_context(self, report):
lines = [
f"Payroll Report: {report.name}",
f"Period: {report.date_start} to {report.date_end}",
f"Employee: {report.employee_id.name if report.employee_id else 'Batch (all employees)'}",
f"Total hours: {report.total_hours:.1f}",
f"Net hours: {report.net_hours:.1f}",
f"Total breaks: {report.total_breaks:.0f} minutes",
f"Total penalties: {report.total_penalties}",
f"Days worked: {report.days_worked}",
"",
"Daily breakdown:",
]
for att in report.attendance_ids:
check_out_str = att.check_out.strftime('%H:%M') if att.check_out else 'MISSING'
auto = ' [AUTO-CLOCKED-OUT]' if att.x_fclk_auto_clocked_out else ''
lines.append(
f" {att.check_in.strftime('%Y-%m-%d')} | "
f"{att.check_in.strftime('%H:%M')}-{check_out_str} | "
f"Net: {att.x_fclk_net_hours:.1f}h | OT: {att.x_fclk_overtime_hours:.1f}h{auto}"
)
return "\n".join(lines)

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionClockAIUsage(models.Model):
_name = 'fusion.clock.ai.usage'
_description = 'AI Usage Tracking'
_order = 'date desc'
date = fields.Date(required=True, index=True)
feature = fields.Char(required=True, index=True)
model_name = fields.Char(required=True)
prompt_tokens = fields.Integer()
completion_tokens = fields.Integer()
total_tokens = fields.Integer(compute='_compute_total')
estimated_cost_usd = fields.Float(digits=(10, 6))
request_count = fields.Integer(default=1)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
def _compute_total(self):
for rec in self:
rec.total_tokens = rec.prompt_tokens + rec.completion_tokens

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import date, timedelta
from odoo import models, fields
_logger = logging.getLogger(__name__)
class ClockCorrectionAI(models.Model):
_inherit = 'fusion.clock.correction'
x_fclk_ai_advice = fields.Text(string='AI Review Advice', readonly=True)
def action_get_ai_advice(self):
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_correction_advisor', 'True') != 'True':
return
AI = self.env['fusion.clock.ai.service'].sudo()
for correction in self:
emp = correction.employee_id
emp_context = AI._build_employee_context(
emp, date.today() - timedelta(days=30), date.today()
)
correction_context = (
f"Correction Request:\n"
f"Employee: {emp.name}\n"
f"Original check-in: {correction.original_check_in}\n"
f"Original check-out: {correction.original_check_out}\n"
f"Requested check-in: {correction.requested_check_in}\n"
f"Requested check-out: {correction.requested_check_out}\n"
f"Reason: {correction.reason}\n"
f"State: {correction.state}\n"
f"\nEmployee history:\n{emp_context}"
)
system_prompt = AI._get_system_prompt('correction_advisor')
try:
advice = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': correction_context},
],
feature='correction_advisor',
)
correction.write({'x_fclk_ai_advice': advice})
except Exception as e:
_logger.warning("AI advice failed for correction %s: %s", correction.id, e)

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class ClockReportAI(models.Model):
_inherit = 'fusion.clock.report'
x_fclk_ai_narrative = fields.Text(string='AI Narrative', readonly=True)
def action_generate_ai_narrative(self):
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_narrative_reports', 'True') != 'True':
return
AI = self.env['fusion.clock.ai.service'].sudo()
for report in self:
context_data = AI._build_payroll_context(report)
system_prompt = AI._get_system_prompt('weekly_narrative')
try:
narrative = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='report_narrative',
)
report.write({'x_fclk_ai_narrative': narrative})
except Exception as e:
_logger.warning("AI narrative failed for report %s: %s", report.name, e)
def action_generate_report(self):
result = super().action_generate_report()
self.action_generate_ai_narrative()
return result

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, api
_logger = logging.getLogger(__name__)
class HrAttendanceAI(models.Model):
_inherit = 'hr.attendance'
def _ai_explain_incident(self, log_record):
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_incident_explain', 'True') != 'True':
return
AI = self.env['fusion.clock.ai.service'].sudo()
system_prompt = AI._get_system_prompt('incident_explanation')
context = (
f"Incident type: {log_record.log_type}\n"
f"Employee: {log_record.employee_id.name}\n"
f"Date/Time: {log_record.log_date}\n"
f"Location: {log_record.location_id.name or 'N/A'}\n"
f"Distance: {log_record.distance or 0:.0f}m\n"
f"Current description: {log_record.description or 'None'}\n"
)
shift = log_record.employee_id.x_fclk_shift_id
if shift:
context += f"Shift: {shift.name} ({shift.start_time:.1f}-{shift.end_time:.1f})\n"
try:
explanation = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context},
],
feature='incident_explain',
)
log_record.sudo().write({'description': explanation})
except Exception as e:
_logger.debug("AI incident explain skipped: %s", e)

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import date, timedelta
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class HrEmployeeAI(models.Model):
_inherit = 'hr.employee'
x_fclk_ai_summary = fields.Text(string='AI Attendance Summary', readonly=True)
x_fclk_ai_summary_date = fields.Date(string='Summary Generated', readonly=True)
x_fclk_ai_coach_tip = fields.Text(string='AI Coach Tip', readonly=True)
def action_generate_ai_summary(self):
AI = self.env['fusion.clock.ai.service'].sudo()
for emp in self:
context_data = AI._build_employee_context(
emp, date.today() - timedelta(days=30), date.today()
)
system_prompt = AI._get_system_prompt('activity_log_summary')
try:
summary = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='log_summary',
)
emp.write({
'x_fclk_ai_summary': summary,
'x_fclk_ai_summary_date': date.today(),
})
except Exception as e:
_logger.warning("Failed to generate AI summary for %s: %s", emp.name, e)
def action_generate_coach_tip(self):
AI = self.env['fusion.clock.ai.service'].sudo()
for emp in self:
context_data = AI._build_employee_context(
emp, date.today() - timedelta(days=14), date.today()
)
system_prompt = AI._get_system_prompt('attendance_coach')
try:
tip = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='attendance_coach',
)
emp.write({'x_fclk_ai_coach_tip': tip})
except Exception as e:
_logger.warning("Failed to generate coach tip for %s: %s", emp.name, e)

View File

@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fclk_ai_openai_api_key = fields.Char(
string='OpenAI API Key',
config_parameter='fusion_clock_ai.openai_api_key',
)
fclk_ai_openai_model = fields.Selection([
('gpt-4o', 'GPT-4o (Best quality, higher cost)'),
('gpt-4o-mini', 'GPT-4o Mini (Good quality, low cost)'),
('gpt-3.5-turbo', 'GPT-3.5 Turbo (Basic, lowest cost)'),
], string='AI Model',
config_parameter='fusion_clock_ai.openai_model',
default='gpt-4o-mini',
)
fclk_ai_max_response_tokens = fields.Integer(
string='Max Response Tokens',
config_parameter='fusion_clock_ai.max_response_tokens',
default=1024,
)
fclk_ai_monthly_budget_usd = fields.Float(
string='Monthly Budget (USD)',
config_parameter='fusion_clock_ai.monthly_budget_usd',
default=50.0,
help="Set to 0 for unlimited. AI requests stop when budget is reached.",
)
fclk_ai_cache_ttl_minutes = fields.Integer(
string='Cache TTL (minutes)',
config_parameter='fusion_clock_ai.cache_ttl_minutes',
default=15,
)
fclk_ai_enable_manager_chat = fields.Boolean(
string='Enable Manager AI Chat',
config_parameter='fusion_clock_ai.enable_manager_chat',
default=True,
)
fclk_ai_enable_employee_chat = fields.Boolean(
string='Enable Employee AI Assistant',
config_parameter='fusion_clock_ai.enable_employee_chat',
default=True,
)
fclk_ai_enable_narrative_reports = fields.Boolean(
string='Enable AI Narrative Reports',
config_parameter='fusion_clock_ai.enable_narrative_reports',
default=True,
)
fclk_ai_enable_anomaly_detection = fields.Boolean(
string='Enable Payroll Anomaly Detection',
config_parameter='fusion_clock_ai.enable_anomaly_detection',
default=True,
)
fclk_ai_enable_coach = fields.Boolean(
string='Enable Attendance Coach',
config_parameter='fusion_clock_ai.enable_coach',
default=True,
)
fclk_ai_enable_correction_advisor = fields.Boolean(
string='Enable Correction Review Advisor',
config_parameter='fusion_clock_ai.enable_correction_advisor',
default=True,
)
fclk_ai_enable_predictions = fields.Boolean(
string='Enable Predictive Alerts',
config_parameter='fusion_clock_ai.enable_predictions',
default=False,
)
fclk_ai_enable_shift_suggestions = fields.Boolean(
string='Enable Shift Optimization',
config_parameter='fusion_clock_ai.enable_shift_suggestions',
default=False,
)
fclk_ai_enable_compliance = fields.Boolean(
string='Enable Compliance Checks',
config_parameter='fusion_clock_ai.enable_compliance',
default=False,
)
fclk_ai_enable_smart_config = fields.Boolean(
string='Enable Natural Language Config',
config_parameter='fusion_clock_ai.enable_smart_config',
default=False,
)
fclk_ai_enable_geofence_tuning = fields.Boolean(
string='Enable Geofence Tuning',
config_parameter='fusion_clock_ai.enable_geofence_tuning',
default=False,
)
fclk_ai_enable_incident_explain = fields.Boolean(
string='Enable Incident Auto-Explain',
config_parameter='fusion_clock_ai.enable_incident_explain',
default=True,
)

View File

@@ -0,0 +1,10 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_prompt_manager,fusion.clock.ai.prompt.manager,model_fusion_clock_ai_prompt,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_conversation_user,fusion.clock.ai.conversation.user,model_fusion_clock_ai_conversation,fusion_clock.group_fusion_clock_user,1,1,1,1
access_ai_conversation_manager,fusion.clock.ai.conversation.manager,model_fusion_clock_ai_conversation,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_message_user,fusion.clock.ai.message.user,model_fusion_clock_ai_message,fusion_clock.group_fusion_clock_user,1,1,1,0
access_ai_message_manager,fusion.clock.ai.message.manager,model_fusion_clock_ai_message,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_usage_manager,fusion.clock.ai.usage.manager,model_fusion_clock_ai_usage,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_cache_manager,fusion.clock.ai.cache.manager,model_fusion_clock_ai_cache,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_conversation_portal,fusion.clock.ai.conversation.portal,model_fusion_clock_ai_conversation,base.group_portal,1,0,1,0
access_ai_message_portal,fusion.clock.ai.message.portal,model_fusion_clock_ai_message,base.group_portal,1,0,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_prompt_manager fusion.clock.ai.prompt.manager model_fusion_clock_ai_prompt fusion_clock.group_fusion_clock_manager 1 1 1 1
3 access_ai_conversation_user fusion.clock.ai.conversation.user model_fusion_clock_ai_conversation fusion_clock.group_fusion_clock_user 1 1 1 1
4 access_ai_conversation_manager fusion.clock.ai.conversation.manager model_fusion_clock_ai_conversation fusion_clock.group_fusion_clock_manager 1 1 1 1
5 access_ai_message_user fusion.clock.ai.message.user model_fusion_clock_ai_message fusion_clock.group_fusion_clock_user 1 1 1 0
6 access_ai_message_manager fusion.clock.ai.message.manager model_fusion_clock_ai_message fusion_clock.group_fusion_clock_manager 1 1 1 1
7 access_ai_usage_manager fusion.clock.ai.usage.manager model_fusion_clock_ai_usage fusion_clock.group_fusion_clock_manager 1 1 1 1
8 access_ai_cache_manager fusion.clock.ai.cache.manager model_fusion_clock_ai_cache fusion_clock.group_fusion_clock_manager 1 1 1 1
9 access_ai_conversation_portal fusion.clock.ai.conversation.portal model_fusion_clock_ai_conversation base.group_portal 1 0 1 0
10 access_ai_message_portal fusion.clock.ai.message.portal model_fusion_clock_ai_message base.group_portal 1 0 1 0

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================
Record Rules - AI Conversation
================================================================ -->
<record id="rule_ai_conversation_user" model="ir.rule">
<field name="name">AI Conversation: User sees own</field>
<field name="model_id" ref="model_fusion_clock_ai_conversation"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_ai_conversation_manager" model="ir.rule">
<field name="name">AI Conversation: Manager sees all</field>
<field name="model_id" ref="model_fusion_clock_ai_conversation"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<record id="rule_ai_conversation_portal" model="ir.rule">
<field name="name">AI Conversation: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_ai_conversation"/>
<field name="domain_force">[('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="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================
Record Rules - AI Message
================================================================ -->
<record id="rule_ai_message_user" model="ir.rule">
<field name="name">AI Message: User sees own conversation messages</field>
<field name="model_id" ref="model_fusion_clock_ai_message"/>
<field name="domain_force">[('conversation_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_ai_message_manager" model="ir.rule">
<field name="name">AI Message: Manager sees all</field>
<field name="model_id" ref="model_fusion_clock_ai_message"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<record id="rule_ai_message_portal" model="ir.rule">
<field name="name">AI Message: Portal user sees own conversation messages</field>
<field name="model_id" ref="model_fusion_clock_ai_message"/>
<field name="domain_force">[('conversation_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="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================
Record Rules - AI Usage (Manager only)
================================================================ -->
<record id="rule_ai_usage_manager" model="ir.rule">
<field name="name">AI Usage: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_ai_usage"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Record Rules - AI Prompt (Manager only)
================================================================ -->
<record id="rule_ai_prompt_manager" model="ir.rule">
<field name="name">AI Prompt: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_ai_prompt"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,544 @@
/* Copyright 2026 Nexa Systems Inc. */
/* License OPL-1 (Odoo Proprietary License v1.0) */
/* Portal AI Chat Widget Styles */
/* ================================================================
Container
================================================================ */
.fclk-ai-portal-chat {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10500;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* ================================================================
Floating Bubble
================================================================ */
.fclk-ai-portal-bubble {
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4), 0 2px 6px rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
position: relative;
}
.fclk-ai-portal-bubble:hover {
transform: scale(1.08);
box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5), 0 3px 10px rgba(0, 0, 0, 0.2);
}
.fclk-ai-portal-bubble:active {
transform: scale(0.95);
}
.fclk-ai-portal-bubble--open {
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
}
/* ================================================================
Chat Panel
================================================================ */
.fclk-ai-portal-panel {
position: absolute;
bottom: 64px;
right: 0;
width: 360px;
max-height: 480px;
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
flex-direction: column;
overflow: hidden;
opacity: 0;
transform: translateY(12px) scale(0.96);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fclk-ai-portal-panel.fclk-ai-pp--visible {
opacity: 1;
transform: translateY(0) scale(1);
}
/* ================================================================
Panel Header
================================================================ */
.fclk-ai-pp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
color: #fff;
flex-shrink: 0;
}
.fclk-ai-pp-header-info {
display: flex;
align-items: center;
gap: 10px;
}
.fclk-ai-pp-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.fclk-ai-pp-title {
font-weight: 600;
font-size: 14px;
line-height: 1.2;
}
.fclk-ai-pp-subtitle {
font-size: 11px;
opacity: 0.85;
line-height: 1.2;
margin-top: 1px;
}
.fclk-ai-pp-close {
background: none;
border: none;
color: #fff;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.fclk-ai-pp-close:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ================================================================
Messages Area
================================================================ */
.fclk-ai-pp-msgs {
flex: 1;
overflow-y: auto;
padding: 14px 14px 8px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
max-height: 280px;
scroll-behavior: smooth;
}
.fclk-ai-pp-msgs::-webkit-scrollbar {
width: 4px;
}
.fclk-ai-pp-msgs::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
}
.fclk-ai-portal-msg {
display: flex;
max-width: 85%;
animation: fclk-ai-msg-in 0.2s ease-out;
}
.fclk-ai-portal-msg--user {
align-self: flex-end;
}
.fclk-ai-portal-msg--assistant {
align-self: flex-start;
}
.fclk-ai-portal-msg-text {
padding: 10px 14px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
white-space: pre-wrap;
}
.fclk-ai-portal-msg--user .fclk-ai-portal-msg-text {
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
border-bottom-right-radius: 4px;
}
.fclk-ai-portal-msg--assistant .fclk-ai-portal-msg-text {
background: #f3f4f6;
color: #1f2937;
border-bottom-left-radius: 4px;
}
@keyframes fclk-ai-msg-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ================================================================
Typing Indicator
================================================================ */
.fclk-ai-portal-typing .fclk-ai-portal-dots {
display: flex;
gap: 4px;
padding: 10px 16px;
background: #f3f4f6;
border-radius: 14px;
border-bottom-left-radius: 4px;
}
.fclk-ai-portal-dots span {
width: 7px;
height: 7px;
background: #9ca3af;
border-radius: 50%;
animation: fclk-ai-dot-bounce 1.2s infinite ease-in-out;
}
.fclk-ai-portal-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.fclk-ai-portal-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes fclk-ai-dot-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-5px); }
}
/* ================================================================
Quick Actions
================================================================ */
.fclk-ai-pp-quick {
display: flex;
gap: 6px;
padding: 8px 14px;
flex-wrap: wrap;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
}
.fclk-ai-pp-qbtn {
background: #faf5ff;
border: 1px solid #e9d5ff;
border-radius: 20px;
padding: 5px 12px;
font-size: 11.5px;
color: #7c3aed;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.fclk-ai-pp-qbtn:hover {
background: #ede9fe;
border-color: #c4b5fd;
transform: translateY(-1px);
}
.fclk-ai-pp-qbtn:active {
transform: translateY(0);
}
.fclk-ai-pp-qbtn .fa {
font-size: 11px;
}
/* ================================================================
Input Row
================================================================ */
.fclk-ai-pp-input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px 14px;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
}
.fclk-ai-pp-input {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background: #fafafa;
color: #1f2937;
}
.fclk-ai-pp-input:focus {
border-color: #a78bfa;
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.15);
background: #fff;
}
.fclk-ai-pp-input::placeholder {
color: #9ca3af;
}
.fclk-ai-pp-send {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s ease, transform 0.1s ease;
}
.fclk-ai-pp-send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fclk-ai-pp-send:not(:disabled):hover {
transform: scale(1.06);
}
.fclk-ai-pp-send:not(:disabled):active {
transform: scale(0.95);
}
/* ================================================================
Polish Overlay
================================================================ */
.fclk-ai-pp-polish-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.97);
flex-direction: column;
justify-content: center;
padding: 24px 20px;
gap: 14px;
z-index: 5;
border-radius: 16px;
}
.fclk-ai-pp-polish-title {
font-weight: 600;
font-size: 15px;
color: #7c3aed;
display: flex;
align-items: center;
gap: 8px;
}
.fclk-ai-pp-polish-title .fa {
font-size: 16px;
}
.fclk-ai-pp-polish-input {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
color: #1f2937;
}
.fclk-ai-pp-polish-input:focus {
border-color: #a78bfa;
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.15);
}
.fclk-ai-pp-polish-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.fclk-ai-pp-polish-cancel {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #fff;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.fclk-ai-pp-polish-cancel:hover {
background: #f3f4f6;
}
.fclk-ai-pp-polish-send {
padding: 8px 20px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
.fclk-ai-pp-polish-send:hover {
opacity: 0.9;
}
.fclk-ai-pp-polish-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ================================================================
Dark Theme Support (Odoo portal dark mode)
================================================================ */
html[data-color-scheme="dark"] .fclk-ai-portal-panel,
.o_dark .fclk-ai-portal-panel {
background: #1e1e2e;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.25);
}
html[data-color-scheme="dark"] .fclk-ai-portal-msg--assistant .fclk-ai-portal-msg-text,
.o_dark .fclk-ai-portal-msg--assistant .fclk-ai-portal-msg-text {
background: #2a2a3e;
color: #e5e7eb;
}
html[data-color-scheme="dark"] .fclk-ai-portal-typing .fclk-ai-portal-dots,
.o_dark .fclk-ai-portal-typing .fclk-ai-portal-dots {
background: #2a2a3e;
}
html[data-color-scheme="dark"] .fclk-ai-portal-dots span,
.o_dark .fclk-ai-portal-dots span {
background: #6b7280;
}
html[data-color-scheme="dark"] .fclk-ai-pp-quick,
.o_dark .fclk-ai-pp-quick {
border-top-color: #333;
}
html[data-color-scheme="dark"] .fclk-ai-pp-qbtn,
.o_dark .fclk-ai-pp-qbtn {
background: #2a2a3e;
border-color: #444;
color: #c4b5fd;
}
html[data-color-scheme="dark"] .fclk-ai-pp-qbtn:hover,
.o_dark .fclk-ai-pp-qbtn:hover {
background: #333;
border-color: #7c3aed;
}
html[data-color-scheme="dark"] .fclk-ai-pp-input-row,
.o_dark .fclk-ai-pp-input-row {
border-top-color: #333;
}
html[data-color-scheme="dark"] .fclk-ai-pp-input,
.o_dark .fclk-ai-pp-input {
background: #2a2a3e;
border-color: #444;
color: #e5e7eb;
}
html[data-color-scheme="dark"] .fclk-ai-pp-input:focus,
.o_dark .fclk-ai-pp-input:focus {
background: #1e1e2e;
border-color: #7c3aed;
}
html[data-color-scheme="dark"] .fclk-ai-pp-input::placeholder,
.o_dark .fclk-ai-pp-input::placeholder {
color: #6b7280;
}
html[data-color-scheme="dark"] .fclk-ai-pp-polish-overlay,
.o_dark .fclk-ai-pp-polish-overlay {
background: rgba(30, 30, 46, 0.97);
}
html[data-color-scheme="dark"] .fclk-ai-pp-polish-input,
.o_dark .fclk-ai-pp-polish-input {
background: #2a2a3e;
border-color: #444;
color: #e5e7eb;
}
html[data-color-scheme="dark"] .fclk-ai-pp-polish-cancel,
.o_dark .fclk-ai-pp-polish-cancel {
background: #2a2a3e;
border-color: #444;
color: #9ca3af;
}
html[data-color-scheme="dark"] .fclk-ai-pp-polish-cancel:hover,
.o_dark .fclk-ai-pp-polish-cancel:hover {
background: #333;
}
/* ================================================================
Responsive: Full-width below 480px
================================================================ */
@media (max-width: 480px) {
.fclk-ai-portal-chat {
bottom: 16px;
right: 16px;
left: 16px;
}
.fclk-ai-portal-bubble {
position: fixed;
bottom: 16px;
right: 16px;
}
.fclk-ai-portal-panel {
position: fixed;
bottom: 80px;
right: 16px;
left: 16px;
width: auto;
max-height: calc(100dvh - 120px);
}
.fclk-ai-pp-msgs {
max-height: none;
flex: 1;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,158 @@
/** @odoo-module **/
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
const SUGGESTIONS = [
"Who had the most overtime this week?",
"Show me late arrivals today",
"Absence summary this month",
"Which team is understaffed?",
];
const TOOLS = [
{ key: "anomaly", icon: "fa-exclamation-triangle", label: "Anomaly Scan" },
{ key: "understaffing", icon: "fa-users", label: "Staffing Forecast" },
{ key: "shift", icon: "fa-calendar", label: "Shift Optimizer" },
{ key: "compliance", icon: "fa-gavel", label: "Compliance Check" },
{ key: "geofence", icon: "fa-map-marker", label: "Geofence Tuning" },
];
export class FusionClockAIChat extends Component {
static props = {};
static template = "fusion_clock_ai.AISystray";
static components = { Dropdown, DropdownItem };
setup() {
this.notification = useService("notification");
this.chatBodyRef = useRef("chatBody");
this.dropdown = useDropdownState();
this.state = useState({
activeTab: "chat",
messages: [],
inputText: "",
loading: false,
conversationId: null,
toolLoading: null,
toolResult: null,
});
}
get suggestions() {
return SUGGESTIONS;
}
get tools() {
return TOOLS;
}
get hasMessages() {
return this.state.messages.length > 0;
}
get canSend() {
return this.state.inputText.trim().length > 0 && !this.state.loading;
}
switchTab(tab) {
this.state.activeTab = tab;
this.state.toolResult = null;
}
newConversation() {
this.state.messages = [];
this.state.conversationId = null;
this.state.inputText = "";
}
onInput(ev) {
this.state.inputText = ev.target.value;
}
onKeydown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
onSuggestionClick(text) {
this.state.inputText = text;
this.sendMessage();
}
_scrollToBottom() {
const el = this.chatBodyRef.el;
if (el) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
}
async sendMessage() {
const text = this.state.inputText.trim();
if (!text || this.state.loading) return;
this.state.messages.push({ role: "user", content: text });
this.state.inputText = "";
this.state.loading = true;
this._scrollToBottom();
try {
const result = await rpc("/fusion_clock_ai/manager_chat", {
message: text,
conversation_id: this.state.conversationId || undefined,
});
if (result.error) {
this.state.messages.push({ role: "assistant", content: result.error });
this.notification.add(result.error, { type: "warning" });
} else {
this.state.messages.push({ role: "assistant", content: result.response });
this.state.conversationId = result.conversation_id;
}
} catch (e) {
const errorMsg = "Failed to get AI response. Please try again.";
this.state.messages.push({ role: "assistant", content: errorMsg });
this.notification.add(errorMsg, { type: "danger" });
}
this.state.loading = false;
this._scrollToBottom();
}
async runTool(toolKey) {
if (this.state.toolLoading) return;
this.state.toolLoading = toolKey;
this.state.toolResult = null;
try {
const result = await rpc("/fusion_clock_ai/run_analysis", {
analysis_type: toolKey,
});
if (result.error) {
this.state.toolResult = { error: true, text: result.error };
this.notification.add(result.error, { type: "warning" });
} else {
this.state.toolResult = { error: false, text: result.response };
}
} catch (e) {
this.state.toolResult = { error: true, text: "Analysis failed. Please try again." };
this.notification.add("Analysis failed.", { type: "danger" });
}
this.state.toolLoading = null;
}
}
registry.category("systray").add("fusion_clock_ai.AISystray", {
Component: FusionClockAIChat,
}, { sequence: 100 });

View File

@@ -0,0 +1,353 @@
/** @odoo-module **/
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
const QUICK_ACTIONS = [
{ key: "hours", label: "My hours this week", icon: "fa-clock-o" },
{ key: "coach", label: "Coach tip", icon: "fa-lightbulb-o" },
{ key: "polish", label: "Polish my reason", icon: "fa-magic" },
];
const BUBBLE_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" 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>`;
const CLOSE_SVG = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>`;
const SEND_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>`;
export class FusionClockAIPortalChat extends Interaction {
static selector = ".fclk-ai-portal-chat";
setup() {
this.panelOpen = false;
this.loading = false;
this.messages = [];
this.conversationId = null;
this.polishMode = false;
this._buildDOM();
this._bindEvents();
}
destroy() {
if (this._onDocClick) {
document.removeEventListener("click", this._onDocClick, true);
}
}
// =========================================================================
// DOM Construction
// =========================================================================
_buildDOM() {
this.el.innerHTML = "";
this.bubbleEl = this._createElement("button", "fclk-ai-portal-bubble", BUBBLE_SVG);
this.el.appendChild(this.bubbleEl);
this.panelEl = this._createElement("div", "fclk-ai-portal-panel");
this.panelEl.style.display = "none";
this.panelEl.innerHTML = this._panelHTML();
this.el.appendChild(this.panelEl);
this.headerCloseEl = this.panelEl.querySelector(".fclk-ai-pp-close");
this.msgsEl = this.panelEl.querySelector(".fclk-ai-pp-msgs");
this.quickEl = this.panelEl.querySelector(".fclk-ai-pp-quick");
this.inputEl = this.panelEl.querySelector(".fclk-ai-pp-input");
this.sendBtnEl = this.panelEl.querySelector(".fclk-ai-pp-send");
this.polishOverlayEl = this.panelEl.querySelector(".fclk-ai-pp-polish-overlay");
this.polishInputEl = this.panelEl.querySelector(".fclk-ai-pp-polish-input");
this.polishSendEl = this.panelEl.querySelector(".fclk-ai-pp-polish-send");
this.polishCancelEl = this.panelEl.querySelector(".fclk-ai-pp-polish-cancel");
}
_panelHTML() {
const quickBtns = QUICK_ACTIONS.map(
(a) => `<button class="fclk-ai-pp-qbtn" data-action="${a.key}"><i class="fa ${a.icon}"></i> ${a.label}</button>`
).join("");
return `
<div class="fclk-ai-pp-header">
<div class="fclk-ai-pp-header-info">
<div class="fclk-ai-pp-avatar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>
<path d="M6 10v1a6 6 0 0 0 12 0v-1"/>
<line x1="12" y1="17" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
</div>
<div>
<div class="fclk-ai-pp-title">AI Assistant</div>
<div class="fclk-ai-pp-subtitle">Ask about your hours, tips & more</div>
</div>
</div>
<button class="fclk-ai-pp-close">${CLOSE_SVG}</button>
</div>
<div class="fclk-ai-pp-msgs">
<div class="fclk-ai-portal-msg fclk-ai-portal-msg--assistant">
<div class="fclk-ai-portal-msg-text">Hi! I can help you check your hours, give you attendance tips, or polish leave request reasons. What can I help with?</div>
</div>
</div>
<div class="fclk-ai-pp-quick">${quickBtns}</div>
<div class="fclk-ai-pp-input-row">
<input type="text" class="fclk-ai-pp-input" placeholder="Type a message..." autocomplete="off"/>
<button class="fclk-ai-pp-send" disabled>${SEND_SVG}</button>
</div>
<div class="fclk-ai-pp-polish-overlay" style="display:none;">
<div class="fclk-ai-pp-polish-title"><i class="fa fa-magic"></i> Polish Leave Reason</div>
<textarea class="fclk-ai-pp-polish-input" rows="3" placeholder="Type your rough reason..."></textarea>
<div class="fclk-ai-pp-polish-actions">
<button class="fclk-ai-pp-polish-cancel">Cancel</button>
<button class="fclk-ai-pp-polish-send">Polish It</button>
</div>
</div>`;
}
_createElement(tag, className, innerHTML) {
const el = document.createElement(tag);
el.className = className;
if (innerHTML) el.innerHTML = innerHTML;
return el;
}
// =========================================================================
// Event Binding
// =========================================================================
_bindEvents() {
this.bubbleEl.addEventListener("click", (e) => {
e.stopPropagation();
this._togglePanel();
});
this.headerCloseEl.addEventListener("click", () => this._togglePanel());
this.sendBtnEl.addEventListener("click", () => this._sendMessage());
this.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this._sendMessage();
}
});
this.inputEl.addEventListener("input", () => {
this.sendBtnEl.disabled = !this.inputEl.value.trim();
});
this.panelEl.querySelectorAll(".fclk-ai-pp-qbtn").forEach((btn) => {
btn.addEventListener("click", () => this._onQuickAction(btn.dataset.action));
});
this.polishSendEl.addEventListener("click", () => this._sendPolish());
this.polishCancelEl.addEventListener("click", () => this._closePolish());
this._onDocClick = (ev) => {
if (!this.panelOpen) return;
if (!ev.target.closest(".fclk-ai-portal-chat")) {
this._closePanel();
}
};
document.addEventListener("click", this._onDocClick, true);
}
// =========================================================================
// Panel Toggle
// =========================================================================
_togglePanel() {
this.panelOpen ? this._closePanel() : this._openPanel();
}
_openPanel() {
this.panelOpen = true;
this.panelEl.style.display = "flex";
this.bubbleEl.innerHTML = CLOSE_SVG;
this.bubbleEl.classList.add("fclk-ai-portal-bubble--open");
requestAnimationFrame(() => {
this.panelEl.classList.add("fclk-ai-pp--visible");
});
this.inputEl.focus();
}
_closePanel() {
this.panelOpen = false;
this.panelEl.classList.remove("fclk-ai-pp--visible");
this.bubbleEl.innerHTML = BUBBLE_SVG;
this.bubbleEl.classList.remove("fclk-ai-portal-bubble--open");
setTimeout(() => {
if (!this.panelOpen) this.panelEl.style.display = "none";
}, 250);
}
// =========================================================================
// Messages
// =========================================================================
_appendMessage(role, text) {
const msg = document.createElement("div");
msg.className = `fclk-ai-portal-msg fclk-ai-portal-msg--${role}`;
const inner = document.createElement("div");
inner.className = "fclk-ai-portal-msg-text";
inner.textContent = text;
msg.appendChild(inner);
this.msgsEl.appendChild(msg);
this._scrollToBottom();
}
_appendTyping() {
const msg = document.createElement("div");
msg.className = "fclk-ai-portal-msg fclk-ai-portal-msg--assistant fclk-ai-portal-typing";
msg.innerHTML = '<div class="fclk-ai-portal-dots"><span></span><span></span><span></span></div>';
this.msgsEl.appendChild(msg);
this._scrollToBottom();
return msg;
}
_removeTyping(el) {
if (el && el.parentNode) el.parentNode.removeChild(el);
}
_scrollToBottom() {
requestAnimationFrame(() => {
this.msgsEl.scrollTop = this.msgsEl.scrollHeight;
});
}
// =========================================================================
// Send / Receive
// =========================================================================
async _sendMessage() {
const text = this.inputEl.value.trim();
if (!text || this.loading) return;
this._appendMessage("user", text);
this.inputEl.value = "";
this.sendBtnEl.disabled = true;
this.loading = true;
const typingEl = this._appendTyping();
try {
const result = await rpc("/fusion_clock_ai/employee_chat", {
message: text,
conversation_id: this.conversationId || undefined,
});
this._removeTyping(typingEl);
if (result.error) {
this._appendMessage("assistant", result.error);
} else {
this._appendMessage("assistant", result.response);
this.conversationId = result.conversation_id;
}
} catch {
this._removeTyping(typingEl);
this._appendMessage("assistant", "Something went wrong. Please try again.");
}
this.loading = false;
}
// =========================================================================
// Quick Actions
// =========================================================================
async _onQuickAction(key) {
if (this.loading) return;
if (key === "hours") {
this.inputEl.value = "How many hours have I worked this week?";
this._sendMessage();
return;
}
if (key === "coach") {
this._appendMessage("user", "Give me a coach tip");
this.loading = true;
const typingEl = this._appendTyping();
try {
const result = await rpc("/fusion_clock_ai/my_coach_tip", {});
this._removeTyping(typingEl);
if (result.error) {
this._appendMessage("assistant", result.error);
} else {
this._appendMessage("assistant", result.tip);
}
} catch {
this._removeTyping(typingEl);
this._appendMessage("assistant", "Could not fetch coach tip.");
}
this.loading = false;
return;
}
if (key === "polish") {
this._openPolish();
}
}
// =========================================================================
// Polish Reason
// =========================================================================
_openPolish() {
this.polishOverlayEl.style.display = "flex";
this.polishInputEl.value = "";
this.polishInputEl.focus();
}
_closePolish() {
this.polishOverlayEl.style.display = "none";
}
async _sendPolish() {
const text = this.polishInputEl.value.trim();
if (!text || this.loading) return;
this.loading = true;
this.polishSendEl.disabled = true;
this.polishSendEl.textContent = "Polishing...";
try {
const result = await rpc("/fusion_clock_ai/polish_reason", {
rough_text: text,
});
this._closePolish();
this._appendMessage("user", `Polish this reason: "${text}"`);
if (result.error) {
this._appendMessage("assistant", result.error);
} else {
this._appendMessage("assistant", result.polished);
}
} catch {
this._closePolish();
this._appendMessage("assistant", "Could not polish reason.");
}
this.polishSendEl.disabled = false;
this.polishSendEl.textContent = "Polish It";
this.loading = false;
}
}
registry
.category("public.interactions")
.add("fusion_clock_ai.PortalChat", FusionClockAIPortalChat);

View File

@@ -0,0 +1,366 @@
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Fusion Clock AI - Systray + Manager Chat styles
// All colors use Odoo/Bootstrap CSS variables for dark mode support
// AI brand color tokens (adapt to dark mode)
:root {
--fclk-ai-brand: #764ba2;
--fclk-ai-brand-light: #8b5fbf;
--fclk-ai-brand-bg: rgba(118, 75, 162, 0.08);
}
html.o_dark {
--fclk-ai-brand: #a87fd4;
--fclk-ai-brand-light: #c4a6e6;
--fclk-ai-brand-bg: rgba(168, 127, 212, 0.12);
}
// Systray icon
.fclk-ai-systray-btn {
position: relative;
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.fclk-ai-systray-img {
width: 18px;
height: 18px;
object-fit: contain;
transition: transform 0.2s;
.fclk-ai-systray-btn:hover & {
transform: scale(1.15);
}
}
// Dropdown container
.fclk-ai-systray-dropdown {
width: 380px;
border-radius: 12px !important;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html.o_dark .fclk-ai-systray-dropdown {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
}
// Panel inside dropdown
.fclk-ai-panel {
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 520px;
background: var(--o-view-background-color, var(--bs-body-bg, #fff));
}
.fclk-ai-panel-header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
gap: 8px;
flex-shrink: 0;
}
.fclk-ai-panel-title {
font-weight: 700;
font-size: 14px;
color: var(--o-main-text-color, var(--bs-body-color, #212529));
white-space: nowrap;
}
.fclk-ai-tabs {
display: flex;
gap: 4px;
margin-left: auto;
}
.fclk-ai-tab {
padding: 4px 12px;
border-radius: 20px;
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
background: transparent;
font-size: 12px;
cursor: pointer;
color: var(--o-main-text-color, var(--bs-body-color, #212529));
transition: background 0.15s;
&.active {
background: var(--fclk-ai-brand);
color: #fff;
border-color: transparent;
}
}
.fclk-ai-chat-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 300px;
max-height: 380px;
}
.fclk-ai-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--o-main-text-color, var(--bs-body-color, #212529));
opacity: 0.6;
text-align: center;
padding: 24px;
}
.fclk-ai-empty-icon {
font-size: 40px;
opacity: 0.3;
}
.fclk-ai-suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.fclk-ai-suggestion {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
background: transparent;
font-size: 12px;
cursor: pointer;
color: var(--o-main-text-color, var(--bs-body-color, #212529));
transition: background 0.15s, border-color 0.15s;
&:hover {
background: var(--fclk-ai-brand);
color: #fff;
border-color: transparent;
}
}
.fclk-ai-msg {
display: flex;
gap: 8px;
align-items: flex-start;
&--user {
flex-direction: row-reverse;
.fclk-ai-msg-content {
background: var(--fclk-ai-brand);
color: #fff;
border-radius: 16px 16px 4px 16px;
}
.fclk-ai-msg-avatar {
background: var(--fclk-ai-brand);
color: #fff;
}
}
&--assistant {
.fclk-ai-msg-content {
background: var(--o-action-color-light, var(--bs-tertiary-bg, #f0f0f0));
color: var(--o-main-text-color, var(--bs-body-color, #212529));
border-radius: 16px 16px 16px 4px;
}
}
}
.fclk-ai-msg-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
background: var(--o-action-color-light, var(--bs-tertiary-bg, #e9ecef));
color: var(--o-main-text-color, var(--bs-body-color, #212529));
}
.fclk-ai-msg-content {
padding: 10px 14px;
font-size: 13px;
line-height: 1.5;
max-width: 80%;
white-space: pre-wrap;
word-break: break-word;
}
.fclk-ai-typing {
display: flex;
gap: 4px;
padding: 12px 16px !important;
span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--o-main-text-color, var(--bs-body-color, #212529));
opacity: 0.4;
animation: fclk-ai-bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.16s; }
&:nth-child(3) { animation-delay: 0.32s; }
}
}
@keyframes fclk-ai-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.fclk-ai-chat-input {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
flex-shrink: 0;
textarea {
flex: 1;
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
border-radius: 20px;
padding: 8px 14px;
font-size: 13px;
resize: none;
outline: none;
background: var(--o-view-background-color, var(--bs-body-bg, #fff));
color: var(--o-main-text-color, var(--bs-body-color, #212529));
max-height: 80px;
min-height: 36px;
&:focus {
border-color: var(--fclk-ai-brand);
}
}
}
.fclk-ai-new-chat,
.fclk-ai-send {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
font-size: 14px;
transition: opacity 0.15s;
}
.fclk-ai-new-chat {
background: var(--o-action-color-light, var(--bs-tertiary-bg, #e9ecef));
color: var(--o-main-text-color, var(--bs-body-color, #212529));
&:hover { opacity: 0.8; }
}
.fclk-ai-send {
background: var(--fclk-ai-brand);
color: #fff;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.fclk-ai-tools-body {
flex: 1;
overflow-y: auto;
padding: 16px;
min-height: 300px;
max-height: 380px;
}
.fclk-ai-tool-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
}
.fclk-ai-tool-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 10px;
border-radius: 12px;
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
background: var(--o-view-background-color, var(--bs-body-bg, #fff));
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
color: var(--o-main-text-color, var(--bs-body-color, #212529));
i {
font-size: 22px;
color: var(--fclk-ai-brand);
}
span {
font-size: 12px;
font-weight: 500;
}
&:hover {
border-color: var(--fclk-ai-brand);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}
.fclk-ai-analysis-loading {
text-align: center;
padding: 20px;
color: var(--o-main-text-color, var(--bs-body-color, #212529));
opacity: 0.6;
font-size: 13px;
i { margin-right: 6px; }
}
.fclk-ai-analysis-result {
pre {
background: var(--o-action-color-light, var(--bs-tertiary-bg, #f8f9fa));
color: var(--o-main-text-color, var(--bs-body-color, #212529));
padding: 14px;
border-radius: 10px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
margin: 0;
}
}
@media (max-width: 480px) {
.fclk-ai-systray-dropdown {
width: calc(100vw - 32px);
}
}

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<templates xml:space="preserve">
<t t-name="fusion_clock_ai.AISystray">
<Dropdown position="'bottom-end'" state="dropdown"
menuClass="'fclk-ai-systray-dropdown p-0'">
<button class="fclk-ai-systray-btn" title="AI Assistant">
<img src="/fusion_clock_ai/static/src/img/ai_icon.png" alt="AI" class="fclk-ai-systray-img"/>
</button>
<t t-set-slot="content">
<div class="fclk-ai-panel" t-on-click.stop="">
<!-- Header -->
<div class="fclk-ai-panel-header">
<span class="fclk-ai-panel-title">AI Assistant</span>
<div class="fclk-ai-tabs">
<button t-attf-class="fclk-ai-tab {{ state.activeTab === 'chat' ? 'active' : '' }}"
t-on-click="() => this.switchTab('chat')">Chat</button>
<button t-attf-class="fclk-ai-tab {{ state.activeTab === 'tools' ? 'active' : '' }}"
t-on-click="() => this.switchTab('tools')">Tools</button>
</div>
</div>
<!-- Chat Body -->
<div t-if="state.activeTab === 'chat'" class="fclk-ai-chat-body" t-ref="chatBody">
<!-- Empty state -->
<t t-if="!hasMessages and !state.loading">
<div class="fclk-ai-empty">
<i class="fa fa-magic fclk-ai-empty-icon"/>
<div>
<strong>Ask me anything</strong><br/>
about your team's attendance
</div>
<div class="fclk-ai-suggestions">
<t t-foreach="suggestions" t-as="s" t-key="s_index">
<button class="fclk-ai-suggestion"
t-on-click="() => this.onSuggestionClick(s)">
<t t-esc="s"/>
</button>
</t>
</div>
</div>
</t>
<!-- Messages -->
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<div t-attf-class="fclk-ai-msg fclk-ai-msg--{{ msg.role }}">
<div class="fclk-ai-msg-avatar">
<i t-if="msg.role === 'user'" class="fa fa-user"/>
<i t-else="" class="fa fa-magic"/>
</div>
<div class="fclk-ai-msg-content">
<t t-esc="msg.content"/>
</div>
</div>
</t>
<!-- Typing indicator -->
<t t-if="state.loading">
<div class="fclk-ai-msg fclk-ai-msg--assistant">
<div class="fclk-ai-msg-avatar">
<i class="fa fa-magic"/>
</div>
<div class="fclk-ai-msg-content fclk-ai-typing">
<span/><span/><span/>
</div>
</div>
</t>
</div>
<!-- Tools Body -->
<div t-if="state.activeTab === 'tools'" class="fclk-ai-tools-body">
<div class="fclk-ai-tool-grid">
<t t-foreach="tools" t-as="tool" t-key="tool.key">
<button class="fclk-ai-tool-btn"
t-on-click="() => this.runTool(tool.key)"
t-att-disabled="state.toolLoading">
<i t-attf-class="fa {{ tool.icon }}"/>
<span t-esc="tool.label"/>
</button>
</t>
</div>
<!-- Tool loading -->
<div t-if="state.toolLoading" class="fclk-ai-analysis-loading">
<i class="fa fa-circle-o-notch fa-spin"/>
Running analysis...
</div>
<!-- Tool result -->
<div t-if="state.toolResult" class="fclk-ai-analysis-result">
<pre t-esc="state.toolResult.text"/>
</div>
</div>
<!-- Input Area (chat tab only) -->
<div t-if="state.activeTab === 'chat'" class="fclk-ai-chat-input">
<button class="fclk-ai-new-chat" t-on-click="newConversation"
title="New conversation">
<i class="fa fa-plus"/>
</button>
<textarea rows="1"
placeholder="Ask about attendance..."
t-on-input="onInput"
t-on-keydown="onKeydown"
t-att-value="state.inputText"
t-att-disabled="state.loading"/>
<button class="fclk-ai-send"
t-on-click="sendMessage"
t-att-disabled="!canSend"
title="Send">
<i class="fa fa-paper-plane"/>
</button>
</div>
</div>
</t>
</Dropdown>
</t>
</templates>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Conversation List View -->
<record id="view_ai_conversation_list" model="ir.ui.view">
<field name="name">fusion.clock.ai.conversation.list</field>
<field name="model">fusion.clock.ai.conversation</field>
<field name="arch" type="xml">
<list string="AI Conversations" default_order="create_date desc" create="false">
<field name="title"/>
<field name="user_id"/>
<field name="conversation_type"/>
<field name="create_date"/>
</list>
</field>
</record>
<!-- Conversation Form View -->
<record id="view_ai_conversation_form" model="ir.ui.view">
<field name="name">fusion.clock.ai.conversation.form</field>
<field name="model">fusion.clock.ai.conversation</field>
<field name="arch" type="xml">
<form string="AI Conversation" create="false" edit="false">
<sheet>
<group>
<group>
<field name="title"/>
<field name="user_id"/>
</group>
<group>
<field name="conversation_type"/>
<field name="create_date"/>
</group>
</group>
<separator string="Messages"/>
<field name="message_ids" nolabel="1">
<list create="false" delete="false">
<field name="role" decoration-info="role == 'user'"
decoration-success="role == 'assistant'"
decoration-muted="role == 'system'" widget="badge"/>
<field name="content"/>
<field name="create_date"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- Conversation Search View -->
<record id="view_ai_conversation_search" model="ir.ui.view">
<field name="name">fusion.clock.ai.conversation.search</field>
<field name="model">fusion.clock.ai.conversation</field>
<field name="arch" type="xml">
<search string="Search Conversations">
<field name="title"/>
<field name="user_id"/>
<filter name="manager_query" string="Manager Queries"
domain="[('conversation_type', '=', 'manager_query')]"/>
<filter name="employee_chat" string="Employee Chats"
domain="[('conversation_type', '=', 'employee_chat')]"/>
<filter name="config" string="Configuration"
domain="[('conversation_type', '=', 'config')]"/>
<separator/>
<filter name="group_type" string="Type"
context="{'group_by': 'conversation_type'}"/>
<filter name="group_user" string="User"
context="{'group_by': 'user_id'}"/>
</search>
</field>
</record>
<!-- Conversation Action -->
<record id="action_ai_conversation" model="ir.actions.act_window">
<field name="name">Chat History</field>
<field name="res_model">fusion.clock.ai.conversation</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_ai_conversation_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI conversations yet
</p>
<p>
Conversations with the AI assistant will appear here.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AI Sub-Menu under Fusion Clock -->
<menuitem id="menu_fusion_clock_ai"
name="AI"
parent="fusion_clock.menu_fusion_clock_root"
sequence="50"
groups="fusion_clock.group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_ai_chat"
name="Chat History"
parent="menu_fusion_clock_ai"
action="action_ai_conversation"
sequence="10"/>
<menuitem id="menu_fusion_clock_ai_prompts"
name="Prompt Templates"
parent="menu_fusion_clock_ai"
action="action_ai_prompt"
sequence="20"/>
<menuitem id="menu_fusion_clock_ai_usage"
name="Usage Dashboard"
parent="menu_fusion_clock_ai"
action="action_ai_usage"
sequence="30"/>
</odoo>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Prompt List View -->
<record id="view_ai_prompt_list" model="ir.ui.view">
<field name="name">fusion.clock.ai.prompt.list</field>
<field name="model">fusion.clock.ai.prompt</field>
<field name="arch" type="xml">
<list string="AI Prompt Templates" default_order="key">
<field name="key"/>
<field name="name"/>
<field name="feature_category" widget="badge"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Prompt Form View -->
<record id="view_ai_prompt_form" model="ir.ui.view">
<field name="name">fusion.clock.ai.prompt.form</field>
<field name="model">fusion.clock.ai.prompt</field>
<field name="arch" type="xml">
<form string="AI Prompt Template">
<sheet>
<div class="oe_button_box" name="button_box">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</div>
<group>
<group>
<field name="key"/>
<field name="name"/>
</group>
<group>
<field name="feature_category"/>
</group>
</group>
<group string="Description">
<field name="description" nolabel="1" colspan="2"
placeholder="Explain when this prompt is used and what variables are available..."/>
</group>
<group string="Prompt Content">
<field name="content" nolabel="1" colspan="2"
placeholder="Enter the system prompt template..."
widget="text"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Prompt Search View -->
<record id="view_ai_prompt_search" model="ir.ui.view">
<field name="name">fusion.clock.ai.prompt.search</field>
<field name="model">fusion.clock.ai.prompt</field>
<field name="arch" type="xml">
<search string="Search Prompts">
<field name="key"/>
<field name="name"/>
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
<separator/>
<filter name="group_category" string="Category"
context="{'group_by': 'feature_category'}"/>
</search>
</field>
</record>
<!-- Prompt Action -->
<record id="action_ai_prompt" model="ir.actions.act_window">
<field name="name">Prompt Templates</field>
<field name="res_model">fusion.clock.ai.prompt</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_ai_prompt_search"/>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No prompt templates defined
</p>
<p>
Create prompt templates to customize AI behavior for different features.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Usage List View -->
<record id="view_ai_usage_list" model="ir.ui.view">
<field name="name">fusion.clock.ai.usage.list</field>
<field name="model">fusion.clock.ai.usage</field>
<field name="arch" type="xml">
<list string="AI Usage" default_order="date desc" create="false">
<field name="date"/>
<field name="feature"/>
<field name="model_name"/>
<field name="request_count" sum="Total Requests"/>
<field name="prompt_tokens" sum="Total Prompt"/>
<field name="completion_tokens" sum="Total Completion"/>
<field name="total_tokens" sum="Total Tokens"/>
<field name="estimated_cost_usd" sum="Total Cost" widget="monetary"
options="{'currency_id': False}"/>
</list>
</field>
</record>
<!-- Usage Search View -->
<record id="view_ai_usage_search" model="ir.ui.view">
<field name="name">fusion.clock.ai.usage.search</field>
<field name="model">fusion.clock.ai.usage</field>
<field name="arch" type="xml">
<search string="Search Usage">
<field name="feature"/>
<field name="model_name"/>
<filter name="today" string="Today"
domain="[('date', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter name="this_week" string="This Week"
domain="[('date', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter name="this_month" string="This Month"
domain="[('date', '>=', context_today().strftime('%Y-%m-01'))]"/>
<separator/>
<filter name="group_date" string="Date"
context="{'group_by': 'date'}"/>
<filter name="group_feature" string="Feature"
context="{'group_by': 'feature'}"/>
<filter name="group_model" string="Model"
context="{'group_by': 'model_name'}"/>
</search>
</field>
</record>
<!-- Usage Graph View -->
<record id="view_ai_usage_graph" model="ir.ui.view">
<field name="name">fusion.clock.ai.usage.graph</field>
<field name="model">fusion.clock.ai.usage</field>
<field name="arch" type="xml">
<graph string="AI Usage" type="line">
<field name="date" interval="day"/>
<field name="estimated_cost_usd" type="measure"/>
</graph>
</field>
</record>
<!-- Usage Pivot View -->
<record id="view_ai_usage_pivot" model="ir.ui.view">
<field name="name">fusion.clock.ai.usage.pivot</field>
<field name="model">fusion.clock.ai.usage</field>
<field name="arch" type="xml">
<pivot string="AI Usage">
<field name="date" interval="week" type="row"/>
<field name="feature" type="col"/>
<field name="total_tokens" type="measure"/>
<field name="estimated_cost_usd" type="measure"/>
<field name="request_count" type="measure"/>
</pivot>
</field>
</record>
<!-- Usage Action -->
<record id="action_ai_usage" model="ir.actions.act_window">
<field name="name">Usage Dashboard</field>
<field name="res_model">fusion.clock.ai.usage</field>
<field name="view_mode">graph,pivot,list</field>
<field name="search_view_id" ref="view_ai_usage_search"/>
<field name="context">{'search_default_this_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI usage recorded yet
</p>
<p>
Usage statistics are tracked automatically when AI features are used.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Correction Form: AI Advice Button and Field -->
<record id="view_fusion_clock_correction_form_ai" model="ir.ui.view">
<field name="name">fusion.clock.correction.form.ai</field>
<field name="model">fusion.clock.correction</field>
<field name="inherit_id" ref="fusion_clock.view_fusion_clock_correction_form"/>
<field name="arch" type="xml">
<!-- Add AI Advice button in header -->
<xpath expr="//button[@name='action_reject']" position="after">
<button name="action_get_ai_advice" type="object"
string="Get AI Advice" class="btn-secondary"
invisible="state != 'pending'"
groups="fusion_clock.group_fusion_clock_manager"
icon="fa-magic"/>
</xpath>
<!-- Add AI Advice section before chatter -->
<xpath expr="//chatter" position="before">
<group string="AI Review Advice" invisible="not x_fclk_ai_advice">
<field name="x_fclk_ai_advice" nolabel="1" colspan="2" readonly="1"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Report Form: AI Narrative Button and Field -->
<record id="view_fusion_clock_report_form_ai" model="ir.ui.view">
<field name="name">fusion.clock.report.form.ai</field>
<field name="model">fusion.clock.report</field>
<field name="inherit_id" ref="fusion_clock.view_fusion_clock_report_form"/>
<field name="arch" type="xml">
<!-- Add AI Narrative button in header -->
<xpath expr="//button[@name='action_export_csv']" position="after">
<button name="action_generate_ai_narrative" type="object"
string="Generate AI Narrative" class="btn-secondary"
invisible="state == 'draft'"
icon="fa-magic"/>
</xpath>
<!-- Add AI Narrative tab in notebook -->
<xpath expr="//page[@name='attendances']" position="after">
<page string="AI Narrative" name="ai_narrative"
invisible="not x_fclk_ai_narrative">
<field name="x_fclk_ai_narrative" nolabel="1" readonly="1"/>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Employee Form: AI Insights Tab -->
<record id="view_employee_form_fusion_clock_ai" model="ir.ui.view">
<field name="name">hr.employee.form.fusion.clock.ai</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="fusion_clock.view_employee_form_fusion_clock"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='fusion_clock_tab']" position="after">
<page string="AI Insights" name="fusion_clock_ai_tab"
groups="fusion_clock.group_fusion_clock_manager,fusion_clock.group_fusion_clock_team_lead">
<group>
<div class="d-flex gap-2 mb-3">
<button name="action_generate_ai_summary" type="object"
string="Generate AI Summary" class="btn-primary"
icon="fa-magic"/>
<button name="action_generate_coach_tip" type="object"
string="Generate Coach Tip" class="btn-secondary"
icon="fa-lightbulb-o"/>
</div>
</group>
<group string="AI Attendance Summary">
<field name="x_fclk_ai_summary" nolabel="1" colspan="2"/>
<field name="x_fclk_ai_summary_date"/>
</group>
<group string="AI Coach Tip">
<field name="x_fclk_ai_coach_tip" nolabel="1" colspan="2"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Nexa Systems Inc. -->
<!-- License OPL-1 (Odoo Proprietary License v1.0) -->
<odoo>
<!-- Inject the AI chat widget into the Fusion Clock portal page.
The JS Interaction class attaches to .fclk-ai-portal-chat and
builds the floating chat bubble + panel dynamically. -->
<template id="portal_clock_ai_chat"
name="Portal Clock AI Chat Widget"
inherit_id="fusion_clock.portal_clock_page">
<xpath expr="//script[@id='fclk-locations-data']" position="after">
<div class="fclk-ai-portal-chat"/>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_clock_ai" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.clock.ai</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="fusion_clock.res_config_settings_view_form_fusion_clock"/>
<field name="priority">96</field>
<field name="arch" type="xml">
<xpath expr="//app[@name='fusion_clock']" position="inside">
<!-- ============================================================
AI Settings - API Configuration
============================================================ -->
<block title="AI Configuration" name="fclk_ai_config">
<setting string="OpenAI API Key" help="Required. Your OpenAI API key for all AI features.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_ai_openai_api_key" string="API Key" class="col-lg-5 o_light_label"/>
<field name="fclk_ai_openai_api_key" class="o_input" placeholder="sk-..." password="True"/>
</div>
</div>
</setting>
<setting string="AI Model" help="Choose the GPT model for AI features. Higher quality costs more per request.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_ai_openai_model" string="Model" class="col-lg-5 o_light_label"/>
<field name="fclk_ai_openai_model"/>
</div>
</div>
</setting>
</block>
<!-- ============================================================
AI Settings - Limits
============================================================ -->
<block title="AI Limits" name="fclk_ai_limits">
<setting string="Monthly Budget" help="Maximum monthly spend on AI API calls. Set to 0 for unlimited.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_ai_monthly_budget_usd" string="Budget (USD)" class="col-lg-5 o_light_label"/>
<field name="fclk_ai_monthly_budget_usd"/>
</div>
</div>
</setting>
<setting string="Max Response Tokens" help="Maximum tokens per AI response. Higher values allow longer replies but cost more.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_ai_max_response_tokens" string="Max Tokens" class="col-lg-5 o_light_label"/>
<field name="fclk_ai_max_response_tokens"/>
</div>
</div>
</setting>
<setting string="Cache TTL" help="How long to cache AI responses to avoid duplicate API calls.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_ai_cache_ttl_minutes" string="TTL (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_ai_cache_ttl_minutes"/>
</div>
</div>
</setting>
</block>
<!-- ============================================================
AI Settings - Core Features (enabled by default)
============================================================ -->
<block title="AI Core Features" name="fclk_ai_core_features">
<setting string="Manager AI Chat" help="Natural language dashboard queries for managers. Ask questions about attendance in plain English.">
<field name="fclk_ai_enable_manager_chat"/>
</setting>
<setting string="Employee AI Assistant" help="Portal chatbot for employees to check hours, request leave, and ask schedule questions.">
<field name="fclk_ai_enable_employee_chat"/>
</setting>
<setting string="AI Narrative Reports" help="Generate human-readable weekly and monthly attendance summaries.">
<field name="fclk_ai_enable_narrative_reports"/>
</setting>
<setting string="Payroll Anomaly Detection" help="GPT flags suspicious attendance patterns before payroll runs.">
<field name="fclk_ai_enable_anomaly_detection"/>
</setting>
<setting string="Attendance Coach" help="Personalized weekly attendance tips and insights per employee.">
<field name="fclk_ai_enable_coach"/>
</setting>
<setting string="Correction Review Advisor" help="AI provides context and recommendation when reviewing correction requests.">
<field name="fclk_ai_enable_correction_advisor"/>
</setting>
<setting string="Incident Auto-Explain" help="Automatically generate human-readable explanations for activity log entries.">
<field name="fclk_ai_enable_incident_explain"/>
</setting>
</block>
<!-- ============================================================
AI Settings - Advanced Features (disabled by default)
============================================================ -->
<block title="AI Advanced Features" name="fclk_ai_advanced_features">
<setting string="Predictive Alerts" help="Forecast absence likelihood by day using historical patterns.">
<field name="fclk_ai_enable_predictions"/>
</setting>
<setting string="Shift Optimization" help="Data-driven shift reassignment suggestions based on attendance patterns.">
<field name="fclk_ai_enable_shift_suggestions"/>
</setting>
<setting string="Compliance Checks" help="Automated labor law violation alerts based on worked hours and schedules.">
<field name="fclk_ai_enable_compliance"/>
</setting>
<setting string="Natural Language Config" help="Describe attendance policies in English and let AI map them to settings.">
<field name="fclk_ai_enable_smart_config"/>
</setting>
<setting string="Geofence Tuning" help="AI analyzes clock-in GPS data and suggests optimal geofence radius adjustments.">
<field name="fclk_ai_enable_geofence_tuning"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>