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

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

View File

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

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 {
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
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">
<i class="fa fa-calendar-check-o me-1"/> Publish…
</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>
@@ -49,6 +52,35 @@
<button class="btn btn-light btn-sm" t-on-click="() => this.togglePublishPanel()">Cancel</button>
</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">
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
</t>
@@ -254,6 +286,12 @@
t-on-click="() => this.clearRecurrence()">
<i class="fa fa-ban me-1"/> Stop repeat
</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"
class="btn btn-sm btn-primary"
t-on-click="() => this.applyEditorRange(true)">