diff --git a/fusion_clock/controllers/portal_schedule.py b/fusion_clock/controllers/portal_schedule.py index 8a11529f..1b9eb5b1 100644 --- a/fusion_clock/controllers/portal_schedule.py +++ b/fusion_clock/controllers/portal_schedule.py @@ -9,8 +9,10 @@ import logging from collections import OrderedDict from datetime import timedelta +from urllib.parse import quote from odoo import http, fields +from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) @@ -30,6 +32,7 @@ class FusionClockSchedulePortal(http.Controller): horizon_local = today_local + timedelta(days=60) Schedule = request.env['fusion.clock.schedule'].sudo() + cutoff = employee.company_id.fclk_self_unassign_days_before or 0 entries = [] for sch in Schedule.search([ ('employee_id', '=', employee.id), @@ -54,11 +57,37 @@ class FusionClockSchedulePortal(http.Controller): '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 '', 'note': sch.note or '', + 'schedule_id': sch.id, + 'releasable': (day - today_local).days >= cutoff, }, )) 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() for _key, day, item in entries: delta_days = (day - today_local).days @@ -86,7 +115,40 @@ class FusionClockSchedulePortal(http.Controller): 'groups': groups, 'slot_count': len(entries), 'next_slot': next_slot_data, + 'open_shifts': open_shifts, + 'error': kw.get('err'), + 'success': kw.get('ok'), 'page_name': 'fusion_clock_schedule', 'show_payslips': 'hr.payslip' in request.env, } 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))) diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index c762c70d..03f81618 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -79,9 +79,26 @@ class FusionClockShiftPlanner(http.Controller): ('company_id', 'in', request.env.user.company_ids.ids), ], 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 { 'week_start': str(start), 'week_end': str(days[-1]), + 'open_shifts': open_by_day, 'days': [{ 'date': str(day), 'weekday': day.strftime('%a').upper(), @@ -277,6 +294,49 @@ class FusionClockShiftPlanner(http.Controller): Schedule.fclk_clear_recurrence(schedule) 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']) def export_xlsx(self, week_start=None, **kw): if not self._check_manager(): diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 73dd1069..1460e7ac 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -470,6 +470,68 @@ class FusionClockSchedule(models.Model): rule.unlink() 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 def fclk_email_posted_range(self, employee, start, end, message=None): """Email one employee a summary of their POSTED shifts between two diff --git a/fusion_clock/static/src/css/portal_schedule.css b/fusion_clock/static/src/css/portal_schedule.css index 3e6b32b5..8b0f93fc 100644 --- a/fusion_clock/static/src/css/portal_schedule.css +++ b/fusion_clock/static/src/css/portal_schedule.css @@ -103,6 +103,53 @@ 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 ---- */ .fclk-container { padding-bottom: 80px; diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index 6ae2f330..9c33e1c2 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -50,6 +50,8 @@ export class FusionClockShiftPlanner extends Component { repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 }, }, publish: { open: false, from: "", to: "", message: "" }, + openShifts: {}, + openShift: { open: false, date: "", start: "09:00", end: "17:00", count: 1 }, }); onWillStart(async () => { @@ -92,6 +94,7 @@ export class FusionClockShiftPlanner extends Component { this.state.departments = data.departments || []; this.state.employees = data.employees || []; this.state.shifts = data.shifts || []; + this.state.openShifts = data.open_shifts || {}; this.state.dirtyCount = 0; this.state.invalidCount = 0; let draft = 0; @@ -368,6 +371,106 @@ export class FusionClockShiftPlanner extends Component { 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() { this.state.editor.open = false; this.activeCellAnchor = null; diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss index 230eebd0..064769f6 100644 --- a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss +++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss @@ -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 { border-top: 1px solid var(--fclk-planner-border, #d8dadd); margin-top: 6px; diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml index 7b10e33f..a7f50d01 100644 --- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -35,6 +35,9 @@ + @@ -49,6 +52,35 @@ +
+ + + + + + +
+ +
+
Open Shifts (employees can claim)
+
+ +
+
+ +
+ + + +
+
+
+
+
+
+
@@ -254,6 +286,12 @@ t-on-click="() => this.clearRecurrence()"> Stop repeat + + + + + + + +
@@ -108,6 +143,12 @@
h +
+ + + +