` (respect days-before).
+- [ ] Template: show open shifts + Claim button; show Unassign on own upcoming shifts when allowed. Commit.
+
+---
+
+## PHASE C — Manifest, verify, deploy
+
+### Task C1: manifest + config settings UI
+- [ ] `__manifest__.py` → version `19.0.5.0.0`; add data files (`clock_role_views.xml`, `clock_recurrence_views.xml`, `clock_recurrence_cron.xml`, `portal_schedule_templates.xml`), asset `portal_schedule.css`; mail template already in `data/`.
+- [ ] `res_config_settings.py` + view: expose `fclk_planning_generation_months`, `fclk_self_unassign_days_before` (Integer — config_parameter ok).
+- [ ] Commit.
+
+### Task C2: clone-verify on Entech
+- [ ] Clone `admin` → `admin_fctest` (pg_dump|psql inside LXC 111).
+- [ ] Stage branch `fusion_clock` into an isolated `_test` addons dir shadowing prod; `-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0` on the clone; assert exit 0 + "Modules loaded".
+- [ ] Run `--test-tags /fusion_clock` on the clone; assert green.
+- [ ] `odoo shell` on clone: assert 144 schedule rows intact, 8 slots + 1 role migrated, portal `/my/clock/schedule` renders; `env.cr.rollback()`.
+
+### Task C3: deploy to Entech prod (gated)
+- [ ] Backup DB (`pg_dump -Fc`) + module dir copy OUTSIDE addons path.
+- [ ] scp branch `fusion_clock` → pve-worker5 → `pct push` into `/mnt/extra-addons/custom/fusion_clock` (swap; keep backup).
+- [ ] `systemctl stop odoo; runuser -u odoo -- odoo -c ... -d admin -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log`. **Restart only if RC==0 + "Modules loaded"**, else restore backup, no restart.
+- [ ] `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then `systemctl start odoo`.
+
+### Task C4: retire fusion_planning (+ optional planning)
+- [ ] After prod `-u` healthy + migration verified: uninstall `fusion_planning` (Apps → uninstall, or `env['ir.module.module'].search([('name','=','fusion_planning')]).button_immediate_uninstall()` via shell with backup).
+- [ ] Verify portal Schedule tab still present (now served by fusion_clock); attendance/penalty crons intact.
+- [ ] Optional, last, gated: uninstall `planning`/`web_gantt` (destructive — only after migration confirmed). Leave if any doubt.
+
+---
+
+## Self-review notes
+- Spec coverage: roles (A1–A3), recurrence (A4–A5), send (A6), portal (A7), migration (A8), multi/overnight/open/self-assign/bulk (B1–B5), Community manifest + deploy (C). All §5–§7 items mapped.
+- Risk: recurrence engine + multi-window are new — covered by `test_recurrence`, `test_multishift_window`, `test_overnight`.
+- Verification is batch (Entech clone), not per-step (no local docker from Mac).
diff --git a/fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md b/fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md
new file mode 100644
index 00000000..9c52df75
--- /dev/null
+++ b/fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md
@@ -0,0 +1,366 @@
+# Remove the Odoo Planning dependency from the Fusion Clock family
+
+**Date:** 2026-06-04
+**Module:** `fusion_clock` (absorbs `fusion_planning`, which is retired)
+**Status:** Design — awaiting spec review
+
+---
+
+## 1. Goal
+
+Make the Fusion Clock product family **fully Community-installable** (no Odoo
+Enterprise `planning` dependency) **and** simplify the Entech deployment, while
+**preserving every scheduling capability** currently delivered through Odoo
+Planning. No feature is removed; the work is sequenced, not trimmed.
+
+Driver (confirmed): **Both** — ship to clients without Enterprise *and* cut the
+barely-used Planning Gantt out of Entech.
+
+## 2. Current state (verified)
+
+`fusion_clock` itself does **not** depend on `planning`. Its deps are clean:
+`hr_attendance, hr, portal, mail, resource`. The entire Odoo-`planning` coupling
+lives in **one bridge module, `fusion_planning`**:
+
+| Coupling point | Where |
+|---|---|
+| `depends: ['planning']` | `fusion_planning/__manifest__.py` |
+| `_inherit = 'planning.slot'` (+ `x_fc_additional_resource_ids`, auto-publish `create()`) | `fusion_planning/models/planning_slot.py` |
+| inherits `planning.planning_view_form`, uses `planning.group_planning_manager` | `fusion_planning/views/planning_slot_views.xml` |
+| uses `hr.employee.default_planning_role_id` / `planning_role_ids`, menu under `planning.planning_menu_settings` | `fusion_planning/views/hr_employee_role_views.xml` |
+| reads `planning.slot` for the portal Schedule tab (already merges with native schedule) | `fusion_planning/controllers/portal_schedule.py` |
+| the Planning **Gantt** backend UI (`web_gantt`, Enterprise) | Odoo Planning |
+
+**Live Entech data (LXC 111, DB `admin`, Enterprise):**
+
+| Table | Rows |
+|---|---|
+| `planning.slot` | **8** (7 published) |
+| `planning.role` | **1** |
+| `fusion.clock.schedule` (native per-day planner) | **144** |
+| `fusion.clock.shift` (native templates) | 6 |
+
+The **native** per-day planner (`fusion.clock.schedule` + the OWL shift planner)
+is the real workhorse. Odoo Planning is essentially vestigial here.
+
+No other module in the repo references `planning` or `fusion_planning` (the one
+grep hit in `fusion_plating` is the English word "Planning" in a selection — noise).
+
+## 3. Decision & rationale
+
+**Chosen approach: re-fit Planning's *logic* onto the native per-day model
+(`fusion.clock.schedule`), extended to full feature parity. Retire
+`fusion_planning` by folding everything into `fusion_clock`.**
+
+Rejected alternative: *vendor `planning.slot` + `planning.recurrency` +
+`planning.planning` wholesale*. Why rejected:
+
+1. **The Gantt can't come to Community anyway.** `web_gantt` is Enterprise.
+ Vendoring `planning.slot`'s datetime model still leaves us building a
+ non-Gantt UI — so wholesale vendoring buys the heavy data model but not the UI.
+2. **Bugs live in the attendance pipeline, not the recurrence engine.**
+ fusion_clock's penalties (money), overtime, absence detection, reminders,
+ portal and dashboard all read one contract: `hr.employee._get_fclk_day_plan()`
+ off `fusion.clock.schedule`. Option 1 leaves that pipeline's data source
+ **untouched** and confines new code to isolated, testable features. Wholesale
+ vendoring forces either a dual schedule model or a rewire of that
+ money-critical pipeline onto Planning's datetime model **plus** a migration of
+ the 144 live rows — the highest-risk change possible, in exactly the code we
+ most need to keep correct.
+3. **Reuse is still honoured.** We copy the parts that copy cleanly
+ (`planning.role` near-verbatim, the recurrence **field design + repeat
+ semantics**, the **mail templates**) and re-fit only the generation loop —
+ which is *less* code on the per-day model because it drops the
+ resource-interval/DST math.
+
+**Two honest deltas vs Odoo Planning (only these):**
+
+- **The Gantt drag-drop board** → replaced by the native weekly OWL planner.
+ Capability preserved, UX differs. (Accepted by owner.) Drag-drop is a possible
+ future enhancement, out of scope here.
+- **Full resource-calendar-aware generation.** Planning's recurrence consults
+ resource work-intervals, flexible-resource flags and contract-end dates when
+ generating. The native re-fit uses the employee weekday pattern and skips
+ approved-leave days. This covers the real case; the heavy resource-calendar
+ engine is overkill at Entech's scale (8 slots). Documented simplification.
+
+## 4. End-state architecture
+
+- **`fusion_clock`** becomes self-contained and Community-installable. It owns:
+ native scheduling (existing), **roles**, **recurrence**, **publish/notify
+ (send)**, the **portal Schedule tab**, and **open / multi / overnight shift**
+ support. Manifest deps unchanged (`hr_attendance, hr, portal, mail, resource`)
+ — crucially **no `planning`**.
+- **`fusion_planning`** is **retired** — its functionality is folded into
+ `fusion_clock`, then it is uninstalled on Entech.
+- The attendance automation contract (`_get_fclk_day_plan`) is **unchanged** in
+ shape; new schedule capabilities resolve into the same single per-day
+ work-window it already returns (see §5.4).
+
+## 5. Detailed design
+
+### 5.1 New model: `fusion.clock.role` (copied from `planning.role`)
+
+Near-verbatim copy of `planning/models/planning_role.py`:
+
+- `name` — Char, required, translate
+- `color` — Integer, default random 1–11
+- `active` — Boolean, default True
+- `sequence` — Integer
+- `company_id` — Many2one `res.company` (added for fusion multi-company
+ consistency; Planning's role had none)
+- Copy `_get_color_from_code(is_open_shift)` → returns the fullcalendar-compatible
+ hex used to colour shifts on the portal Schedule tab.
+- Drop `resource_ids` m2m and `slot_properties_definition` (unused here).
+
+### 5.2 `hr.employee` — native role fields
+
+- `x_fclk_default_role_id` — Many2one `fusion.clock.role` (fills new shifts)
+- `x_fclk_role_ids` — Many2many `fusion.clock.role` (allowed roles)
+
+(Migrated from `default_planning_role_id` / `planning_role_ids`.)
+
+**Employee Roles editor** — port `fusion_planning/views/hr_employee_role_views.xml`
+to the native fields; reparent the menu from `planning.planning_menu_settings`
+to a fusion_clock config menu; gate with `group_fusion_clock_manager`.
+
+### 5.3 `fusion.clock.shift` (existing template) — additions
+
+- `role_id` — Many2one `fusion.clock.role` (a template can carry a default role)
+
+### 5.4 `fusion.clock.schedule` (existing per-day) — additions
+
+**Part A additions** (ship with the core dependency removal; the
+`UNIQUE(employee_id, schedule_date)` one-shift/day constraint is **kept**):
+
+- `role_id` — Many2one `fusion.clock.role`; default from `shift_id.role_id` or
+ `employee_id.x_fclk_default_role_id`. Drives portal colour/label.
+- `recurrence_id` — Many2one `fusion.clock.schedule.recurrence` (set when a rule
+ generated the row); `ondelete='set null'`.
+
+In Part A the attendance contract `_get_fclk_day_plan` is **completely
+unchanged** (one posted row per employee per day, exactly as today).
+
+**Part B additions** (parity for currently-unused Planning capabilities; built
+after A — see §10):
+
+- `is_open` — Boolean; an **open / unassigned** shift available for self-assign.
+- `crosses_midnight` — Boolean (overnight support).
+- **Constraint changes:** replace the hard `UNIQUE(employee_id, schedule_date)`
+ with a **partial unique** that still forbids accidental duplicate assigned
+ rows while allowing intentional multiple shifts/day. Exact predicate finalised
+ in the plan; use `models.Constraint` / `models.UniqueIndex` per Odoo-19 rules.
+ `employee_id` becomes **not required** *only* when `is_open = True` (enforced by
+ a Python `@api.constrains`).
+- **Overnight:** relax `_check_schedule_times` to permit `end_time <= start_time`
+ as crossing midnight (set `crosses_midnight`); update `_compute_planned_hours`
+ (`(24 - start) + end - break`) and `_get_fclk_scheduled_times` (out datetime is
+ next day).
+
+**The attendance contract stays single-window in Part B too.**
+`_get_fclk_day_plan(date)` still returns one plan per employee per day:
+
+- 0 assigned rows → not scheduled (unchanged).
+- 1 assigned row → that row (unchanged).
+- N assigned rows for the day → resolve to one work-window = earliest start →
+ latest end across that day's assigned shifts; break = sum of breaks. This keeps
+ penalties/overtime/absence math **unchanged in shape** while letting managers
+ schedule split shifts and employees see each shift on the portal.
+- `is_open` rows never feed any employee's plan until self-assigned.
+
+This is the key safety property: **multi-shift / overnight / open-shift live in
+the scheduling + UI + portal layers; the money-critical attendance layer keeps
+its existing one-window contract.**
+
+### 5.5 New model: `fusion.clock.schedule.recurrence` (design copied from `planning.recurrency`)
+
+Fields (copied semantics):
+
+- `repeat_interval` — Integer, default 1, `CHECK(repeat_interval >= 1)`
+- `repeat_unit` — Selection day/week/month/year, default week
+- `repeat_type` — Selection forever/until/x_times, default forever
+- `repeat_until` — Date (required when `repeat_type='until'`)
+- `repeat_number` — Integer (`>= 0`)
+- `last_generated_date` — Date, readonly
+- `company_id` — Many2one res.company
+- `schedule_ids` — One2many `fusion.clock.schedule`
+
+**Generation** (`_generate(stop_date=False)`), re-fit of `_repeat_slot` onto the
+per-day model — much simpler (no resource-interval/DST math):
+
+- Seed = the schedule entry the rule was created from (employee, weekday,
+ start/end/break/role).
+- Emit per-day `fusion.clock.schedule` rows at the cadence (`repeat_interval` ×
+ `repeat_unit`) up to a horizon = `min(repeat_until, today + company.fclk_planning_generation_months, repeat_number cap)`.
+- **Skip** dates the employee has an approved `fusion.clock.leave.request`
+ (the "resource-calendar-aware" simplification).
+- Generated rows are created in **draft** (must be posted/published to drive
+ automation), carrying `recurrence_id`.
+- Idempotent via `last_generated_date` (never regenerate past rows).
+- `_stop(from_date)` deletes future **draft** rows of the rule (copy of
+ Planning's `_delete_slot`); posted rows are kept.
+
+**Cron** `_cron_generate_recurring_schedules` (copy of Planning's
+`_cron_schedule_next` shape) rolls the horizon forward. Odoo-19: no `numbercall`;
+`active=True` recurring cron.
+
+### 5.6 Manager UI — native OWL planner extensions
+
+The existing weekly planner (`fusion_clock_shift_planner.js/xml` + controller
+`shift_planner.py`) gains, alongside its current toolbar (Prev/This/Next week,
+Copy Previous Week, Export XLSX, Save, Post Schedule):
+
+- **Role** shown/edited per cell (colour chip from `role_id._get_color_from_code`).
+- **Repeat…** control in the cell editor → creates a `fusion.clock.schedule.recurrence`
+ for that cell and generates rows; a backend list view manages/stops recurrences.
+- **Publish & Notify** (generalises the existing `post_week`): pick a date range
+ (default current week) + optional employee subset + optional message → posts
+ matching draft rows and emails each affected employee their posted shifts for
+ the range (see §5.8).
+- **Open shifts lane** + **bulk apply** ("Apply Also To" replacement): create an
+ open shift, or apply one cell's shift to several selected employees in one go.
+
+All planner endpoints stay gated by `group_fusion_clock_manager` (unchanged).
+
+### 5.7 Portal — fold the Schedule tab into `fusion_clock`
+
+- Move the controller `/my/clock/schedule` into `fusion_clock`
+ (`controllers/portal_clock.py` or a new `portal_schedule.py`), reading **only**
+ `fusion.clock.schedule` (drop the `planning.slot` branch). Role colour/label
+ come from `role_id`.
+- Move the template `fusion_planning.portal_schedule_page` →
+ `fusion_clock.portal_schedule_page`.
+- Add the **Schedule** nav button **inline** in each fusion_clock portal page's
+ `.fclk-nav-bar` (clock, timesheets, reports, payslips list, payslip detail),
+ replacing `fusion_planning`'s cross-module xpath inherits. Keep the
+ `.fclk-nav-bar` structure stable (no shared-template refactor — see the known
+ Odoo-19 xpath-inheritor gotcha).
+- **Self-assign / unassign** of open shifts on the portal (respect a company
+ "days before shift" setting, mirroring Planning's `allow_self_unassign`).
+
+### 5.8 Mail templates (copied, reworded)
+
+Port Planning's "send schedule" templates (`planning/data/mail_template_data.xml`)
+as the basis for fusion_clock's publish/notify email; reword for the Fusion
+portal link. The native `fusion.clock.schedule.fclk_email_posted_week()` is
+generalised to `fclk_email_posted_range(employee, start, end)`.
+Follow Odoo-19 mail.template rules (no `url_encode` in QWeb; `ctx` is
+`env.context`).
+
+### 5.9 Security & menus
+
+- `fusion.clock.role` + `fusion.clock.schedule.recurrence` → `ir.model.access.csv`
+ (manager write, user read as needed) + appropriate `ir.rule`s.
+- Employee Roles editor menu + a Recurrences menu under the fusion_clock
+ configuration menu, gated `group_fusion_clock_manager`.
+
+## 6. Data migration & retirement
+
+### 6.1 Migration (in `fusion_clock`, guarded, idempotent)
+
+A post-migration step (new module version) that runs only where Planning data
+exists (`if 'planning.role' in env` / `'planning.slot' in env`):
+
+1. **Roles:** each `planning.role` → find-or-create `fusion.clock.role`
+ (name + color). Build an id map.
+2. **Employee roles:** `default_planning_role_id` → `x_fclk_default_role_id`;
+ `planning_role_ids` → `x_fclk_role_ids` (via the map).
+3. **Slots:** each `planning.slot` → `fusion.clock.schedule`:
+ `resource_id`→employee, local date + local float start/end (employee tz),
+ break derived from span vs `allocated_hours`, `role_id` via map,
+ `state = posted` if published else draft. Unusual slots (overnight / multi /
+ open) handled by the §5.4 rules; anything unexpected is logged, not dropped.
+ Idempotent via a one-time `ir.config_parameter` marker.
+
+Volume is tiny (8 slots, 1 role) — fast and low-risk.
+
+### 6.2 Retire `fusion_planning`
+
+After `fusion_clock` provides the Schedule tab + roles + migration, **uninstall
+`fusion_planning`** (its portal templates, nav xpath-inherits and the
+`x_fc_additional_resource_ids` m2m are removed). fusion_clock now owns the
+Schedule tab inline. **Optionally** uninstall `planning` / `web_gantt` afterwards
+(separate, gated cleanup — destructive, so done last and only on sign-off).
+
+### 6.3 Community-install guarantee
+
+After the change, `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_clock`
+must install on **Community** with no `planning` present (the migration is
+guarded; no runtime code references `planning.*`). Add this as a smoke check.
+
+## 7. Entech rollout (gated, revert-on-failure)
+
+1. **Backup** DB + module dir (outside the addons path).
+2. **Clone-verify**: clone `admin` → upgrade `fusion_clock` (+migration) on the
+ clone → assert: 144 native rows intact, 8 slots + 1 role migrated, roles +
+ recurrence + portal Schedule render, attendance/penalty tests green.
+3. **Prod upgrade** `fusion_clock` (stop → `-u` → start **only if RC==0 +
+ "Modules loaded"**, else restore backup, no restart). Clear asset bundle
+ attachments; restart.
+4. **Uninstall `fusion_planning`**.
+5. **Optional**: uninstall `planning` / `web_gantt` (final, on sign-off).
+
+## 8. Feature-parity matrix
+
+| Planning feature | Preserved as |
+|---|---|
+| Assign shifts, weekly board | Native OWL planner (extended) |
+| Gantt drag-drop timeline | ❗→ native weekly planner (Gantt can't be Community) |
+| Shift templates | `fusion.clock.shift` (exists) + `role_id` |
+| Roles + colour | `fusion.clock.role` (copied) + portal colour |
+| Employee default/allowed roles | `x_fclk_default_role_id` / `x_fclk_role_ids` + editor |
+| Recurrence (N day/week/month/year; forever/until/N-times) + cron | `fusion.clock.schedule.recurrence` (copied design) |
+| Send / publish + email | Publish & Notify over a range (copied templates) |
+| Multiple shifts/day | per-day model + single work-window contract (§5.4) |
+| Overnight shifts | `crosses_midnight` (§5.4) |
+| Open shifts + self-assign/unassign | `is_open` + portal self-assign |
+| Auto-publish on create | native option (kept) |
+| "Apply Also To" multi-employee | native bulk-apply |
+| Allocated hours, portal My Schedule | `planned_hours`; Schedule tab folded in |
+| Attendance/penalty/overtime/absence | **UNCHANGED** (per-day contract preserved) |
+| Resource-calendar-aware generation | simplified: weekday pattern + skip leave |
+
+## 9. Testing strategy
+
+- **Unit:** role + colour; recurrence generation across each repeat_type/unit;
+ `_stop` deletes future drafts only; publish-range posts + emails; migration maps
+ roles/slots/employee-roles; overnight `planned_hours` + scheduled_times;
+ open-shift self-assign; multi-shift day-plan → correct single work-window.
+- **Regression:** existing attendance / penalty / overtime / absence / dashboard
+ tests stay green (data source unchanged).
+- **Community smoke:** install `fusion_clock` on Community `modsdev` (no planning).
+- Odoo-19 test runner: `--http-port=0 --gevent-port=0`, `--test-tags /fusion_clock`.
+
+## 10. Sequencing
+
+**Decision: Part A and Part B ship together in one release** (full Planning
+parity at once). The A/B labels below are **internal build phases** for the
+implementation plan (so each gets its own review checkpoint), not separate
+deployments.
+
+- **Phase A:** drop the planning dep with parity for everything in real use —
+ roles, recurrence, publish/notify, portal fold-in, migration, retire
+ `fusion_planning`. (Per-day model keeps one-shift/day here.)
+- **Phase B:** the remaining Planning capabilities — multi-shift/day, overnight,
+ open shifts + self-assign, "Apply Also To" bulk — using the safe single-window
+ attendance contract; isolated from the attendance engine.
+
+Both phases are validated together before the single Entech rollout in §7.
+
+## 11. Risks & open questions
+
+- **Recurrence correctness** is new code — mitigated by isolation + unit tests
+ across every repeat_type/unit and the idempotent `last_generated_date` guard.
+- **Multi-shift day-plan resolution** (§5.4) is the subtlest change; covered by a
+ dedicated test asserting the work-window and that penalties are unaffected.
+- **Licensing:** the role model is generic, the recurrence loop is re-fit/original,
+ and mail templates are reworded — so near-verbatim Enterprise code is minimal.
+ Flag for the resale build; owner's call.
+- **Resolved:** Parts A and B ship together in one release (§10).
+
+## 12. Out of scope
+
+- Drag-and-drop on the native planner (future enhancement).
+- Full resource working-interval / flexible-resource / contract-end recurrence
+ math (deliberate simplification, §3).
+- Uninstalling `planning` from Entech is optional and gated separately.
diff --git a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py
new file mode 100644
index 00000000..3d6094cb
--- /dev/null
+++ b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# One-time port of Odoo Enterprise Planning data into the native Fusion Clock
+# models, so a deployment that previously used the `planning` bridge keeps all
+# its roles, employee role assignments and shifts after `planning` /
+# `fusion_planning` are removed.
+#
+# Guarded: a no-op on Community / fresh installs where planning data is absent.
+# Idempotent: a marker param stops it re-running.
+
+import logging
+
+from odoo import api, SUPERUSER_ID
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ """Drop the legacy one-shift-per-day constraint and attempt the planning ->
+ native port. The port (fusion.clock.schedule._fclk_port_planning_data) is
+ marker-guarded and self-defers: because fusion_clock doesn't depend on
+ planning, planning's ORM may not be loaded here, in which case the deploy
+ shell step finishes the port. Lives in the model so it's unit-testable."""
+ env = api.Environment(cr, SUPERUSER_ID, {})
+
+ # Phase B drops the hard one-shift-per-day uniqueness so split/open shifts
+ # are allowed. Odoo drops removed declarative constraints on upgrade, but be
+ # explicit so the upgrade can never leave the old constraint behind.
+ cr.execute(
+ "ALTER TABLE fusion_clock_schedule "
+ "DROP CONSTRAINT IF EXISTS fusion_clock_schedule_employee_date_unique")
+
+ counts = env['fusion.clock.schedule'].sudo()._fclk_port_planning_data()
+ if counts.get('deferred'):
+ _logger.info("Fusion Clock: planning models not loaded during migration; "
+ "data will be ported by the deploy shell step.")
+ else:
+ _logger.info("Fusion Clock: planning -> native migration: %s", counts)
diff --git a/fusion_clock/migrations/19.0.5.0.0/pre-migrate.py b/fusion_clock/migrations/19.0.5.0.0/pre-migrate.py
new file mode 100644
index 00000000..ca8a9901
--- /dev/null
+++ b/fusion_clock/migrations/19.0.5.0.0/pre-migrate.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Defensive pre-migration: re-link orphaned fusion_clock config-parameter
+# external IDs.
+#
+# Booleans/floats saved through the Settings UI go in via set_param(), which
+# creates the ir_config_parameter row WITHOUT an ir_model_data external id. If a
+# param later also appears in the noupdate data/ir_config_parameter_data.xml,
+# a plain `-u` can't match it by external id, treats it as new, and the INSERT
+# trips the UNIQUE(key) constraint -> "Failed to load registry".
+#
+# This runs BEFORE the data files load: for every config record in the XML whose
+# param already exists but whose external id is missing, we create the external
+# id pointing at the existing param. The noupdate load then matches + skips it,
+# so the existing (possibly customised) value is preserved.
+
+import logging
+import os
+
+from lxml import etree
+
+from odoo.modules.module import get_module_path
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ module_path = get_module_path('fusion_clock')
+ if not module_path:
+ return
+ xml_path = os.path.join(module_path, 'data', 'ir_config_parameter_data.xml')
+ if not os.path.exists(xml_path):
+ return
+
+ tree = etree.parse(xml_path)
+ fixed = 0
+ for rec in tree.findall('.//record[@model="ir.config_parameter"]'):
+ xmlid = rec.get('id')
+ key_node = rec.find('./field[@name="key"]')
+ if not xmlid or key_node is None or not (key_node.text or '').strip():
+ continue
+ key = key_node.text.strip()
+
+ cr.execute("SELECT id FROM ir_config_parameter WHERE key = %s", (key,))
+ param = cr.fetchone()
+ if not param:
+ continue # not set yet -> the noupdate load will create it cleanly
+
+ cr.execute(
+ "SELECT id FROM ir_model_data WHERE module = 'fusion_clock' AND name = %s",
+ (xmlid,))
+ if cr.fetchone():
+ continue # already linked
+
+ cr.execute("""
+ INSERT INTO ir_model_data (module, name, model, res_id, noupdate, create_date, write_date)
+ VALUES ('fusion_clock', %s, 'ir.config_parameter', %s, true, now(), now())
+ """, (xmlid, param[0]))
+ fixed += 1
+
+ if fixed:
+ _logger.info(
+ "Fusion Clock: re-linked %s orphaned config-parameter external id(s).", fixed)
diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py
index c51939ad..aeb5efe8 100644
--- a/fusion_clock/models/__init__.py
+++ b/fusion_clock/models/__init__.py
@@ -10,7 +10,9 @@ from . import clock_report
from . import res_config_settings
from . import clock_activity_log
from . import clock_leave_request
+from . import clock_role
from . import clock_shift
+from . import clock_recurrence
from . import clock_schedule
from . import clock_correction
from . import res_company
diff --git a/fusion_clock/models/clock_recurrence.py b/fusion_clock/models/clock_recurrence.py
new file mode 100644
index 00000000..caf8767a
--- /dev/null
+++ b/fusion_clock/models/clock_recurrence.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Native recurring-shift engine. The field design and repeat semantics are
+# adapted from Odoo Enterprise ``planning.recurrency`` (repeat every N
+# days/weeks/months/years; forever / until / N-times), but the generation loop
+# targets Fusion Clock's per-day ``fusion.clock.schedule`` rows instead of
+# datetime ``planning.slot`` records — so there is no resource-calendar / DST
+# machinery to carry. Generated rows are born ``draft`` and must be posted
+# (published) before any attendance automation acts on them.
+
+import logging
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+_logger = logging.getLogger(__name__)
+
+# Hard safety cap on iterations when projecting a recurrence forward, so a
+# misconfigured rule can never loop unbounded (5 years of daily shifts).
+_MAX_OCCURRENCES = 365 * 5
+
+
+class FusionClockScheduleRecurrence(models.Model):
+ _name = 'fusion.clock.schedule.recurrence'
+ _description = 'Clock Schedule Recurrence'
+ _rec_name = 'display_name'
+
+ schedule_ids = fields.One2many(
+ 'fusion.clock.schedule', 'recurrence_id', string='Generated Shifts')
+ repeat_interval = fields.Integer('Repeat Every', default=1, required=True)
+ repeat_unit = fields.Selection(
+ [('day', 'Days'), ('week', 'Weeks'), ('month', 'Months'), ('year', 'Years')],
+ string='Repeat Unit', default='week', required=True)
+ repeat_type = fields.Selection(
+ [('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Repetitions')],
+ string='Until', default='forever', required=True)
+ repeat_until = fields.Date('Repeat Until')
+ repeat_number = fields.Integer('Repetitions', default=1)
+ last_generated_date = fields.Date(readonly=True)
+ company_id = fields.Many2one(
+ 'res.company', string='Company', required=True,
+ default=lambda self: self.env.company)
+ display_name = fields.Char(compute='_compute_display_name')
+
+ _check_interval_positive = models.Constraint(
+ 'CHECK(repeat_interval >= 1)', 'The repeat interval must be at least 1.')
+
+ @api.constrains('repeat_type', 'repeat_until')
+ def _check_until(self):
+ for rec in self:
+ if rec.repeat_type == 'until' and not rec.repeat_until:
+ raise ValidationError(_('Set an end date for an "Until" recurrence.'))
+
+ @api.constrains('repeat_type', 'repeat_number')
+ def _check_number(self):
+ for rec in self:
+ if rec.repeat_type == 'x_times' and rec.repeat_number < 1:
+ raise ValidationError(_('The number of repetitions must be at least 1.'))
+
+ @api.depends('repeat_type', 'repeat_interval', 'repeat_unit', 'repeat_until', 'repeat_number')
+ def _compute_display_name(self):
+ units = dict(self._fields['repeat_unit'].selection)
+ for rec in self:
+ unit = units.get(rec.repeat_unit, rec.repeat_unit)
+ if rec.repeat_type == 'forever':
+ rec.display_name = _('Every %(n)s %(u)s, forever', n=rec.repeat_interval, u=unit)
+ elif rec.repeat_type == 'until':
+ rec.display_name = _('Every %(n)s %(u)s until %(d)s',
+ n=rec.repeat_interval, u=unit, d=rec.repeat_until)
+ else:
+ rec.display_name = _('Every %(n)s %(u)s, %(c)s times',
+ n=rec.repeat_interval, u=unit, c=rec.repeat_number)
+
+ def _delta(self, n):
+ """relativedelta for the n-th occurrence after the seed."""
+ self.ensure_one()
+ key = {'day': 'days', 'week': 'weeks', 'month': 'months', 'year': 'years'}[self.repeat_unit]
+ return relativedelta(**{key: self.repeat_interval * n})
+
+ def _horizon(self):
+ """Furthest date we pre-generate to when the recurrence has no end."""
+ self.ensure_one()
+ months = self.company_id.fclk_planning_generation_months or 6
+ return fields.Date.today() + relativedelta(months=months)
+
+ def _generate(self, stop_date=False):
+ """Materialise per-day schedule rows for each recurrence up to its
+ horizon. Idempotent: dates already covered for the rule are skipped and
+ ``last_generated_date`` advances."""
+ Schedule = self.env['fusion.clock.schedule'].sudo()
+ for rec in self:
+ seed = Schedule.search(
+ [('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1)
+ if not seed:
+ # No anchor row -> nothing to repeat; drop the empty rule.
+ rec.unlink()
+ continue
+ anchor = Schedule.search(
+ [('recurrence_id', '=', rec.id)], order='schedule_date asc', limit=1)
+ bounds = [stop_date or rec._horizon()]
+ if rec.repeat_until:
+ bounds.append(rec.repeat_until)
+ limit = min(bounds)
+
+ existing = Schedule.search_count([('recurrence_id', '=', rec.id)])
+ vals_list, last = [], rec.last_generated_date
+ for i in range(1, _MAX_OCCURRENCES + 1):
+ nxt = anchor.schedule_date + rec._delta(i)
+ if nxt > limit:
+ break
+ if rec.repeat_type == 'x_times' and existing + len(vals_list) >= rec.repeat_number:
+ break
+ if Schedule.search_count(
+ [('recurrence_id', '=', rec.id), ('schedule_date', '=', nxt)]):
+ continue
+ if anchor.employee_id and anchor.employee_id._fclk_on_leave(nxt):
+ continue
+ vals_list.append({
+ 'employee_id': anchor.employee_id.id or False,
+ 'schedule_date': nxt,
+ 'shift_id': anchor.shift_id.id or False,
+ 'role_id': anchor.role_id.id or False,
+ 'is_off': anchor.is_off,
+ # is_open is added in the Phase B schedule extension; guard so
+ # the engine works whether or not that field exists yet.
+ 'is_open': bool(getattr(anchor, 'is_open', False)),
+ 'start_time': anchor.start_time,
+ 'end_time': anchor.end_time,
+ 'break_minutes': anchor.break_minutes,
+ 'note': anchor.note or False,
+ 'recurrence_id': rec.id,
+ 'state': 'draft',
+ })
+ last = nxt
+ if vals_list:
+ Schedule.create(vals_list)
+ rec.last_generated_date = last
+
+ def _stop(self, from_date):
+ """Delete future DRAFT rows of these rules (posted rows are kept)."""
+ self.env['fusion.clock.schedule'].sudo().search([
+ ('recurrence_id', 'in', self.ids),
+ ('schedule_date', '>=', from_date),
+ ('state', '=', 'draft'),
+ ]).unlink()
+
+ @api.model
+ def _cron_generate(self):
+ """Roll every recurrence's horizon forward (called daily)."""
+ self.search([])._generate()
diff --git a/fusion_clock/models/clock_role.py b/fusion_clock/models/clock_role.py
new file mode 100644
index 00000000..b68a4bfb
--- /dev/null
+++ b/fusion_clock/models/clock_role.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Native shift role. Re-implements the small, useful subset of Odoo
+# Enterprise ``planning.role`` (name + colour) so Fusion Clock can colour and
+# label shifts on the portal without depending on the Enterprise Planning app.
+
+from random import randint
+
+from odoo import fields, models
+
+
+class FusionClockRole(models.Model):
+ _name = 'fusion.clock.role'
+ _description = 'Clock Shift Role'
+ _order = 'sequence, name'
+ _rec_name = 'name'
+
+ def _get_default_color(self):
+ return randint(1, 11)
+
+ name = fields.Char(required=True, translate=True)
+ color = fields.Integer(default=_get_default_color)
+ active = fields.Boolean(default=True)
+ sequence = fields.Integer(default=10)
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ # Kanban colour code (1-11) -> hex, mirroring planning.role._get_color_from_code
+ # so the portal Schedule tab shows the same palette Planning used.
+ _COLOR_HEX = {
+ 0: '#008784', 1: '#EE4B39', 2: '#F29648', 3: '#F4C609',
+ 4: '#55B7EA', 5: '#71405B', 6: '#E86869', 7: '#008784',
+ 8: '#267283', 9: '#BF1255', 10: '#2BAF73', 11: '#8754B0',
+ }
+
+ def _get_color_from_code(self, is_open_shift=False):
+ """Return a hex colour for this role. Open shifts get an '80' alpha
+ suffix (matching Planning's open-shift transparency convention)."""
+ self.ensure_one()
+ hex_value = self._COLOR_HEX.get(self.color, '#008784')
+ return hex_value + ('80' if is_open_shift else '')
diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py
index e734cf6f..6860bebb 100644
--- a/fusion_clock/models/clock_schedule.py
+++ b/fusion_clock/models/clock_schedule.py
@@ -21,10 +21,16 @@ class FusionClockSchedule(models.Model):
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
- required=True,
+ required=False, # open (unassigned) shifts have no employee until claimed
index=True,
ondelete='cascade',
)
+ is_open = fields.Boolean(
+ string='Open Shift',
+ default=False,
+ index=True,
+ help="An unassigned shift any eligible employee can claim from the portal.",
+ )
schedule_date = fields.Date(
string='Date',
required=True,
@@ -57,13 +63,35 @@ class FusionClockSchedule(models.Model):
compute='_compute_planned_hours',
store=True,
)
+ crosses_midnight = fields.Boolean(
+ string='Overnight',
+ compute='_compute_planned_hours',
+ store=True,
+ help="Set automatically when the shift ends on the next day "
+ "(end time on or before start time).",
+ )
note = fields.Char(string='Note')
+ role_id = fields.Many2one(
+ 'fusion.clock.role',
+ string='Role',
+ help="Shift role — drives the colour/label shown on the employee's "
+ "portal schedule. Defaults from the shift template or the "
+ "employee's Default Shift Role.",
+ )
+ recurrence_id = fields.Many2one(
+ 'fusion.clock.schedule.recurrence',
+ string='Recurrence',
+ ondelete='set null',
+ index=True,
+ help="Set when this entry was generated by a recurring rule.",
+ )
company_id = fields.Many2one(
'res.company',
string='Company',
- related='employee_id.company_id',
+ compute='_compute_fclk_company',
store=True,
- readonly=True,
+ readonly=False,
+ index=True,
)
department_id = fields.Many2one(
'hr.department',
@@ -86,18 +114,41 @@ class FusionClockSchedule(models.Model):
)
posted_date = fields.Datetime(string='Posted On', readonly=True)
- _employee_date_unique = models.Constraint(
- 'UNIQUE(employee_id, schedule_date)',
- 'Only one shift schedule is allowed per employee per day.',
- )
+ # No hard UNIQUE(employee, date): the per-day model now allows split shifts
+ # and open (unassigned) shifts. The shift planner still manages one cell per
+ # day in place; the attendance contract (_get_fclk_day_plan) resolves
+ # multiple posted rows into a single work-window.
+
+ @api.depends('employee_id')
+ def _compute_fclk_company(self):
+ for rec in self:
+ if rec.employee_id:
+ rec.company_id = rec.employee_id.company_id
+ elif not rec.company_id:
+ rec.company_id = self.env.company
+
+ @api.constrains('employee_id', 'is_open')
+ def _check_employee_or_open(self):
+ for rec in self:
+ if not rec.employee_id and not rec.is_open:
+ raise ValidationError(
+ _("A shift must have an employee unless it is an open shift."))
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
def _compute_planned_hours(self):
for rec in self:
+ rec.crosses_midnight = False
if rec.is_off:
rec.planned_hours = 0.0
continue
- raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
+ start = rec.start_time or 0.0
+ end = rec.end_time or 0.0
+ if end <= start:
+ # Overnight: the shift ends on the following day.
+ rec.crosses_midnight = True
+ raw_hours = (24.0 - start) + end
+ else:
+ raw_hours = end - start
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
@@ -116,11 +167,13 @@ class FusionClockSchedule(models.Model):
continue
if rec.start_time < 0 or rec.start_time >= 24:
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
- if rec.end_time <= 0 or rec.end_time > 24:
- raise ValidationError(_("End time must be between 00:01 and 24:00."))
+ if rec.end_time < 0 or rec.end_time > 24:
+ raise ValidationError(_("End time must be between 00:00 and 24:00."))
+ # Overnight shifts (end on/before start) are allowed and span midnight.
if rec.end_time <= rec.start_time:
- raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
- shift_minutes = (rec.end_time - rec.start_time) * 60.0
+ shift_minutes = ((24.0 - rec.start_time) + rec.end_time) * 60.0
+ else:
+ shift_minutes = (rec.end_time - rec.start_time) * 60.0
if rec.break_minutes >= shift_minutes:
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
@@ -292,10 +345,21 @@ class FusionClockSchedule(models.Model):
new_schedule = self.browse()
new_value = ''
else:
+ # Resolve the role: explicit payload role wins, then the shift
+ # template's role, then the employee's default role.
+ role_id = payload.get('role_id')
+ if not role_id:
+ shift_id = parsed.get('shift_id')
+ shift = self.env['fusion.clock.shift'].browse(shift_id) if shift_id else None
+ if shift and shift.role_id:
+ role_id = shift.role_id.id
+ elif employee.x_fclk_default_role_id:
+ role_id = employee.x_fclk_default_role_id.id
vals = {
'employee_id': employee.id,
'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or False,
+ 'role_id': int(role_id) if role_id else False,
'is_off': bool(parsed.get('is_off')),
'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0,
@@ -349,6 +413,10 @@ class FusionClockSchedule(models.Model):
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '',
+ 'role_id': schedule.role_id.id or False,
+ 'role_name': schedule.role_id.name or '',
+ 'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '',
+ 'recurring': bool(schedule.recurrence_id),
}
plan = employee._get_fclk_day_plan(date_obj)
@@ -366,25 +434,122 @@ class FusionClockSchedule(models.Model):
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
+ 'role_id': False,
+ 'role_name': '',
+ 'role_color': '',
}
@api.model
- def fclk_email_posted_week(self, employee, week_start, week_end):
- """Email one employee a summary of their POSTED shifts for the week."""
+ def fclk_attach_recurrence(self, schedule, repeat_vals):
+ """Attach a recurrence rule to a seed schedule cell and generate it
+ forward. ``repeat_vals`` mirrors the recurrence fields."""
+ schedule = schedule.sudo()
+ if not schedule:
+ raise ValidationError(_("Pick a shift to repeat first."))
+ rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({
+ 'repeat_interval': int(repeat_vals.get('repeat_interval') or 1),
+ 'repeat_unit': repeat_vals.get('repeat_unit') or 'week',
+ 'repeat_type': repeat_vals.get('repeat_type') or 'forever',
+ 'repeat_until': repeat_vals.get('repeat_until') or False,
+ 'repeat_number': int(repeat_vals.get('repeat_number') or 1),
+ 'company_id': schedule.company_id.id or self.env.company.id,
+ })
+ schedule.recurrence_id = rule.id
+ rule._generate()
+ return rule
+
+ @api.model
+ def fclk_clear_recurrence(self, schedule):
+ """Detach + stop the recurrence on a seed cell (keeps posted rows)."""
+ schedule = schedule.sudo()
+ rule = schedule.recurrence_id
+ if rule:
+ rule._stop(fields.Date.today())
+ schedule.recurrence_id = False
+ if not rule.schedule_ids:
+ rule.unlink()
+ 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
+ def fclk_email_posted_range(self, employee, start, end, message=None):
+ """Email one employee a summary of their POSTED shifts between two
+ dates (inclusive). Optional ``message`` is shown above the schedule."""
employee = employee.sudo()
if not employee.work_email:
return False
from .hr_attendance import _fclk_email_wrap
entries = self.sudo().search([
('employee_id', '=', employee.id),
- ('schedule_date', '>=', week_start),
- ('schedule_date', '<=', week_end),
+ ('schedule_date', '>=', start),
+ ('schedule_date', '<=', end),
('state', '=', 'posted'),
])
by_date = {entry.schedule_date: entry for entry in entries}
rows = []
- day = week_start
- while day <= week_end:
+ day = start
+ while day <= end:
entry = by_date.get(day)
rows.append((
day.strftime('%a %b %d'),
@@ -392,20 +557,23 @@ class FusionClockSchedule(models.Model):
))
day += timedelta(days=1)
company = employee.company_id or self.env.company
+ summary = (
+ f'Hello {employee.name}, your shifts for '
+ f'{start.strftime("%b %d")} - {end.strftime("%b %d, %Y")} '
+ f'have been posted.'
+ )
+ if message:
+ summary += f'
{message}'
body = _fclk_email_wrap(
company_name=company.name or '',
title='Your Posted Schedule',
- summary=(
- f'Hello {employee.name}, your shifts for '
- f'{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")} '
- f'have been posted.'
- ),
- sections=[('This Week', rows)],
- note='Log in to your portal for details.',
+ summary=summary,
+ sections=[('Schedule', rows)],
+ note='Log in to your portal for details.',
)
try:
mail = self.env['mail.mail'].sudo().create({
- 'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
+ 'subject': f'Your schedule: {start.strftime("%b %d")} - {end.strftime("%b %d")}',
'email_from': company.email or '',
'email_to': employee.work_email,
'body_html': body,
@@ -419,6 +587,136 @@ class FusionClockSchedule(models.Model):
)
return False
+ @api.model
+ def fclk_email_posted_week(self, employee, week_start, week_end):
+ """Back-compat wrapper — email one employee their posted week."""
+ return self.fclk_email_posted_range(employee, week_start, week_end)
+
+ @api.model
+ def fclk_publish_range(self, employees, start, end, message=None):
+ """Post every draft shift in [start, end] for the given employees and
+ email each affected employee. Returns (posted_count, notified_count)."""
+ Schedule = self.sudo()
+ domain = [
+ ('employee_id', 'in', employees.ids),
+ ('schedule_date', '>=', start),
+ ('schedule_date', '<=', end),
+ ('state', '!=', 'posted'),
+ ]
+ # Never auto-post open (unassigned) shifts (Phase B field).
+ if 'is_open' in Schedule._fields:
+ domain.append(('is_open', '=', False))
+ drafts = Schedule.search(domain)
+ posted = len(drafts)
+ affected = drafts.mapped('employee_id')
+ if drafts:
+ drafts.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
+ notified = 0
+ for employee in affected:
+ if Schedule.fclk_email_posted_range(employee, start, end, message=message):
+ notified += 1
+ return posted, notified
+
+ @api.model
+ def _fclk_port_planning_data(self):
+ """Port Odoo Planning data (roles, employee roles, slots) into the
+ native models. Idempotent (marker-guarded). Returns a dict of counts.
+
+ Because fusion_clock does NOT depend on planning, during a `-u` planning
+ may load AFTER us, so its ORM models aren't available in the migration's
+ registry. When that happens we set ``deferred`` and do nothing; the
+ deploy then runs this again from `odoo shell`, where the whole registry
+ (planning included) is loaded. Called by the 19.0.5.0.0 migration, the
+ deploy shell step, and tests."""
+ import pytz
+
+ counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0, 'deferred': False}
+ env = self.env
+ ICP = env['ir.config_parameter'].sudo()
+ if ICP.get_param('fusion_clock.planning_migrated'):
+ return counts
+
+ # Do the planning tables exist at all? (raw SQL — independent of whether
+ # planning's ORM models are loaded in this registry.)
+ env.cr.execute(
+ "SELECT to_regclass('public.planning_role'), to_regclass('public.planning_slot')")
+ role_tbl, slot_tbl = env.cr.fetchone()
+ if not role_tbl and not slot_tbl:
+ ICP.set_param('fusion_clock.planning_migrated', '1') # Community / fresh
+ return counts
+
+ # Tables exist but the ORM models may not be loaded yet -> defer.
+ if 'planning.slot' not in env or 'planning.role' not in env:
+ counts['deferred'] = True
+ return counts
+
+ has_roles = bool(role_tbl)
+ has_slots = bool(slot_tbl)
+ Role = env['fusion.clock.role'].sudo()
+ role_map = {}
+ if has_roles:
+ for prole in env['planning.role'].sudo().with_context(active_test=False).search([]):
+ target = Role.with_context(active_test=False).search(
+ [('name', '=ilike', prole.name)], limit=1) or Role.create({
+ 'name': prole.name, 'color': prole.color or 1, 'active': prole.active})
+ role_map[prole.id] = target.id
+ counts['roles'] = len(role_map)
+
+ Employee = env['hr.employee'].sudo().with_context(active_test=False)
+ for emp in Employee.search([]):
+ vals = {}
+ if emp._fields.get('default_planning_role_id') and emp.default_planning_role_id:
+ mapped = role_map.get(emp.default_planning_role_id.id)
+ if mapped:
+ vals['x_fclk_default_role_id'] = mapped
+ if emp._fields.get('planning_role_ids') and emp.planning_role_ids:
+ mapped_ids = [role_map[r.id] for r in emp.planning_role_ids if r.id in role_map]
+ if mapped_ids:
+ vals['x_fclk_role_ids'] = [(6, 0, mapped_ids)]
+ if vals:
+ emp.write(vals)
+ counts['employees'] += 1
+
+ if has_slots:
+ Schedule = self.sudo()
+ for slot in env['planning.slot'].sudo().search([], order='start_datetime'):
+ if not slot.start_datetime or not slot.end_datetime:
+ counts['skipped'] += 1
+ continue
+ employee = slot.employee_id if 'employee_id' in slot._fields else False
+ tz_name = ((employee.tz if employee else False)
+ or (slot.resource_id.tz if slot.resource_id else False)
+ or env.company.partner_id.tz or 'UTC')
+ try:
+ tz = pytz.timezone(tz_name)
+ except Exception:
+ tz = pytz.UTC
+ local_start = pytz.utc.localize(slot.start_datetime).astimezone(tz)
+ local_end = pytz.utc.localize(slot.end_datetime).astimezone(tz)
+ span_hours = (slot.end_datetime - slot.start_datetime).total_seconds() / 3600.0
+ allocated = slot.allocated_hours if 'allocated_hours' in slot._fields else span_hours
+ vals = {
+ 'employee_id': employee.id if employee else False,
+ 'is_open': not bool(employee),
+ 'schedule_date': local_start.date(),
+ 'start_time': round(local_start.hour + local_start.minute / 60.0, 2),
+ 'end_time': round(local_end.hour + local_end.minute / 60.0, 2),
+ 'break_minutes': round(max(0.0, span_hours - (allocated or span_hours)) * 60.0, 0),
+ 'role_id': role_map.get(slot.role_id.id) if slot.role_id else False,
+ 'note': slot.name or False,
+ 'state': 'posted' if slot.state == 'published' else 'draft',
+ }
+ with env.cr.savepoint():
+ try:
+ Schedule.create(vals)
+ counts['slots'] += 1
+ except Exception as exc:
+ counts['skipped'] += 1
+ _logger.warning("Fusion Clock: skip planning.slot %s (%s).", slot.id, exc)
+
+ ICP.set_param('fusion_clock.planning_migrated', '1')
+ return counts
+
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'
diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py
index c86ad164..b5bb2dd7 100644
--- a/fusion_clock/models/clock_shift.py
+++ b/fusion_clock/models/clock_shift.py
@@ -42,6 +42,12 @@ class FusionClockShift(models.Model):
)
active = fields.Boolean(default=True)
color = fields.Char(string='Color', default='#3B82F6')
+ role_id = fields.Many2one(
+ 'fusion.clock.role',
+ string='Default Role',
+ help="Role assigned to shifts created from this template "
+ "(drives the colour/label on the employee's portal schedule).",
+ )
# Weekday pattern — which days this recurring shift applies as the baseline
# when there is no posted planner entry for the day. Default Mon-Fri.
diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py
index 1b840320..63e5d70d 100644
--- a/fusion_clock/models/hr_employee.py
+++ b/fusion_clock/models/hr_employee.py
@@ -33,6 +33,21 @@ class HrEmployee(models.Model):
help="Assigned shift schedule. Leave empty to use global defaults.",
)
+ # Shift roles (native replacement for Odoo Planning's employee role fields)
+ x_fclk_default_role_id = fields.Many2one(
+ 'fusion.clock.role',
+ string='Default Shift Role',
+ help="Pre-fills the role on every new shift created for this employee.",
+ )
+ x_fclk_role_ids = fields.Many2many(
+ 'fusion.clock.role',
+ 'fclk_employee_role_rel',
+ 'employee_id',
+ 'role_id',
+ string='Allowed Shift Roles',
+ help="Roles this employee is allowed to be scheduled for.",
+ )
+
# Pending reason enforcement
x_fclk_pending_reason = fields.Boolean(
string='Pending Reason Required',
@@ -158,6 +173,19 @@ class HrEmployee(models.Model):
('schedule_date', '=', date_obj),
], limit=1)
+ def _fclk_on_leave(self, date):
+ """True if an approved leave request covers ``date`` for this employee.
+ Used by the recurrence engine to skip generating shifts on days off."""
+ self.ensure_one()
+ date_obj = fields.Date.to_date(date)
+ if not date_obj:
+ return False
+ return bool(self.env['fusion.clock.leave.request'].sudo().search_count([
+ ('employee_id', '=', self.id),
+ ('leave_date', '<=', date_obj),
+ ('date_to', '>=', date_obj),
+ ]))
+
def _get_fclk_day_plan(self, date):
"""Return the effective plan for a local date, with an explicit
``scheduled`` flag that ALL attendance automation keys off.
@@ -172,23 +200,60 @@ class HrEmployee(models.Model):
"""
self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
- schedule = self._get_fclk_schedule_for_date(date)
- if schedule and schedule.state == 'posted':
+ date_obj = fields.Date.to_date(date)
+
+ # All POSTED, assigned (non-open) rows for the day. The model now allows
+ # split shifts, so resolve several rows into one work-window that the
+ # whole attendance pipeline keys off — earliest start to latest end.
+ posted = Schedule.search([
+ ('employee_id', '=', self.id),
+ ('schedule_date', '=', date_obj),
+ ('state', '=', 'posted'),
+ ('is_open', '=', False),
+ ]) if date_obj else Schedule.browse()
+ working = posted.filtered(lambda s: not s.is_off)
+ if working:
+ start = min(working.mapped('start_time'))
+
+ def _eff_end(s):
+ return (s.end_time + 24.0) if s.crosses_midnight else s.end_time
+ win_end_eff = max(_eff_end(s) for s in working)
+ crosses = win_end_eff > 24.0
+ end = win_end_eff - 24.0 if crosses else win_end_eff
return {
'source': 'schedule',
- 'schedule_id': schedule.id,
- 'scheduled': not schedule.is_off,
- 'is_off': schedule.is_off,
- 'start_time': schedule.start_time,
- 'end_time': schedule.end_time,
- 'break_minutes': schedule.break_minutes,
- 'hours': schedule.planned_hours,
- 'label': schedule.fclk_display_value(),
+ 'schedule_id': working[0].id,
+ 'scheduled': True,
+ 'is_off': False,
+ 'start_time': start,
+ 'end_time': end,
+ 'break_minutes': sum(working.mapped('break_minutes')),
+ 'hours': sum(working.mapped('planned_hours')),
+ 'crosses_midnight': crosses,
+ 'label': '%s - %s' % (
+ Schedule.fclk_float_to_display(start),
+ Schedule.fclk_float_to_display(end),
+ ),
+ }
+ if posted: # every posted row for the day is OFF
+ return {
+ 'source': 'schedule',
+ 'schedule_id': posted[0].id,
+ 'scheduled': False,
+ 'is_off': True,
+ 'start_time': 0.0,
+ 'end_time': 0.0,
+ 'break_minutes': 0.0,
+ 'hours': 0.0,
+ 'crosses_midnight': False,
+ 'label': 'OFF',
}
shift = self.x_fclk_shift_id
if shift and shift.covers_weekday(date):
- hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
+ crosses = shift.end_time <= shift.start_time
+ raw = ((24.0 - shift.start_time) + shift.end_time) if crosses else (shift.end_time - shift.start_time)
+ hours = max(raw - (shift.break_minutes / 60.0), 0.0)
return {
'source': 'shift',
'schedule_id': False,
@@ -198,6 +263,7 @@ class HrEmployee(models.Model):
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': hours,
+ 'crosses_midnight': crosses,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
@@ -214,6 +280,7 @@ class HrEmployee(models.Model):
'schedule_id': False,
'scheduled': False,
'is_off': False,
+ 'crosses_midnight': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
@@ -292,6 +359,9 @@ class HrEmployee(models.Model):
local_out = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
)
+ # Overnight shift: scheduled clock-out lands on the following day.
+ if plan.get('crosses_midnight'):
+ local_out = local_out + timedelta(days=1)
scheduled_in = local_in.astimezone(utc).replace(tzinfo=None)
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
diff --git a/fusion_clock/models/res_company.py b/fusion_clock/models/res_company.py
index 7daa8453..c6914193 100644
--- a/fusion_clock/models/res_company.py
+++ b/fusion_clock/models/res_company.py
@@ -14,3 +14,14 @@ class ResCompany(models.Model):
domain="[('company_id', '=', id)]",
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
)
+ fclk_planning_generation_months = fields.Integer(
+ string='Schedule Generation Horizon (months)',
+ default=6,
+ help="How many months ahead recurring shifts are pre-generated.",
+ )
+ fclk_self_unassign_days_before = fields.Integer(
+ string='Self-Unassign Cutoff (days before shift)',
+ default=1,
+ help="Employees may release an open shift they claimed up to this many "
+ "days before it starts.",
+ )
diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py
index d00c53e3..2e854a5a 100644
--- a/fusion_clock/models/res_config_settings.py
+++ b/fusion_clock/models/res_config_settings.py
@@ -245,6 +245,19 @@ class ResConfigSettings(models.TransientModel):
help="Which clock location is bound to the NFC kiosk for this company. "
"Required when the kiosk is enabled.",
)
+ fclk_planning_generation_months = fields.Integer(
+ related='company_id.fclk_planning_generation_months',
+ readonly=False,
+ string='Schedule Generation Horizon (months)',
+ help="How many months ahead recurring shifts are pre-generated.",
+ )
+ fclk_self_unassign_days_before = fields.Integer(
+ related='company_id.fclk_self_unassign_days_before',
+ readonly=False,
+ string='Self-Unassign Cutoff (days before shift)',
+ help="Employees may release an open shift they claimed up to this many "
+ "days before it starts.",
+ )
fclk_photo_retention_days = fields.Integer(
string='Auto-Wipe Photos After (days)',
config_parameter='fusion_clock.photo_retention_days',
diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv
index 644cecff..240c705d 100644
--- a/fusion_clock/security/ir.model.access.csv
+++ b/fusion_clock/security/ir.model.access.csv
@@ -28,3 +28,8 @@ access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_sh
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
+access_fusion_clock_role_user,fusion.clock.role.user,model_fusion_clock_role,group_fusion_clock_user,1,0,0,0
+access_fusion_clock_role_manager,fusion.clock.role.manager,model_fusion_clock_role,group_fusion_clock_manager,1,1,1,1
+access_fusion_clock_role_portal,fusion.clock.role.portal,model_fusion_clock_role,base.group_portal,1,0,0,0
+access_fusion_clock_recurrence_user,fusion.clock.schedule.recurrence.user,model_fusion_clock_schedule_recurrence,group_fusion_clock_user,1,0,0,0
+access_fusion_clock_recurrence_manager,fusion.clock.schedule.recurrence.manager,model_fusion_clock_schedule_recurrence,group_fusion_clock_manager,1,1,1,1
diff --git a/fusion_clock/static/src/css/portal_schedule.css b/fusion_clock/static/src/css/portal_schedule.css
new file mode 100644
index 00000000..8b0f93fc
--- /dev/null
+++ b/fusion_clock/static/src/css/portal_schedule.css
@@ -0,0 +1,156 @@
+/* Fusion Planning - Portal Schedule
+ * Inherits Fusion Clock dark-theme tokens (--fclk-card, --fclk-green, etc.)
+ */
+
+/* ---- 4-tab nav fit (keep items grouped at center, just tighter padding) ---- */
+.fclk-nav-item {
+ padding: 8px 19px !important;
+}
+
+/* ---- Next Shift hero card ---- */
+.fpl-next-shift {
+ text-align: center;
+ padding: 20px 16px;
+}
+
+.fpl-next-label {
+ font-size: 11px;
+ color: var(--fclk-text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 6px;
+}
+
+.fpl-next-date {
+ font-size: 18px;
+ color: var(--fclk-text);
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.fpl-next-time {
+ font-size: 32px;
+ color: var(--fclk-green);
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ margin-bottom: 6px;
+}
+
+.fpl-next-role {
+ display: inline-block;
+ font-size: 12px;
+ color: var(--fclk-text-dim);
+ background: rgba(16, 185, 129, 0.08);
+ border: 1px solid rgba(16, 185, 129, 0.18);
+ padding: 4px 12px;
+ border-radius: 12px;
+}
+
+/* ---- Empty state ---- */
+.fpl-empty-card {
+ text-align: center;
+ padding: 28px 16px;
+}
+
+.fpl-empty-icon {
+ margin-bottom: 12px;
+ opacity: 0.7;
+}
+
+.fpl-empty-title {
+ font-size: 16px;
+ color: var(--fclk-text);
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.fpl-empty-sub {
+ font-size: 13px;
+ color: var(--fclk-text-dim);
+}
+
+/* ---- Group headers ---- */
+.fpl-group {
+ margin-bottom: 18px;
+}
+
+.fpl-group-title {
+ font-size: 13px;
+ color: var(--fclk-text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+ margin: 12px 16px 8px;
+}
+
+.fpl-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* ---- Shift item polish ---- */
+.fpl-shift-item .fclk-recent-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.fpl-shift-note {
+ font-size: 11px;
+ color: var(--fclk-text-dim);
+ margin-top: 2px;
+ 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;
+}
diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js
index f725dbe6..9c33e1c2 100644
--- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js
+++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js
@@ -45,7 +45,13 @@ export class FusionClockShiftPlanner extends Component {
error: "",
top: 0,
left: 0,
+ recurring: false,
+ showRepeat: false,
+ 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 () => {
@@ -88,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;
@@ -258,9 +265,212 @@ 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;
+ }
+
+ togglePublishPanel() {
+ this.state.publish.open = !this.state.publish.open;
+ if (this.state.publish.open && !this.state.publish.from) {
+ this.state.publish.from = this.state.weekStart;
+ this.state.publish.to = this.state.weekEnd;
+ }
+ }
+
+ onPublishField(field, ev) {
+ this.state.publish[field] = ev.target.value;
+ }
+
+ async publishRange() {
+ const publish = this.state.publish;
+ this.state.saving = true;
+ try {
+ const result = await rpc("/fusion_clock/shift_planner/publish_range", {
+ date_from: publish.from,
+ date_to: publish.to,
+ message: publish.message,
+ week_start: this.state.weekStart,
+ });
+ if (result.error || result.success === false) {
+ this.notification.add(result.error || result.message || "Could not publish.", {
+ type: "danger",
+ });
+ } else {
+ this._applyData(result.data);
+ this.state.publish.open = false;
+ this.notification.add(
+ `Published ${result.posted} shift(s); notified ${result.notified} employee(s).`,
+ { type: "success" }
+ );
+ }
+ } catch (error) {
+ this.notification.add(error.message || "Could not publish.", { type: "danger" });
+ }
+ 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;
diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
index 735c45c0..064769f6 100644
--- a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
+++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
@@ -217,6 +217,131 @@
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__publish-panel {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin: 0 10px 10px;
+ padding: 10px 12px;
+ background: var(--fclk-planner-card, #ffffff);
+ border: 1px solid var(--fclk-planner-border, #d8dadd);
+ border-radius: 6px;
+
+ .fclk-planner__publish-msg {
+ flex: 1 1 220px;
+ min-width: 160px;
+ }
+}
+
+.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;
+ 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 {
diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
index e3a96537..a7f50d01 100644
--- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
+++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
@@ -32,6 +32,52 @@
Post Schedule
( draft)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Open Shifts (employees can claim)
+
@@ -115,6 +161,13 @@
+
+
+
+
@@ -182,12 +235,63 @@
+
+
+
+
+
+
+
+
|