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:
gsinghpal
2026-05-30 21:54:05 -04:00
parent b5d5a9acba
commit 2aaa1a57e7
18 changed files with 557 additions and 160 deletions

View File

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

View File

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