update
This commit is contained in:
6
fusion_clock_ai/__init__.py
Normal file
6
fusion_clock_ai/__init__.py
Normal 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
|
||||
71
fusion_clock_ai/__manifest__.py
Normal file
71
fusion_clock_ai/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
fusion_clock_ai/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/__pycache__/__manifest__.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/__pycache__/__manifest__.cpython-312.pyc
Normal file
Binary file not shown.
6
fusion_clock_ai/controllers/__init__.py
Normal file
6
fusion_clock_ai/controllers/__init__.py
Normal 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
|
||||
BIN
fusion_clock_ai/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/controllers/__pycache__/ai_api.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/controllers/__pycache__/ai_api.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
216
fusion_clock_ai/controllers/ai_api.py
Normal file
216
fusion_clock_ai/controllers/ai_api.py
Normal 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)}
|
||||
121
fusion_clock_ai/controllers/portal_ai.py
Normal file
121
fusion_clock_ai/controllers/portal_ai.py
Normal 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}
|
||||
253
fusion_clock_ai/data/ai_prompt_data.xml
Normal file
253
fusion_clock_ai/data/ai_prompt_data.xml
Normal 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>
|
||||
24
fusion_clock_ai/data/ir_config_parameter_data.xml
Normal file
24
fusion_clock_ai/data/ir_config_parameter_data.xml
Normal 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>
|
||||
43
fusion_clock_ai/data/ir_cron_data.xml
Normal file
43
fusion_clock_ai/data/ir_cron_data.xml
Normal 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>
|
||||
14
fusion_clock_ai/models/__init__.py
Normal file
14
fusion_clock_ai/models/__init__.py
Normal 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
|
||||
BIN
fusion_clock_ai/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_cache.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_cache.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_prompt.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_prompt.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_service.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_usage.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_usage.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
22
fusion_clock_ai/models/ai_cache.py
Normal file
22
fusion_clock_ai/models/ai_cache.py
Normal 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()
|
||||
46
fusion_clock_ai/models/ai_conversation.py
Normal file
46
fusion_clock_ai/models/ai_conversation.py
Normal 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()
|
||||
31
fusion_clock_ai/models/ai_prompt.py
Normal file
31
fusion_clock_ai/models/ai_prompt.py
Normal 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)
|
||||
323
fusion_clock_ai/models/ai_service.py
Normal file
323
fusion_clock_ai/models/ai_service.py
Normal 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)
|
||||
25
fusion_clock_ai/models/ai_usage.py
Normal file
25
fusion_clock_ai/models/ai_usage.py
Normal 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
|
||||
50
fusion_clock_ai/models/clock_correction_ai.py
Normal file
50
fusion_clock_ai/models/clock_correction_ai.py
Normal 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)
|
||||
40
fusion_clock_ai/models/clock_report_ai.py
Normal file
40
fusion_clock_ai/models/clock_report_ai.py
Normal 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
|
||||
45
fusion_clock_ai/models/hr_attendance_ai.py
Normal file
45
fusion_clock_ai/models/hr_attendance_ai.py
Normal 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)
|
||||
58
fusion_clock_ai/models/hr_employee_ai.py
Normal file
58
fusion_clock_ai/models/hr_employee_ai.py
Normal 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)
|
||||
98
fusion_clock_ai/models/res_config_settings.py
Normal file
98
fusion_clock_ai/models/res_config_settings.py
Normal 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,
|
||||
)
|
||||
10
fusion_clock_ai/security/ir.model.access.csv
Normal file
10
fusion_clock_ai/security/ir.model.access.csv
Normal 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
|
||||
|
88
fusion_clock_ai/security/security.xml
Normal file
88
fusion_clock_ai/security/security.xml
Normal 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>
|
||||
BIN
fusion_clock_ai/static/description/icon.png
Normal file
BIN
fusion_clock_ai/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
544
fusion_clock_ai/static/src/css/portal_ai.css
Normal file
544
fusion_clock_ai/static/src/css/portal_ai.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
fusion_clock_ai/static/src/img/ai_icon.png
Normal file
BIN
fusion_clock_ai/static/src/img/ai_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
158
fusion_clock_ai/static/src/js/ai_chat_backend.js
Normal file
158
fusion_clock_ai/static/src/js/ai_chat_backend.js
Normal 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 });
|
||||
353
fusion_clock_ai/static/src/js/ai_chat_portal.js
Normal file
353
fusion_clock_ai/static/src/js/ai_chat_portal.js
Normal 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);
|
||||
366
fusion_clock_ai/static/src/scss/fusion_clock_ai.scss
Normal file
366
fusion_clock_ai/static/src/scss/fusion_clock_ai.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
126
fusion_clock_ai/static/src/xml/ai_chat_backend.xml
Normal file
126
fusion_clock_ai/static/src/xml/ai_chat_backend.xml
Normal 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>
|
||||
90
fusion_clock_ai/views/ai_conversation_views.xml
Normal file
90
fusion_clock_ai/views/ai_conversation_views.xml
Normal 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>
|
||||
29
fusion_clock_ai/views/ai_menus.xml
Normal file
29
fusion_clock_ai/views/ai_menus.xml
Normal 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>
|
||||
86
fusion_clock_ai/views/ai_prompt_views.xml
Normal file
86
fusion_clock_ai/views/ai_prompt_views.xml
Normal 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>
|
||||
92
fusion_clock_ai/views/ai_usage_views.xml
Normal file
92
fusion_clock_ai/views/ai_usage_views.xml
Normal 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>
|
||||
28
fusion_clock_ai/views/clock_correction_views.xml
Normal file
28
fusion_clock_ai/views/clock_correction_views.xml
Normal 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>
|
||||
28
fusion_clock_ai/views/clock_report_views.xml
Normal file
28
fusion_clock_ai/views/clock_report_views.xml
Normal 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>
|
||||
39
fusion_clock_ai/views/hr_employee_views.xml
Normal file
39
fusion_clock_ai/views/hr_employee_views.xml
Normal 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>
|
||||
17
fusion_clock_ai/views/portal_ai_templates.xml
Normal file
17
fusion_clock_ai/views/portal_ai_templates.xml
Normal 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>
|
||||
116
fusion_clock_ai/views/res_config_settings_views.xml
Normal file
116
fusion_clock_ai/views/res_config_settings_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user