feat(fusion_clock): schedule-driven attendance automation
Reminders, absence detection, late/early penalties, and auto-clock-out are now driven by each employee's real schedule (posted planner entry -> recurring shift), never the global 9-5 default. Employees who aren't scheduled get no reminders/absence. Overtime past the scheduled end is never cut off — auto clock-out only fires at a max-shift safety cap (default raised 12 -> 16h). Team leads build the planner in draft and Post it (publishes + emails employees). - hr.employee._get_fclk_day_plan: explicit `scheduled` flag; posted-only planner entries (drafts ignored), else recurring shift covering that weekday, else not-scheduled; sources 'schedule'/'shift'/'none'. - fusion.clock.shift: day_mon..day_sun weekday pattern + covers_weekday(). - fusion.clock.schedule: draft/posted state + posted_date; planner edits reset to draft; fclk_email_posted_week notification. - Rewrote the reminder / absence / auto-clock-out crons: schedule-gated, per-employee savepoints, OT-aware cap, weekend hardcode removed. - Penalties + all three clock-in paths skip days the employee isn't scheduled. - shift_planner: Post Week route + planner Post button + draft count. - Migration backfills pre-existing schedule entries to 'posted' so they keep driving automation after upgrade. - Tests: resolver matrix, cron gating, OT cap; fixed the existing planner test for the new state/source semantics. Design: docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md Frontend footprint kept at zero to avoid colliding with the concurrent employee-portal (payslips) work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ export class FusionClockShiftPlanner extends Component {
|
||||
error: "",
|
||||
dirtyCount: 0,
|
||||
invalidCount: 0,
|
||||
draftCount: 0,
|
||||
collapsed: {},
|
||||
editor: {
|
||||
open: false,
|
||||
@@ -89,6 +90,15 @@ export class FusionClockShiftPlanner extends Component {
|
||||
this.state.shifts = data.shifts || [];
|
||||
this.state.dirtyCount = 0;
|
||||
this.state.invalidCount = 0;
|
||||
let draft = 0;
|
||||
for (const emp of this.state.employees) {
|
||||
for (const key in emp.cells || {}) {
|
||||
if (emp.cells[key] && emp.cells[key].state === "draft") {
|
||||
draft += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.draftCount = draft;
|
||||
this.state.error = "";
|
||||
this.closeCellEditor();
|
||||
}
|
||||
@@ -194,6 +204,34 @@ export class FusionClockShiftPlanner extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async postWeek() {
|
||||
if (this.state.dirtyCount) {
|
||||
this.notification.add("Save your changes before posting.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("Post this week's schedule? Employees will be emailed their shifts, and reminders/absence checks will start using it.")) {
|
||||
return;
|
||||
}
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/post_week", {
|
||||
week_start: this.state.weekStart,
|
||||
});
|
||||
if (result.error) {
|
||||
this.notification.add(result.error, { type: "danger" });
|
||||
} else {
|
||||
this._applyData(result.data);
|
||||
this.notification.add(
|
||||
`Posted ${result.posted || 0} shift(s); notified ${result.notified || 0} employee(s).`,
|
||||
{ type: "success" },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not post the schedule.", { type: "danger" });
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
openCellEditor(employee, day, ev) {
|
||||
if (this.state.loading || this.state.saving) {
|
||||
return;
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
Save
|
||||
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
||||
</button>
|
||||
<button class="btn btn-success" t-on-click="() => this.postWeek()" t-att-disabled="state.loading or state.saving or state.dirtyCount" t-att-title="state.dirtyCount ? 'Save your changes before posting' : 'Publish this week and email employees their shifts'">
|
||||
<i class="fa fa-paper-plane me-1"/> Post Schedule
|
||||
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +104,7 @@
|
||||
</td>
|
||||
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
|
||||
<t t-set="cell" t-value="employee.cells[day.date]"/>
|
||||
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
||||
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source !== 'schedule' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
||||
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
|
||||
<input class="fclk-planner__shift-input"
|
||||
t-att-value="cell.input"
|
||||
|
||||
Reference in New Issue
Block a user