/** @odoo-module **/ import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } from "@odoo/owl"; import { rpc } from "@web/core/network/rpc"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; export class FusionClockShiftPlanner extends Component { static template = "fusion_clock.ShiftPlanner"; static props = []; setup() { this.notification = useService("notification"); this.dirtyCells = {}; this.root = useRef("root"); this.editorRef = useRef("shiftEditor"); this.activeCellAnchor = null; this.activeEditorEmployee = null; this.activeEditorDay = null; this.timeOptions = this._buildTimeOptions(); this.state = useState({ loading: true, saving: false, weekStart: "", weekEnd: "", days: [], departments: [], employees: [], shifts: [], error: "", dirtyCount: 0, invalidCount: 0, collapsed: {}, editor: { open: false, employeeId: false, employeeName: "", date: "", dayLabel: "", startValue: "9.00", endValue: "17.00", breakMinutes: 30, hoursDisplay: "7:30", error: "", top: 0, left: 0, }, }); onWillStart(async () => { await this.loadWeek(); }); useExternalListener( window, "click", (ev) => this.onGlobalClick(ev), { capture: true } ); useExternalListener(window, "resize", () => this._positionActiveEditor()); useExternalListener(window, "scroll", () => this._positionActiveEditor(), true); onPatched(() => { this._positionActiveEditor(); }); } async loadWeek(weekStart = null) { this.state.loading = true; this.state.error = ""; try { const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart }); if (data.error) { this.state.error = data.error; } else { this._applyData(data); } } catch (error) { this.state.error = error.message || "Failed to load shift planner."; } this.state.loading = false; } _applyData(data) { this.dirtyCells = {}; this.state.weekStart = data.week_start; this.state.weekEnd = data.week_end; this.state.days = data.days || []; this.state.departments = data.departments || []; this.state.employees = data.employees || []; this.state.shifts = data.shifts || []; this.state.dirtyCount = 0; this.state.invalidCount = 0; this.state.error = ""; this.closeCellEditor(); } get weekTitle() { if (!this.state.weekStart || !this.state.weekEnd) { return ""; } return `${this.state.weekStart} to ${this.state.weekEnd}`; } getDepartmentEmployees(department) { const ids = new Set(department.employee_ids || []); return this.state.employees.filter((employee) => ids.has(employee.id)); } isCollapsed(department) { return !!this.state.collapsed[department.id]; } toggleDepartment(department) { this.state.collapsed[department.id] = !this.state.collapsed[department.id]; this.closeCellEditor(); } async previousWeek() { await this.loadWeek(this._dateAdd(this.state.weekStart, -7)); } async nextWeek() { await this.loadWeek(this._dateAdd(this.state.weekStart, 7)); } async currentWeek() { await this.loadWeek(); } async copyPreviousWeek() { if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) { return; } this.state.saving = true; try { const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", { week_start: this.state.weekStart, }); if (result.error) { this.notification.add(result.error, { type: "danger" }); } else { this._applyData(result.data); this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" }); } } catch (error) { this.notification.add(error.message || "Could not copy previous week.", { type: "danger" }); } this.state.saving = false; } async save() { this._recountInvalid(); if (this.state.invalidCount) { this.notification.add("Fix invalid shift cells before saving.", { type: "danger" }); return; } const changes = Object.values(this.dirtyCells); if (!changes.length) { this.notification.add("No shift changes to save.", { type: "info" }); return; } this.state.saving = true; try { const result = await rpc("/fusion_clock/shift_planner/save", { week_start: this.state.weekStart, changes, }); if (result.error) { this.notification.add(result.error, { type: "danger" }); } else if (!result.success) { this._markServerErrors(result.errors || []); this.notification.add("Some shift cells could not be saved.", { type: "danger" }); } else { this._applyData(result.data); this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" }); } } catch (error) { this.notification.add(error.message || "Could not save shift planner.", { type: "danger" }); } this.state.saving = false; } async exportXlsx() { try { const result = await rpc("/fusion_clock/shift_planner/export_xlsx", { week_start: this.state.weekStart, }); if (result.error) { this.notification.add(result.error, { type: "danger" }); return; } window.location = result.url; } catch (error) { this.notification.add(error.message || "Could not export shift planner.", { type: "danger" }); } } openCellEditor(employee, day, ev) { if (this.state.loading || this.state.saving) { return; } const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget; this.activeCellAnchor = anchor; this.activeEditorEmployee = employee; this.activeEditorDay = day; const cell = employee.cells[day.date] || {}; const fallback = this._defaultTimes(employee, day); const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start); const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end); const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30); const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0); this.state.editor.open = true; this.state.editor.employeeId = employee.id; this.state.editor.employeeName = employee.name; this.state.editor.date = day.date; this.state.editor.dayLabel = `${day.weekday} ${day.label}`; this.state.editor.startValue = this._timeValue(start); this.state.editor.endValue = this._timeValue(end); this.state.editor.breakMinutes = breakMinutes; this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours); this.state.editor.error = cell.error || ""; this._positionActiveEditor(anchor); } closeCellEditor() { this.state.editor.open = false; this.activeCellAnchor = null; this.activeEditorEmployee = null; this.activeEditorDay = null; } onGlobalClick(ev) { if (!this.state.editor.open) { return; } const target = ev.target; const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target); const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target); if (!clickedEditor && !clickedCell) { this.closeCellEditor(); } } isActiveCell(employee, day) { return this.state.editor.open && this.state.editor.employeeId === employee.id && this.state.editor.date === day.date; } onCellInput(employee, day, ev) { this._setCellFromInput(employee, day, ev.target.value, ev.target); } onCellKeydown(employee, day, ev) { if (ev.key === "Escape") { ev.preventDefault(); this.closeCellEditor(); return; } if (ev.key === "Tab") { this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget); this.closeCellEditor(); return; } if (ev.key === "Enter") { ev.preventDefault(); this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget); if (!employee.cells[day.date]?.error) { this.closeCellEditor(); this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length); } } } selectQuickShift(option) { const context = this._activeEditorContext(); if (!context) { return; } let parsed; if (option.type === "template") { parsed = { is_off: false, shift_id: option.shiftId, start_time: option.start, end_time: option.end, break_minutes: option.breakMinutes, hours: option.hours, hours_display: option.hoursDisplay, label: option.input, normalized_input: option.input, }; } else { parsed = this._parseInput(option.input, context.cell); } this._applyParsedToCell(context.employee, context.day, parsed, option.input); this._syncEditorFromCell(context.employee, context.day); this.closeCellEditor(); } clearActiveCell() { const context = this._activeEditorContext(); if (!context) { return; } this._setCellFromInput(context.employee, context.day, ""); this.closeCellEditor(); } onEditorStartChange(ev) { this.state.editor.startValue = ev.target.value; this.applyEditorRange(false); } onEditorEndChange(ev) { this.state.editor.endValue = ev.target.value; this.applyEditorRange(false); } applyEditorRange(close = true) { const context = this._activeEditorContext(); if (!context) { return; } const start = Number(this.state.editor.startValue); let end = Number(this.state.editor.endValue); if (end <= start) { end = Math.min(start + 0.5, 24); this.state.editor.endValue = this._timeValue(end); } const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0); if (parsed.error) { context.cell.error = parsed.error; this.state.editor.error = parsed.error; } else { this._applyParsedToCell(context.employee, context.day, parsed, parsed.label); this._syncEditorFromCell(context.employee, context.day); } this._recountInvalid(); if (close && !parsed.error) { this.closeCellEditor(); } } _setCellFromInput(employee, day, input, target = null) { const cell = employee.cells[day.date]; cell.input = input; const parsed = this._parseInput(input, cell); this._applyParsedToCell(employee, day, parsed, input); if (!parsed.error && target && parsed.normalized_input !== undefined) { target.value = parsed.normalized_input; } this._syncEditorFromCell(employee, day); } _applyParsedToCell(employee, day, parsed, input) { const cell = employee.cells[day.date]; cell.error = parsed.error || ""; if (parsed.error) { cell.input = input; this.state.editor.error = parsed.error; this._markDirty(employee, day); this._recountInvalid(); return; } cell.is_off = parsed.is_off || false; cell.shift_id = parsed.shift_id || false; cell.start_time = parsed.start_time || 0; cell.end_time = parsed.end_time || 0; cell.break_minutes = parsed.break_minutes || 0; cell.hours = parsed.hours || 0; cell.hours_display = parsed.hours_display || "0:00"; cell.label = parsed.label || ""; cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input; this.state.editor.error = ""; this._markDirty(employee, day); this._recountInvalid(); } _markDirty(employee, day) { const cell = employee.cells[day.date]; const key = `${employee.id}:${day.date}`; const payload = { employee_id: employee.id, date: day.date, input: cell.input, shift_id: cell.shift_id || false, note: cell.note || "", }; if ((cell.input || "").trim()) { payload.is_off = !!cell.is_off; payload.start_time = cell.start_time || 0; payload.end_time = cell.end_time || 0; payload.break_minutes = cell.break_minutes || 0; } this.dirtyCells[key] = payload; this.state.dirtyCount = Object.keys(this.dirtyCells).length; } _markServerErrors(errors) { for (const error of errors) { const employee = this.state.employees.find((emp) => emp.id === error.employee_id); const cell = employee && employee.cells[error.date]; if (cell) { cell.error = error.message; } } this._recountInvalid(); } _recountInvalid() { let invalid = 0; for (const employee of this.state.employees) { for (const day of this.state.days) { if (employee.cells[day.date]?.error) { invalid++; } } } this.state.invalidCount = invalid; } _parseInput(value, currentCell = {}) { const text = (value || "").trim(); if (!text) { return { is_off: false, shift_id: false, start_time: 0, end_time: 0, break_minutes: 0, label: "", hours: 0, hours_display: "0:00", normalized_input: "", }; } if (text.toUpperCase() === "OFF") { return { is_off: true, shift_id: false, start_time: 0, end_time: 0, break_minutes: 0, hours: 0, hours_display: "0:00", label: "OFF", normalized_input: "OFF", }; } const lowerText = text.toLowerCase(); const template = this.state.shifts.find((shift) => [shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText) ); if (template) { return { is_off: false, shift_id: template.id, start_time: template.start_time, end_time: template.end_time, break_minutes: template.break_minutes, hours: template.hours, hours_display: template.hours_display, label: template.label, normalized_input: template.label, }; } try { const parsed = this._parseTypedShift(text, currentCell); return parsed; } catch (error) { return { error: error.message }; } } _parseTypedShift(value, currentCell = {}) { const normalized = value.replaceAll("–", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-"); const parts = normalized.split("-"); if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) { throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF."); } const start = this._parseTimePart(parts[0]); let end = this._parseTimePart(parts[1]); if (end <= start && end + 12 <= 24) { end += 12; } if (end <= start) { throw new Error("End must be after start."); } const breakMinutes = currentCell.break_minutes || 30; const hours = Math.max(end - start - breakMinutes / 60, 0); const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`; return { is_off: false, shift_id: false, start_time: start, end_time: end, break_minutes: breakMinutes, hours, hours_display: this._formatHours(hours), label, normalized_input: label, }; } _rangeToParsed(start, end, breakMinutes) { if (Number.isNaN(start) || Number.isNaN(end)) { return { error: "Choose a start and end time." }; } if (end <= start) { return { error: "End must be after start." }; } const hours = Math.max(end - start - breakMinutes / 60, 0); const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`; return { is_off: false, shift_id: false, start_time: start, end_time: end, break_minutes: breakMinutes, hours, hours_display: this._formatHours(hours), label, normalized_input: label, }; } _parseTimePart(raw) { const text = raw.trim().toLowerCase().replaceAll(".", ""); const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/); if (!match) { throw new Error(`Could not read "${raw.trim()}".`); } let hour = Number(match[1]); const minute = Number(match[2] || 0); const meridiem = match[3]; if (minute < 0 || minute > 59) { throw new Error("Minutes must be 00-59."); } if (meridiem) { if (hour < 1 || hour > 12) { throw new Error("Use 1-12 with am/pm."); } if (meridiem === "am") { hour = hour === 12 ? 0 : hour; } else { hour = hour === 12 ? 12 : hour + 12; } } if (hour < 0 || hour > 24) { throw new Error("Hours must be 0-24."); } return hour + minute / 60; } _formatFloatTime(value) { let hour = Math.floor(value); let minute = Math.round((value - hour) * 60); if (minute === 60) { hour += 1; minute = 0; } const suffix = hour < 12 || hour === 24 ? "am" : "pm"; let displayHour = hour % 12; if (displayHour === 0) { displayHour = 12; } return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`; } _formatHours(value) { let hour = Math.floor(value); let minute = Math.round((value - hour) * 60); if (minute === 60) { hour += 1; minute = 0; } return `${hour}:${String(minute).padStart(2, "0")}`; } _timeValue(value) { const rounded = Math.round(Number(value || 0) * 4) / 4; return rounded.toFixed(2); } _buildTimeOptions() { const options = []; for (let minutes = 0; minutes <= 24 * 60; minutes += 15) { const value = minutes / 60; options.push({ value: this._timeValue(value), label: this._formatFloatTime(value), }); } return options; } _defaultTimes(employee, day) { const dayIndex = this.state.days.findIndex((item) => item.date === day.date); if (dayIndex > 0) { const previousDay = this.state.days[dayIndex - 1]; const previousCell = employee.cells[previousDay.date]; if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) { return { start: previousCell.start_time, end: previousCell.end_time, breakMinutes: previousCell.break_minutes || 30, }; } } const firstShift = this.state.shifts[0]; if (firstShift) { return { start: firstShift.start_time, end: firstShift.end_time, breakMinutes: firstShift.break_minutes || 30, }; } return { start: 9, end: 17, breakMinutes: 30 }; } get quickShiftOptions() { const options = [{ key: "off", type: "input", input: "OFF", label: "OFF", detail: "0:00", }]; const seen = new Set(["OFF"]); for (const shift of this.state.shifts) { if (seen.has(shift.label)) { continue; } seen.add(shift.label); options.push({ key: `shift-${shift.id}`, type: "template", shiftId: shift.id, input: shift.label, label: shift.name || shift.label, detail: `${shift.label} - ${shift.hours_display}`, start: shift.start_time, end: shift.end_time, breakMinutes: shift.break_minutes, hours: shift.hours, hoursDisplay: shift.hours_display, }); } for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) { if (seen.has(input)) { continue; } const parsed = this._parseInput(input, { break_minutes: 30 }); seen.add(input); options.push({ key: `common-${input}`, type: "input", input, label: input, detail: parsed.hours_display || "0:00", }); } return options.slice(0, 10); } _activeEditorContext() { if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) { return null; } return { employee: this.activeEditorEmployee, day: this.activeEditorDay, cell: this.activeEditorEmployee.cells[this.activeEditorDay.date], }; } _syncEditorFromCell(employee, day) { if (!this.isActiveCell(employee, day)) { return; } const cell = employee.cells[day.date] || {}; if (!cell.is_off && cell.start_time && cell.end_time) { this.state.editor.startValue = this._timeValue(cell.start_time); this.state.editor.endValue = this._timeValue(cell.end_time); } this.state.editor.breakMinutes = cell.break_minutes || 0; this.state.editor.hoursDisplay = cell.hours_display || "0:00"; this.state.editor.error = cell.error || ""; } _focusRelativeCell(input, offset) { const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input")); const index = inputs.indexOf(input); const next = inputs[index + offset]; if (next) { next.focus(); next.select(); } } _positionActiveEditor(anchor = null) { if (!this.state.editor.open) { return; } const target = anchor || this.activeCellAnchor; if (!target || !target.isConnected) { this.closeCellEditor(); return; } const rect = target.getBoundingClientRect(); const editorWidth = Math.min(380, window.innerWidth - 16); const editorHeight = this.editorRef.el?.offsetHeight || 300; let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8)); let top = rect.bottom + 8; if (top + editorHeight > window.innerHeight - 8) { top = Math.max(8, rect.top - editorHeight - 8); } left = Math.round(left); top = Math.round(top); if (this.state.editor.left !== left) { this.state.editor.left = left; } if (this.state.editor.top !== top) { this.state.editor.top = top; } } _dateAdd(dateString, days) { const date = new Date(`${dateString}T12:00:00`); date.setDate(date.getDate() + days); return date.toISOString().slice(0, 10); } } registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner);