changes
This commit is contained in:
@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---- Scheduled Shift Card ---- */
|
||||
.fclk-schedule-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--fclk-card);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin: -14px 0 28px;
|
||||
box-shadow: var(--fclk-shadow);
|
||||
}
|
||||
|
||||
.fclk-schedule-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--fclk-blue);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fclk-schedule-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fclk-schedule-label {
|
||||
color: var(--fclk-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.fclk-schedule-value {
|
||||
color: var(--fclk-text);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fclk-schedule-hours {
|
||||
color: var(--fclk-text);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Timer Section ---- */
|
||||
.fclk-timer-section {
|
||||
text-align: center;
|
||||
|
||||
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
@@ -0,0 +1,741 @@
|
||||
/** @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);
|
||||
@@ -0,0 +1,77 @@
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_fclk-planner-page: #f3f4f6;
|
||||
$_fclk-planner-panel: #eef1f4;
|
||||
$_fclk-planner-card: #ffffff;
|
||||
$_fclk-planner-text: #1f2937;
|
||||
$_fclk-planner-muted: #6b7280;
|
||||
$_fclk-planner-border: #d8dadd;
|
||||
$_fclk-planner-border-strong: #9ca3af;
|
||||
$_fclk-planner-day: #b7dff5;
|
||||
$_fclk-planner-subhead: #d8e9bd;
|
||||
$_fclk-planner-hours: #f5d39b;
|
||||
$_fclk-planner-fallback: #fff8e5;
|
||||
$_fclk-planner-row-hover: #f9fafb;
|
||||
$_fclk-planner-error: #dc2626;
|
||||
$_fclk-planner-focus: #2563eb;
|
||||
$_fclk-planner-shadow: rgba(15, 23, 42, 0.08);
|
||||
$_fclk-planner-editor: #111827;
|
||||
$_fclk-planner-editor-text: #f9fafb;
|
||||
$_fclk-planner-editor-muted: #cbd5e1;
|
||||
$_fclk-planner-editor-border: #374151;
|
||||
$_fclk-planner-editor-control: #ffffff;
|
||||
$_fclk-planner-editor-control-text: #111827;
|
||||
$_fclk-planner-editor-chip: #1f2937;
|
||||
$_fclk-planner-editor-chip-hover: #334155;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fclk-planner-page: #171a1f !global;
|
||||
$_fclk-planner-panel: #20242b !global;
|
||||
$_fclk-planner-card: #262b33 !global;
|
||||
$_fclk-planner-text: #f3f4f6 !global;
|
||||
$_fclk-planner-muted: #a3aab8 !global;
|
||||
$_fclk-planner-border: #3b424c !global;
|
||||
$_fclk-planner-border-strong: #647082 !global;
|
||||
$_fclk-planner-day: #21465f !global;
|
||||
$_fclk-planner-subhead: #394b2d !global;
|
||||
$_fclk-planner-hours: #6f4f22 !global;
|
||||
$_fclk-planner-fallback: #393326 !global;
|
||||
$_fclk-planner-row-hover: #2b313a !global;
|
||||
$_fclk-planner-error: #f87171 !global;
|
||||
$_fclk-planner-focus: #60a5fa !global;
|
||||
$_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global;
|
||||
$_fclk-planner-editor: #0f172a !global;
|
||||
$_fclk-planner-editor-text: #f9fafb !global;
|
||||
$_fclk-planner-editor-muted: #cbd5e1 !global;
|
||||
$_fclk-planner-editor-border: #475569 !global;
|
||||
$_fclk-planner-editor-control: #1f2937 !global;
|
||||
$_fclk-planner-editor-control-text: #f9fafb !global;
|
||||
$_fclk-planner-editor-chip: #1e293b !global;
|
||||
$_fclk-planner-editor-chip-hover: #334155 !global;
|
||||
}
|
||||
|
||||
:root {
|
||||
--fclk-planner-page: #{$_fclk-planner-page};
|
||||
--fclk-planner-panel: #{$_fclk-planner-panel};
|
||||
--fclk-planner-card: #{$_fclk-planner-card};
|
||||
--fclk-planner-text: #{$_fclk-planner-text};
|
||||
--fclk-planner-muted: #{$_fclk-planner-muted};
|
||||
--fclk-planner-border: #{$_fclk-planner-border};
|
||||
--fclk-planner-border-strong: #{$_fclk-planner-border-strong};
|
||||
--fclk-planner-day: #{$_fclk-planner-day};
|
||||
--fclk-planner-subhead: #{$_fclk-planner-subhead};
|
||||
--fclk-planner-hours: #{$_fclk-planner-hours};
|
||||
--fclk-planner-fallback: #{$_fclk-planner-fallback};
|
||||
--fclk-planner-row-hover: #{$_fclk-planner-row-hover};
|
||||
--fclk-planner-error: #{$_fclk-planner-error};
|
||||
--fclk-planner-focus: #{$_fclk-planner-focus};
|
||||
--fclk-planner-shadow: #{$_fclk-planner-shadow};
|
||||
--fclk-planner-editor: #{$_fclk-planner-editor};
|
||||
--fclk-planner-editor-text: #{$_fclk-planner-editor-text};
|
||||
--fclk-planner-editor-muted: #{$_fclk-planner-editor-muted};
|
||||
--fclk-planner-editor-border: #{$_fclk-planner-editor-border};
|
||||
--fclk-planner-editor-control: #{$_fclk-planner-editor-control};
|
||||
--fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text};
|
||||
--fclk-planner-editor-chip: #{$_fclk-planner-editor-chip};
|
||||
--fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
--fclk-planner-page: #171a1f;
|
||||
--fclk-planner-panel: #20242b;
|
||||
--fclk-planner-card: #262b33;
|
||||
--fclk-planner-text: #f3f4f6;
|
||||
--fclk-planner-muted: #a3aab8;
|
||||
--fclk-planner-border: #3b424c;
|
||||
--fclk-planner-border-strong: #647082;
|
||||
--fclk-planner-day: #21465f;
|
||||
--fclk-planner-subhead: #394b2d;
|
||||
--fclk-planner-hours: #6f4f22;
|
||||
--fclk-planner-fallback: #393326;
|
||||
--fclk-planner-row-hover: #2b313a;
|
||||
--fclk-planner-error: #f87171;
|
||||
--fclk-planner-focus: #60a5fa;
|
||||
--fclk-planner-shadow: rgba(0, 0, 0, 0.32);
|
||||
--fclk-planner-editor: #0f172a;
|
||||
--fclk-planner-editor-text: #f9fafb;
|
||||
--fclk-planner-editor-muted: #cbd5e1;
|
||||
--fclk-planner-editor-border: #475569;
|
||||
--fclk-planner-editor-control: #1f2937;
|
||||
--fclk-planner-editor-control-text: #f9fafb;
|
||||
--fclk-planner-editor-chip: #1e293b;
|
||||
--fclk-planner-editor-chip-hover: #334155;
|
||||
}
|
||||
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
@@ -0,0 +1,447 @@
|
||||
.fclk-planner {
|
||||
min-height: 100%;
|
||||
background: var(--fclk-planner-page, #f3f4f6);
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fclk-planner__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
border-bottom: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||
box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.fclk-planner__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-planner__subtitle {
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
font-size: 13px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.fclk-planner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fclk-planner__warning {
|
||||
margin: 12px 16px 0;
|
||||
padding: 10px 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 6px;
|
||||
color: #9a3412;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-planner__loading {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 340px;
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
}
|
||||
|
||||
.fclk-planner__table-wrap {
|
||||
flex: 1;
|
||||
margin: 16px;
|
||||
overflow: auto;
|
||||
background: var(--fclk-planner-panel, #eef1f4);
|
||||
border: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.fclk-planner__table {
|
||||
--fclk-planner-shift-width: 135px;
|
||||
--fclk-planner-hours-width: 55px;
|
||||
--fclk-planner-days-width: 1330px;
|
||||
width: 100%;
|
||||
min-width: 1600px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-planner__employee-col {
|
||||
width: calc(100% - var(--fclk-planner-days-width));
|
||||
}
|
||||
|
||||
.fclk-planner__shift-col {
|
||||
width: var(--fclk-planner-shift-width);
|
||||
}
|
||||
|
||||
.fclk-planner__hours-col {
|
||||
width: var(--fclk-planner-hours-width);
|
||||
}
|
||||
|
||||
.fclk-planner__table th,
|
||||
.fclk-planner__table td {
|
||||
border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-head,
|
||||
.fclk-planner__day-head,
|
||||
.fclk-planner__sub-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-head {
|
||||
left: 0;
|
||||
z-index: 8;
|
||||
width: calc(100% - var(--fclk-planner-days-width));
|
||||
background: var(--fclk-planner-day, #b7dff5);
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-planner__day-head {
|
||||
background: var(--fclk-planner-day, #b7dff5);
|
||||
text-align: center;
|
||||
padding: 6px 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-planner__sub-head {
|
||||
top: 47px;
|
||||
background: var(--fclk-planner-subhead, #d8e9bd);
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.fclk-planner__hours-head {
|
||||
width: var(--fclk-planner-hours-width);
|
||||
text-align: center;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.fclk-planner__weekday {
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.fclk-planner__date {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.fclk-planner__department-row td {
|
||||
background: var(--fclk-planner-panel, #eef1f4);
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.fclk-planner__department-toggle {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
font-weight: 650;
|
||||
padding: 7px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fclk-planner__department-count {
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__employee-row {
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-row:hover {
|
||||
background: var(--fclk-planner-row-hover, #f9fafb);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-cell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
width: calc(100% - var(--fclk-planner-days-width));
|
||||
background: inherit;
|
||||
padding: 8px 12px;
|
||||
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-name {
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-planner__employee-role {
|
||||
margin-top: 2px;
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell {
|
||||
width: var(--fclk-planner-shift-width);
|
||||
min-height: 42px;
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--fallback {
|
||||
background: var(--fclk-planner-fallback, #fff8e5);
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--error {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--active {
|
||||
box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb);
|
||||
}
|
||||
|
||||
.fclk-planner__shift-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
padding: 4px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fclk-planner__shift-input:focus {
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
border-color: var(--fclk-planner-focus, #2563eb);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.fclk-planner__cell-error {
|
||||
color: var(--fclk-planner-error, #dc2626);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 3px 5px 0;
|
||||
}
|
||||
|
||||
.fclk-planner__hours-cell {
|
||||
width: var(--fclk-planner-hours-width);
|
||||
background: var(--fclk-planner-hours, #f5d39b);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
vertical-align: middle;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.fclk-planner__cell-editor {
|
||||
position: fixed;
|
||||
z-index: 1080;
|
||||
width: calc(100vw - 16px);
|
||||
max-width: 380px;
|
||||
padding: 14px;
|
||||
color: var(--fclk-planner-editor-text, #f9fafb);
|
||||
background: var(--fclk-planner-editor, #111827);
|
||||
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.fclk-planner__cell-editor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 28px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--fclk-planner-editor, #111827);
|
||||
border-left: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-top: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.fclk-planner__editor-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-day {
|
||||
margin-top: 2px;
|
||||
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-hours {
|
||||
min-width: 56px;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
color: #111827;
|
||||
background: var(--fclk-planner-hours, #f5d39b);
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-chip {
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 7px 9px;
|
||||
color: var(--fclk-planner-editor-text, #f9fafb);
|
||||
background: var(--fclk-planner-editor-chip, #1f2937);
|
||||
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-chip:hover,
|
||||
.fclk-planner__quick-chip:focus {
|
||||
background: var(--fclk-planner-editor-chip-hover, #334155);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-label {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-detail {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.fclk-planner__time-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__time-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.fclk-planner__time-field select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: var(--fclk-planner-editor-control-text, #111827);
|
||||
background: var(--fclk-planner-editor-control, #ffffff);
|
||||
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-error {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 10px;
|
||||
padding: 7px 8px;
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fclk-planner__toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fclk-planner__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fclk-planner__table-wrap {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.fclk-planner__cell-editor {
|
||||
width: calc(100vw - 16px);
|
||||
}
|
||||
}
|
||||
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
@@ -0,0 +1,198 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock.ShiftPlanner">
|
||||
<div class="o_action fclk-planner" t-ref="root">
|
||||
<div class="fclk-planner__toolbar">
|
||||
<div>
|
||||
<h2 class="fclk-planner__title">Shift Planner</h2>
|
||||
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
|
||||
</div>
|
||||
<div class="fclk-planner__actions">
|
||||
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-chevron-left"/>
|
||||
</button>
|
||||
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
|
||||
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-chevron-right"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-copy me-1"/> Copy Previous Week
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-file-excel-o me-1"/> Export XLSX
|
||||
</button>
|
||||
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
|
||||
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||
<t t-else=""><i class="fa fa-save me-1"/></t>
|
||||
Save
|
||||
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.invalidCount">
|
||||
<div class="fclk-planner__warning">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-esc="state.invalidCount"/> invalid cells need attention.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="fclk-planner__loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<span>Loading shift planner...</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.loading and !state.error">
|
||||
<div class="fclk-planner__table-wrap">
|
||||
<table class="fclk-planner__table">
|
||||
<colgroup>
|
||||
<col class="fclk-planner__employee-col"/>
|
||||
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
|
||||
<col class="fclk-planner__shift-col"/>
|
||||
<col class="fclk-planner__hours-col"/>
|
||||
</t>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
|
||||
<t t-foreach="state.days" t-as="day" t-key="day.date">
|
||||
<th class="fclk-planner__day-head" colspan="2">
|
||||
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
|
||||
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
<tr>
|
||||
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
|
||||
<th class="fclk-planner__sub-head">Shift</th>
|
||||
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.departments" t-as="department" t-key="department.id">
|
||||
<tr class="fclk-planner__department-row">
|
||||
<td t-att-colspan="1 + state.days.length * 2">
|
||||
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
|
||||
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
|
||||
<span><t t-esc="department.name"/></span>
|
||||
<span class="fclk-planner__department-count">
|
||||
<t t-esc="department.employee_ids.length"/> employees
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<t t-if="!isCollapsed(department)">
|
||||
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
|
||||
<tr class="fclk-planner__employee-row">
|
||||
<td class="fclk-planner__employee-cell">
|
||||
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
|
||||
<div class="fclk-planner__employee-role" t-if="employee.job_title">
|
||||
<t t-esc="employee.job_title"/>
|
||||
</div>
|
||||
</td>
|
||||
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
|
||||
<t t-set="cell" t-value="employee.cells[day.date]"/>
|
||||
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
||||
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
|
||||
<input class="fclk-planner__shift-input"
|
||||
t-att-value="cell.input"
|
||||
t-att-title="cell.error || cell.label"
|
||||
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
|
||||
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
|
||||
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
|
||||
<div class="fclk-planner__cell-error" t-if="cell.error">
|
||||
<t t-esc="cell.error"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fclk-planner__hours-cell">
|
||||
<t t-esc="cell.hours_display || '0:00'"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div t-if="state.editor.open"
|
||||
t-ref="shiftEditor"
|
||||
class="fclk-planner__cell-editor"
|
||||
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
|
||||
<div class="fclk-planner__editor-head">
|
||||
<div class="fclk-planner__editor-person">
|
||||
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
|
||||
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
|
||||
</div>
|
||||
<div class="fclk-planner__editor-hours">
|
||||
<span><t t-esc="state.editor.hoursDisplay"/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__quick-grid">
|
||||
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
|
||||
<button type="button"
|
||||
class="fclk-planner__quick-chip"
|
||||
t-on-click="() => this.selectQuickShift(option)">
|
||||
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
|
||||
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__time-row">
|
||||
<label class="fclk-planner__time-field">
|
||||
<span>Start</span>
|
||||
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
|
||||
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
|
||||
<option t-att-value="option.value"
|
||||
t-att-selected="option.value === state.editor.startValue">
|
||||
<t t-esc="option.label"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fclk-planner__time-field">
|
||||
<span>End</span>
|
||||
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
|
||||
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
|
||||
<option t-att-value="option.value"
|
||||
t-att-selected="option.value === state.editor.endValue">
|
||||
<t t-esc="option.label"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__editor-error" t-if="state.editor.error">
|
||||
<t t-esc="state.editor.error"/>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__editor-actions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-light"
|
||||
t-on-click="() => this.clearActiveCell()">
|
||||
<i class="fa fa-eraser me-1"/> Clear
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
t-on-click="() => this.applyEditorRange(true)">
|
||||
<i class="fa fa-check me-1"/> Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user