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

View File

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

View File

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