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:
gsinghpal
2026-06-04 21:12:10 -04:00
parent 68aaa132ee
commit 2ad94070c7
9 changed files with 514 additions and 1 deletions

View File

@@ -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)))

View File

@@ -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():

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)">

View File

@@ -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)

View File

@@ -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>