feat(fusion_clock): open shifts + self-assign + bulk apply [B4-B5]
Model: fclk_create_open_shifts/claim_open_shift/release_shift (days-before cutoff + role eligibility)/bulk_apply. Planner: Open Shift… panel, open-shifts strip with delete, Apply-to-dept; load includes open shifts. Portal: claim open shifts + release own upcoming shifts with feedback banners. Tests for claim/role-gate/release/bulk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from odoo import http, fields
|
from odoo import http, fields
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -30,6 +32,7 @@ class FusionClockSchedulePortal(http.Controller):
|
|||||||
horizon_local = today_local + timedelta(days=60)
|
horizon_local = today_local + timedelta(days=60)
|
||||||
|
|
||||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
cutoff = employee.company_id.fclk_self_unassign_days_before or 0
|
||||||
entries = []
|
entries = []
|
||||||
for sch in Schedule.search([
|
for sch in Schedule.search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
@@ -54,11 +57,37 @@ class FusionClockSchedulePortal(http.Controller):
|
|||||||
'role_name': sch.role_id.name if sch.role_id else '',
|
'role_name': sch.role_id.name if sch.role_id else '',
|
||||||
'role_color': sch.role_id._get_color_from_code() if sch.role_id else '',
|
'role_color': sch.role_id._get_color_from_code() if sch.role_id else '',
|
||||||
'note': sch.note or '',
|
'note': sch.note or '',
|
||||||
|
'schedule_id': sch.id,
|
||||||
|
'releasable': (day - today_local).days >= cutoff,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
entries.sort(key=lambda e: e[0])
|
entries.sort(key=lambda e: e[0])
|
||||||
|
|
||||||
|
# Open shifts the employee may claim: company-scoped, future, and either
|
||||||
|
# role-eligible (allowed-role list contains the shift role) or roleless.
|
||||||
|
open_shifts = []
|
||||||
|
for row in Schedule.search([
|
||||||
|
('is_open', '=', True),
|
||||||
|
('state', '=', 'posted'),
|
||||||
|
('company_id', '=', employee.company_id.id),
|
||||||
|
('schedule_date', '>=', today_local),
|
||||||
|
('schedule_date', '<=', horizon_local),
|
||||||
|
], order='schedule_date asc, start_time asc', limit=100):
|
||||||
|
if row.role_id and employee.x_fclk_role_ids and row.role_id not in employee.x_fclk_role_ids:
|
||||||
|
continue
|
||||||
|
d = row.schedule_date
|
||||||
|
open_shifts.append({
|
||||||
|
'id': row.id,
|
||||||
|
'date_full': d.strftime('%a, %b %d'),
|
||||||
|
'time_range': '%s - %s' % (
|
||||||
|
Schedule.fclk_float_to_display(row.start_time),
|
||||||
|
Schedule.fclk_float_to_display(row.end_time),
|
||||||
|
),
|
||||||
|
'role_name': row.role_id.name if row.role_id else '',
|
||||||
|
'duration_hours': round(row.planned_hours or 0.0, 1),
|
||||||
|
})
|
||||||
|
|
||||||
groups = OrderedDict()
|
groups = OrderedDict()
|
||||||
for _key, day, item in entries:
|
for _key, day, item in entries:
|
||||||
delta_days = (day - today_local).days
|
delta_days = (day - today_local).days
|
||||||
@@ -86,7 +115,40 @@ class FusionClockSchedulePortal(http.Controller):
|
|||||||
'groups': groups,
|
'groups': groups,
|
||||||
'slot_count': len(entries),
|
'slot_count': len(entries),
|
||||||
'next_slot': next_slot_data,
|
'next_slot': next_slot_data,
|
||||||
|
'open_shifts': open_shifts,
|
||||||
|
'error': kw.get('err'),
|
||||||
|
'success': kw.get('ok'),
|
||||||
'page_name': 'fusion_clock_schedule',
|
'page_name': 'fusion_clock_schedule',
|
||||||
'show_payslips': 'hr.payslip' in request.env,
|
'show_payslips': 'hr.payslip' in request.env,
|
||||||
}
|
}
|
||||||
return request.render('fusion_clock.portal_schedule_page', values)
|
return request.render('fusion_clock.portal_schedule_page', values)
|
||||||
|
|
||||||
|
@http.route('/my/clock/schedule/claim', type='http', auth='user',
|
||||||
|
methods=['POST'], website=True)
|
||||||
|
def claim_open_shift(self, schedule_id=None, **kw):
|
||||||
|
employee = request.env.user.employee_id
|
||||||
|
if not employee or not schedule_id:
|
||||||
|
return request.redirect('/my/clock/schedule')
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
sch = Schedule.browse(int(schedule_id))
|
||||||
|
try:
|
||||||
|
Schedule.fclk_claim_open_shift(sch, employee)
|
||||||
|
return request.redirect('/my/clock/schedule?ok=claimed')
|
||||||
|
except ValidationError as exc:
|
||||||
|
return request.redirect(
|
||||||
|
'/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc)))
|
||||||
|
|
||||||
|
@http.route('/my/clock/schedule/release', type='http', auth='user',
|
||||||
|
methods=['POST'], website=True)
|
||||||
|
def release_shift(self, schedule_id=None, **kw):
|
||||||
|
employee = request.env.user.employee_id
|
||||||
|
if not employee or not schedule_id:
|
||||||
|
return request.redirect('/my/clock/schedule')
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
sch = Schedule.browse(int(schedule_id))
|
||||||
|
try:
|
||||||
|
Schedule.fclk_release_shift(sch, employee)
|
||||||
|
return request.redirect('/my/clock/schedule?ok=released')
|
||||||
|
except ValidationError as exc:
|
||||||
|
return request.redirect(
|
||||||
|
'/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc)))
|
||||||
|
|||||||
@@ -79,9 +79,26 @@ class FusionClockShiftPlanner(http.Controller):
|
|||||||
('company_id', 'in', request.env.user.company_ids.ids),
|
('company_id', 'in', request.env.user.company_ids.ids),
|
||||||
], order='sequence, name')
|
], order='sequence, name')
|
||||||
|
|
||||||
|
open_rows = Schedule.search([
|
||||||
|
('is_open', '=', True),
|
||||||
|
('company_id', 'in', request.env.user.company_ids.ids),
|
||||||
|
('schedule_date', '>=', start),
|
||||||
|
('schedule_date', '<=', days[-1]),
|
||||||
|
], order='schedule_date, start_time')
|
||||||
|
open_by_day = {}
|
||||||
|
for row in open_rows:
|
||||||
|
open_by_day.setdefault(str(row.schedule_date), []).append({
|
||||||
|
'id': row.id,
|
||||||
|
'label': row.fclk_display_value(),
|
||||||
|
'role_name': row.role_id.name or '',
|
||||||
|
'role_color': row.role_id._get_color_from_code(True) if row.role_id else '',
|
||||||
|
'hours_display': Schedule.fclk_hours_display(row.planned_hours),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'week_start': str(start),
|
'week_start': str(start),
|
||||||
'week_end': str(days[-1]),
|
'week_end': str(days[-1]),
|
||||||
|
'open_shifts': open_by_day,
|
||||||
'days': [{
|
'days': [{
|
||||||
'date': str(day),
|
'date': str(day),
|
||||||
'weekday': day.strftime('%a').upper(),
|
'weekday': day.strftime('%a').upper(),
|
||||||
@@ -277,6 +294,49 @@ class FusionClockShiftPlanner(http.Controller):
|
|||||||
Schedule.fclk_clear_recurrence(schedule)
|
Schedule.fclk_clear_recurrence(schedule)
|
||||||
return {'success': True, 'data': self._load_week_data(week_start)}
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/create_open_shift', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def create_open_shift(self, date=None, start_time=None, end_time=None, role_id=None,
|
||||||
|
count=1, break_minutes=0.0, week_start=None, **kw):
|
||||||
|
"""Create one or more open (unassignable) shifts for a day."""
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
company = request.env.company
|
||||||
|
try:
|
||||||
|
Schedule.fclk_create_open_shifts(
|
||||||
|
company, date, start_time, end_time,
|
||||||
|
role_id=role_id, count=count, break_minutes=break_minutes)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
|
||||||
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/delete_open_shift', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def delete_open_shift(self, schedule_id=None, week_start=None, **kw):
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
row = Schedule.browse(int(schedule_id or 0))
|
||||||
|
if row.exists() and row.is_open:
|
||||||
|
row.unlink()
|
||||||
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def bulk_apply(self, employee_ids=None, date=None, payload=None, week_start=None, **kw):
|
||||||
|
"""Apply one shift to several employees at once (Apply Also To)."""
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
employees = self._manager_employees()
|
||||||
|
wanted = {int(eid) for eid in (employee_ids or [])}
|
||||||
|
employees = employees.filtered(lambda e: e.id in wanted)
|
||||||
|
if not employees:
|
||||||
|
return {'success': False, 'message': 'Pick at least one employee.'}
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
try:
|
||||||
|
Schedule.fclk_bulk_apply(employees, date, payload or {}, request.env.user)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
|
||||||
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def export_xlsx(self, week_start=None, **kw):
|
def export_xlsx(self, week_start=None, **kw):
|
||||||
if not self._check_manager():
|
if not self._check_manager():
|
||||||
|
|||||||
@@ -470,6 +470,68 @@ class FusionClockSchedule(models.Model):
|
|||||||
rule.unlink()
|
rule.unlink()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ----- Open shifts + bulk apply (native "Apply Also To" / self-assign) -----
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_create_open_shifts(self, company, date_obj, start, end,
|
||||||
|
role_id=False, count=1, break_minutes=0.0, note=None):
|
||||||
|
"""Create N open (unassigned) shifts for a day, available to claim."""
|
||||||
|
date_obj = fields.Date.to_date(date_obj)
|
||||||
|
if not date_obj:
|
||||||
|
raise ValidationError(_("Pick a date for the open shift."))
|
||||||
|
company_id = (company.id if company else False) or self.env.company.id
|
||||||
|
vals_list = [{
|
||||||
|
'is_open': True,
|
||||||
|
'schedule_date': date_obj,
|
||||||
|
'start_time': float(start or 0.0),
|
||||||
|
'end_time': float(end or 0.0),
|
||||||
|
'break_minutes': float(break_minutes or 0.0),
|
||||||
|
'role_id': int(role_id) if role_id else False,
|
||||||
|
'company_id': company_id,
|
||||||
|
'note': note or False,
|
||||||
|
'state': 'posted',
|
||||||
|
} for _i in range(max(1, int(count or 1)))]
|
||||||
|
return self.sudo().create(vals_list)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_claim_open_shift(self, schedule, employee):
|
||||||
|
"""Assign an open shift to an employee (portal self-assign)."""
|
||||||
|
schedule = schedule.sudo()
|
||||||
|
employee = employee.sudo()
|
||||||
|
if not schedule or not schedule.is_open:
|
||||||
|
raise ValidationError(_("This shift is no longer available."))
|
||||||
|
if not employee:
|
||||||
|
raise ValidationError(_("No employee to assign this shift to."))
|
||||||
|
# If the shift carries a role and the employee has an explicit allowed
|
||||||
|
# list, enforce eligibility (no list = eligible for anything).
|
||||||
|
if schedule.role_id and employee.x_fclk_role_ids \
|
||||||
|
and schedule.role_id not in employee.x_fclk_role_ids:
|
||||||
|
raise ValidationError(_("You are not eligible for this shift's role."))
|
||||||
|
schedule.write({'employee_id': employee.id, 'is_open': False})
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_release_shift(self, schedule, employee):
|
||||||
|
"""Release a claimed shift back to the open pool (portal self-unassign),
|
||||||
|
respecting the company's days-before cutoff."""
|
||||||
|
schedule = schedule.sudo()
|
||||||
|
if not schedule or schedule.employee_id != employee.sudo():
|
||||||
|
raise ValidationError(_("You can only release your own shift."))
|
||||||
|
cutoff = schedule.company_id.fclk_self_unassign_days_before or 0
|
||||||
|
if (schedule.schedule_date - fields.Date.today()).days < cutoff:
|
||||||
|
raise ValidationError(_("It is too late to release this shift."))
|
||||||
|
schedule.write({'employee_id': False, 'is_open': True})
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_bulk_apply(self, employees, date_obj, payload, user=None):
|
||||||
|
"""Apply the same shift payload to several employees in one go
|
||||||
|
(native replacement for Planning's 'Apply Also To')."""
|
||||||
|
results = self.browse()
|
||||||
|
for employee in employees:
|
||||||
|
results |= self.fclk_apply_planner_cell(employee, date_obj, dict(payload or {}), user)
|
||||||
|
return results
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def fclk_email_posted_range(self, employee, start, end, message=None):
|
def fclk_email_posted_range(self, employee, start, end, message=None):
|
||||||
"""Email one employee a summary of their POSTED shifts between two
|
"""Email one employee a summary of their POSTED shifts between two
|
||||||
|
|||||||
@@ -103,6 +103,53 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Claim / release feedback + open shifts ---- */
|
||||||
|
.fpl-flash {
|
||||||
|
margin: 0 16px 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fpl-flash-err {
|
||||||
|
background: rgba(239, 68, 68, 0.10);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.30);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fpl-flash-ok {
|
||||||
|
background: rgba(16, 185, 129, 0.10);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||||
|
color: var(--fclk-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fpl-open-item {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fpl-claim-form,
|
||||||
|
.fpl-release-form {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fpl-release-btn {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fpl-release-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Bottom padding so nav doesn't cover last shift ---- */
|
/* ---- Bottom padding so nav doesn't cover last shift ---- */
|
||||||
.fclk-container {
|
.fclk-container {
|
||||||
padding-bottom: 80px;
|
padding-bottom: 80px;
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 },
|
repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 },
|
||||||
},
|
},
|
||||||
publish: { open: false, from: "", to: "", message: "" },
|
publish: { open: false, from: "", to: "", message: "" },
|
||||||
|
openShifts: {},
|
||||||
|
openShift: { open: false, date: "", start: "09:00", end: "17:00", count: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
@@ -92,6 +94,7 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
this.state.departments = data.departments || [];
|
this.state.departments = data.departments || [];
|
||||||
this.state.employees = data.employees || [];
|
this.state.employees = data.employees || [];
|
||||||
this.state.shifts = data.shifts || [];
|
this.state.shifts = data.shifts || [];
|
||||||
|
this.state.openShifts = data.open_shifts || {};
|
||||||
this.state.dirtyCount = 0;
|
this.state.dirtyCount = 0;
|
||||||
this.state.invalidCount = 0;
|
this.state.invalidCount = 0;
|
||||||
let draft = 0;
|
let draft = 0;
|
||||||
@@ -368,6 +371,106 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
this.state.saving = false;
|
this.state.saving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_timeStrToFloat(str) {
|
||||||
|
const [h, m] = (str || "0:0").split(":").map(Number);
|
||||||
|
return (h || 0) + (m || 0) / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenShiftsForDay(date) {
|
||||||
|
return this.state.openShifts[date] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasOpenShifts() {
|
||||||
|
return Object.keys(this.state.openShifts || {}).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleOpenShiftPanel() {
|
||||||
|
this.state.openShift.open = !this.state.openShift.open;
|
||||||
|
if (this.state.openShift.open && !this.state.openShift.date) {
|
||||||
|
this.state.openShift.date = this.state.weekStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenShiftField(field, ev) {
|
||||||
|
this.state.openShift[field] = ev.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOpenShift() {
|
||||||
|
const os = this.state.openShift;
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/create_open_shift", {
|
||||||
|
date: os.date || this.state.weekStart,
|
||||||
|
start_time: this._timeStrToFloat(os.start),
|
||||||
|
end_time: this._timeStrToFloat(os.end),
|
||||||
|
count: Number(os.count) || 1,
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
});
|
||||||
|
if (result.error || result.success === false) {
|
||||||
|
this.notification.add(result.error || result.message || "Could not add open shift.", {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.state.openShift.open = false;
|
||||||
|
this.notification.add("Open shift added.", { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not add open shift.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpenShift(id) {
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/delete_open_shift", {
|
||||||
|
schedule_id: id,
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
});
|
||||||
|
if (!result.error) {
|
||||||
|
this._applyData(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not remove open shift.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkApplyDept() {
|
||||||
|
const editor = this.state.editor;
|
||||||
|
const employee = this.state.employees.find((e) => e.id === editor.employeeId);
|
||||||
|
if (!employee) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const department = this.state.departments.find((d) => d.id === employee.department_id);
|
||||||
|
const ids = (department && department.employee_ids) || [employee.id];
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/bulk_apply", {
|
||||||
|
employee_ids: ids,
|
||||||
|
date: editor.date,
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
payload: {
|
||||||
|
start_time: Number(editor.startValue),
|
||||||
|
end_time: Number(editor.endValue),
|
||||||
|
break_minutes: editor.breakMinutes || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.error || result.success === false) {
|
||||||
|
this.notification.add(result.error || result.message || "Could not apply.", {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.notification.add(`Applied to ${ids.length} employee(s).`, { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not apply.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
closeCellEditor() {
|
closeCellEditor() {
|
||||||
this.state.editor.open = false;
|
this.state.editor.open = false;
|
||||||
this.activeCellAnchor = null;
|
this.activeCellAnchor = null;
|
||||||
|
|||||||
@@ -256,6 +256,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-strip {
|
||||||
|
margin: 0 10px 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
border: 1px dashed var(--fclk-planner-border, #d8dadd);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.fclk-planner__open-strip-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-cols {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-col {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-day {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: var(--fclk-planner-fallback, #fff8e5);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-role {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-del {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__open-del:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fclk-planner__repeat-panel {
|
.fclk-planner__repeat-panel {
|
||||||
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
|
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
<button class="btn btn-outline-success" t-on-click="() => this.togglePublishPanel()" t-att-disabled="state.loading or state.saving" title="Publish a custom date range and notify employees">
|
<button class="btn btn-outline-success" t-on-click="() => this.togglePublishPanel()" t-att-disabled="state.loading or state.saving" title="Publish a custom date range and notify employees">
|
||||||
<i class="fa fa-calendar-check-o me-1"/> Publish…
|
<i class="fa fa-calendar-check-o me-1"/> Publish…
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" t-on-click="() => this.toggleOpenShiftPanel()" t-att-disabled="state.loading or state.saving" title="Create an open shift employees can claim">
|
||||||
|
<i class="fa fa-plus me-1"/> Open Shift…
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +52,35 @@
|
|||||||
<button class="btn btn-light btn-sm" t-on-click="() => this.togglePublishPanel()">Cancel</button>
|
<button class="btn btn-light btn-sm" t-on-click="() => this.togglePublishPanel()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.openShift.open" class="fclk-planner__publish-panel">
|
||||||
|
<label>Date <input type="date" t-att-value="state.openShift.date" t-on-change="(ev) => this.onOpenShiftField('date', ev)"/></label>
|
||||||
|
<label>Start <input type="time" t-att-value="state.openShift.start" t-on-change="(ev) => this.onOpenShiftField('start', ev)"/></label>
|
||||||
|
<label>End <input type="time" t-att-value="state.openShift.end" t-on-change="(ev) => this.onOpenShiftField('end', ev)"/></label>
|
||||||
|
<label>Count <input type="number" min="1" class="fclk-planner__repeat-int" t-att-value="state.openShift.count" t-on-change="(ev) => this.onOpenShiftField('count', ev)"/></label>
|
||||||
|
<button class="btn btn-secondary btn-sm" t-on-click="() => this.addOpenShift()" t-att-disabled="state.saving">
|
||||||
|
<i class="fa fa-plus me-1"/> Add Open Shift
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-light btn-sm" t-on-click="() => this.toggleOpenShiftPanel()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="hasOpenShifts" class="fclk-planner__open-strip">
|
||||||
|
<div class="fclk-planner__open-strip-title"><i class="fa fa-bullhorn me-1"/> Open Shifts (employees can claim)</div>
|
||||||
|
<div class="fclk-planner__open-cols">
|
||||||
|
<t t-foreach="state.days" t-as="day" t-key="'open_' + day.date">
|
||||||
|
<div class="fclk-planner__open-col" t-if="getOpenShiftsForDay(day.date).length">
|
||||||
|
<div class="fclk-planner__open-day"><t t-esc="day.weekday"/> <t t-esc="day.label"/></div>
|
||||||
|
<t t-foreach="getOpenShiftsForDay(day.date)" t-as="op" t-key="op.id">
|
||||||
|
<div class="fclk-planner__open-chip">
|
||||||
|
<span><t t-esc="op.label"/></span>
|
||||||
|
<span t-if="op.role_name" class="fclk-planner__open-role"><t t-esc="op.role_name"/></span>
|
||||||
|
<button class="fclk-planner__open-del" t-on-click="() => this.deleteOpenShift(op.id)" title="Remove open shift">×</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<t t-if="state.error">
|
<t t-if="state.error">
|
||||||
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
|
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
|
||||||
</t>
|
</t>
|
||||||
@@ -254,6 +286,12 @@
|
|||||||
t-on-click="() => this.clearRecurrence()">
|
t-on-click="() => this.clearRecurrence()">
|
||||||
<i class="fa fa-ban me-1"/> Stop repeat
|
<i class="fa fa-ban me-1"/> Stop repeat
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-light"
|
||||||
|
t-on-click="() => this.bulkApplyDept()"
|
||||||
|
title="Apply this shift to everyone in the same department">
|
||||||
|
<i class="fa fa-users me-1"/> Apply to dept
|
||||||
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
t-on-click="() => this.applyEditorRange(true)">
|
t-on-click="() => this.applyEditorRange(true)">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
from odoo.tests import tagged
|
from odoo.tests import tagged
|
||||||
@@ -44,3 +44,41 @@ class TestOpenShift(TransactionCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.S.search_count([('employee_id', '=', emp.id), ('schedule_date', '=', d)]), 2,
|
self.S.search_count([('employee_id', '=', emp.id), ('schedule_date', '=', d)]), 2,
|
||||||
"the hard one-shift-per-day uniqueness is gone")
|
"the hard one-shift-per-day uniqueness is gone")
|
||||||
|
|
||||||
|
def test_claim_open_shift_assigns_to_employee(self):
|
||||||
|
emp = self.env['hr.employee'].create({'name': 'Claimer'})
|
||||||
|
op = self.S.create({
|
||||||
|
'is_open': True, 'schedule_date': date(2026, 6, 10),
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
|
||||||
|
self.S.fclk_claim_open_shift(op, emp)
|
||||||
|
self.assertFalse(op.is_open)
|
||||||
|
self.assertEqual(op.employee_id, emp)
|
||||||
|
|
||||||
|
def test_claim_enforces_role_eligibility(self):
|
||||||
|
forklift = self.env['fusion.clock.role'].create({'name': 'Forklift', 'color': 2})
|
||||||
|
desk = self.env['fusion.clock.role'].create({'name': 'Desk', 'color': 3})
|
||||||
|
emp = self.env['hr.employee'].create({
|
||||||
|
'name': 'Picky', 'x_fclk_role_ids': [(6, 0, [desk.id])]})
|
||||||
|
op = self.S.create({
|
||||||
|
'is_open': True, 'schedule_date': date(2026, 6, 10),
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'role_id': forklift.id, 'state': 'posted'})
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.S.fclk_claim_open_shift(op, emp)
|
||||||
|
|
||||||
|
def test_release_returns_shift_to_open_pool(self):
|
||||||
|
emp = self.env['hr.employee'].create({'name': 'Releaser'})
|
||||||
|
future = date.today() + timedelta(days=30)
|
||||||
|
sch = self.S.create({
|
||||||
|
'employee_id': emp.id, 'schedule_date': future,
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
|
||||||
|
self.S.fclk_release_shift(sch, emp)
|
||||||
|
self.assertTrue(sch.is_open)
|
||||||
|
self.assertFalse(sch.employee_id)
|
||||||
|
|
||||||
|
def test_bulk_apply_to_many_employees(self):
|
||||||
|
e1 = self.env['hr.employee'].create({'name': 'Bulk A'})
|
||||||
|
e2 = self.env['hr.employee'].create({'name': 'Bulk B'})
|
||||||
|
d = date(2026, 6, 10)
|
||||||
|
self.S.fclk_bulk_apply(e1 + e2, d, {'start_time': 8.0, 'end_time': 16.0, 'break_minutes': 0.0})
|
||||||
|
self.assertEqual(
|
||||||
|
self.S.search_count([('schedule_date', '=', d), ('employee_id', 'in', (e1 + e2).ids)]), 2)
|
||||||
|
|||||||
@@ -16,6 +16,41 @@
|
|||||||
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claim / release feedback -->
|
||||||
|
<div class="fpl-flash fpl-flash-err" t-if="error">
|
||||||
|
<t t-esc="error"/>
|
||||||
|
</div>
|
||||||
|
<div class="fpl-flash fpl-flash-ok" t-if="success">
|
||||||
|
<t t-if="success == 'claimed'">Shift claimed — it's now on your schedule.</t>
|
||||||
|
<t t-elif="success == 'released'">Shift released back to the open pool.</t>
|
||||||
|
<t t-else="">Done.</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open shifts available to claim -->
|
||||||
|
<t t-if="open_shifts">
|
||||||
|
<div class="fpl-group">
|
||||||
|
<div class="fpl-group-title">Open Shifts — Available to Claim</div>
|
||||||
|
<div class="fpl-list">
|
||||||
|
<t t-foreach="open_shifts" t-as="op">
|
||||||
|
<div class="fclk-recent-item fpl-open-item">
|
||||||
|
<div class="fclk-recent-info">
|
||||||
|
<div class="fclk-recent-location">
|
||||||
|
<t t-esc="op['date_full']"/>
|
||||||
|
<t t-if="op['role_name']"> · <t t-esc="op['role_name']"/></t>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-recent-times"><t t-esc="op['time_range']"/></div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/my/clock/schedule/claim" class="fpl-claim-form">
|
||||||
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||||
|
<input type="hidden" name="schedule_id" t-att-value="op['id']"/>
|
||||||
|
<button type="submit" class="btn btn-sm btn-success">Claim</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
<!-- Next Shift Card (if any upcoming) -->
|
<!-- Next Shift Card (if any upcoming) -->
|
||||||
<t t-if="next_slot">
|
<t t-if="next_slot">
|
||||||
<div class="fclk-status-card fpl-next-shift">
|
<div class="fclk-status-card fpl-next-shift">
|
||||||
@@ -108,6 +143,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="fclk-recent-hours">
|
<div class="fclk-recent-hours">
|
||||||
<t t-esc="'%.1f' % item['duration_hours']"/>h
|
<t t-esc="'%.1f' % item['duration_hours']"/>h
|
||||||
|
<form t-if="item.get('releasable')" method="post"
|
||||||
|
action="/my/clock/schedule/release" class="fpl-release-form">
|
||||||
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||||
|
<input type="hidden" name="schedule_id" t-att-value="item['schedule_id']"/>
|
||||||
|
<button type="submit" class="fpl-release-btn" title="Release this shift">Release</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
Reference in New Issue
Block a user