feat(fusion_clock): native recurring shifts engine [A4-A5]
fusion.clock.schedule.recurrence (repeat every N day/week/month/year; forever/until/N-times) re-fit from planning.recurrency onto per-day rows; daily generation cron; _fclk_on_leave skip; planner Repeat…/Stop-repeat UI + endpoints; recurrence + role indicators on cells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,9 @@ export class FusionClockShiftPlanner extends Component {
|
||||
error: "",
|
||||
top: 0,
|
||||
left: 0,
|
||||
recurring: false,
|
||||
showRepeat: false,
|
||||
repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,9 +261,72 @@ export class FusionClockShiftPlanner extends Component {
|
||||
this.state.editor.breakMinutes = breakMinutes;
|
||||
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
|
||||
this.state.editor.error = cell.error || "";
|
||||
this.state.editor.recurring = !!cell.recurring;
|
||||
this.state.editor.showRepeat = false;
|
||||
this._positionActiveEditor(anchor);
|
||||
}
|
||||
|
||||
toggleRepeatPanel() {
|
||||
this.state.editor.showRepeat = !this.state.editor.showRepeat;
|
||||
}
|
||||
|
||||
onRepeatField(field, ev) {
|
||||
const value = ev.target.value;
|
||||
this.state.editor.repeat[field] =
|
||||
field === "interval" || field === "number" ? Number(value) : value;
|
||||
}
|
||||
|
||||
async setRecurrence() {
|
||||
const editor = this.state.editor;
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/set_recurrence", {
|
||||
employee_id: editor.employeeId,
|
||||
date: editor.date,
|
||||
week_start: this.state.weekStart,
|
||||
repeat: {
|
||||
repeat_interval: editor.repeat.interval,
|
||||
repeat_unit: editor.repeat.unit,
|
||||
repeat_type: editor.repeat.type,
|
||||
repeat_until: editor.repeat.until || false,
|
||||
repeat_number: editor.repeat.number,
|
||||
},
|
||||
});
|
||||
if (result.error || result.success === false) {
|
||||
this.notification.add(result.error || result.message || "Could not repeat shift.", {
|
||||
type: "danger",
|
||||
});
|
||||
} else {
|
||||
this._applyData(result.data);
|
||||
this.notification.add("Recurring shift created.", { type: "success" });
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not repeat shift.", { type: "danger" });
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
async clearRecurrence() {
|
||||
const editor = this.state.editor;
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/clear_recurrence", {
|
||||
employee_id: editor.employeeId,
|
||||
date: editor.date,
|
||||
week_start: this.state.weekStart,
|
||||
});
|
||||
if (result.error) {
|
||||
this.notification.add(result.error, { type: "danger" });
|
||||
} else {
|
||||
this._applyData(result.data);
|
||||
this.notification.add("Recurrence stopped.", { type: "success" });
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not stop recurrence.", { type: "danger" });
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
closeCellEditor() {
|
||||
this.state.editor.open = false;
|
||||
this.activeCellAnchor = null;
|
||||
|
||||
@@ -217,6 +217,52 @@
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fclk-planner__cell-recur {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
font-size: 9px;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fclk-planner__cell-role {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fclk-planner__repeat-panel {
|
||||
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.fclk-planner__repeat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
select,
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-planner__repeat-int {
|
||||
max-width: 64px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--fallback {
|
||||
|
||||
@@ -115,6 +115,13 @@
|
||||
<div class="fclk-planner__cell-error" t-if="cell.error">
|
||||
<t t-esc="cell.error"/>
|
||||
</div>
|
||||
<span class="fclk-planner__cell-recur" t-if="cell.recurring"
|
||||
title="Recurring shift">
|
||||
<i class="fa fa-repeat"/>
|
||||
</span>
|
||||
<span class="fclk-planner__cell-role" t-if="cell.role_color"
|
||||
t-att-style="'background-color: ' + cell.role_color + ';'"
|
||||
t-att-title="cell.role_name"/>
|
||||
</td>
|
||||
<td class="fclk-planner__hours-cell">
|
||||
<t t-esc="cell.hours_display || '0:00'"/>
|
||||
@@ -182,12 +189,57 @@
|
||||
<t t-esc="state.editor.error"/>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__repeat-panel" t-if="state.editor.showRepeat">
|
||||
<div class="fclk-planner__repeat-row">
|
||||
<span>Every</span>
|
||||
<input type="number" min="1" class="fclk-planner__repeat-int"
|
||||
t-att-value="state.editor.repeat.interval"
|
||||
t-on-change="(ev) => this.onRepeatField('interval', ev)"/>
|
||||
<select t-on-change="(ev) => this.onRepeatField('unit', ev)">
|
||||
<option value="day" t-att-selected="state.editor.repeat.unit === 'day'">day(s)</option>
|
||||
<option value="week" t-att-selected="state.editor.repeat.unit === 'week'">week(s)</option>
|
||||
<option value="month" t-att-selected="state.editor.repeat.unit === 'month'">month(s)</option>
|
||||
<option value="year" t-att-selected="state.editor.repeat.unit === 'year'">year(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fclk-planner__repeat-row">
|
||||
<select t-on-change="(ev) => this.onRepeatField('type', ev)">
|
||||
<option value="forever" t-att-selected="state.editor.repeat.type === 'forever'">Forever</option>
|
||||
<option value="until" t-att-selected="state.editor.repeat.type === 'until'">Until date</option>
|
||||
<option value="x_times" t-att-selected="state.editor.repeat.type === 'x_times'"># of times</option>
|
||||
</select>
|
||||
<input type="date" t-if="state.editor.repeat.type === 'until'"
|
||||
t-att-value="state.editor.repeat.until"
|
||||
t-on-change="(ev) => this.onRepeatField('until', ev)"/>
|
||||
<input type="number" min="1" t-if="state.editor.repeat.type === 'x_times'"
|
||||
class="fclk-planner__repeat-int"
|
||||
t-att-value="state.editor.repeat.number"
|
||||
t-on-change="(ev) => this.onRepeatField('number', ev)"/>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary w-100"
|
||||
t-on-click="() => this.setRecurrence()">
|
||||
<i class="fa fa-check me-1"/> Apply recurrence
|
||||
</button>
|
||||
</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"
|
||||
t-if="!state.editor.recurring"
|
||||
class="btn btn-sm btn-light"
|
||||
t-on-click="() => this.toggleRepeatPanel()">
|
||||
<i class="fa fa-repeat me-1"/> Repeat…
|
||||
</button>
|
||||
<button type="button"
|
||||
t-if="state.editor.recurring"
|
||||
class="btn btn-sm btn-warning"
|
||||
t-on-click="() => this.clearRecurrence()">
|
||||
<i class="fa fa-ban me-1"/> Stop repeat
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
t-on-click="() => this.applyEditorRange(true)">
|
||||
|
||||
Reference in New Issue
Block a user