This commit is contained in:
gsinghpal
2026-05-23 07:53:41 -04:00
parent 27e12dd544
commit 005daade55
50 changed files with 3300 additions and 42 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
]
}
}

358
fusion_clock/CLAUDE.md Normal file
View File

@@ -0,0 +1,358 @@
# Fusion Clock - Claude Code Instructions
> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module.
## 1. What This Module Is
- **Name**: Fusion Clock.
- **Version**: `19.0.3.3.0`.
- **Category**: Human Resources/Attendances.
- **License**: OPL-1, Nexa Systems Inc.
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
- **Top-level menu**: `Fusion Clock`.
- **Main surfaces**:
- Portal clock page at `/my/clock`.
- Portal timesheets at `/my/clock/timesheets`.
- Portal reports at `/my/clock/reports`.
- Shared PIN kiosk at `/fusion_clock/kiosk`.
- NFC tap kiosk at `/fusion_clock/kiosk/nfc`.
- Backend systray clock widget.
- Backend manager/team-lead dashboard client action.
Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs.
## 2. Dependencies
Declared in `__manifest__.py`:
```
hr_attendance, hr, portal, mail, resource
```
External Python used directly:
- `pytz` for timezone-safe local day boundaries.
- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata.
- `dateutil.relativedelta` inside pay-period calculations.
External browser APIs:
- Browser geolocation.
- `ipapi.co` fallback geolocation in frontend/backend clock widgets.
- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured.
- Web NFC and camera APIs for the NFC kiosk.
## 3. Naming And Field Prefixes
This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`.
Examples:
- `hr.employee.x_fclk_enable_clock`
- `hr.employee.x_fclk_nfc_card_uid`
- `hr.attendance.x_fclk_clock_source`
- `res.company.x_fclk_nfc_kiosk_location_id`
New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to.
## 4. Model Map
Custom models:
| Model | File | Purpose |
|---|---|---|
| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. |
| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. |
| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. |
| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. |
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
Inherited models:
- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links.
- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields.
- `res.config.settings`: all `fusion_clock.*` settings.
- `res.company`: NFC kiosk location binding.
Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly.
## 5. Clocking Flow
Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`.
Clock-in flow:
1. Resolve current user to `hr.employee`.
2. Block if `x_fclk_enable_clock` is false.
3. If `x_fclk_pending_reason` is true, return `requires_reason`.
4. Verify location against allowed active `fusion.clock.location` records.
5. Call Odoo's `_attendance_action_change()`.
6. Write location, distance, source, and optional photo to `hr.attendance`.
7. Log `clock_in`.
8. Create `late_in` penalty when outside grace.
9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100.
10. Notify office user for very-late clock-ins.
Clock-out flow:
1. Verify location again.
2. Call `_attendance_action_change()`.
3. Write out-distance.
4. Apply break deduction when configured.
5. Create `early_out` penalty when outside grace.
6. Log `clock_out`.
7. Log overtime if computed overtime is positive.
Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`.
## 6. Kiosk And NFC
Classic kiosk:
- Page: `/fusion_clock/kiosk`
- JSON routes:
- `/fusion_clock/kiosk/search`
- `/fusion_clock/kiosk/verify_pin`
- `/fusion_clock/kiosk/clock`
- Requires `fusion_clock.group_fusion_clock_manager`.
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
- Uses `hr.employee.x_fclk_kiosk_pin`.
NFC kiosk:
- Page: `/fusion_clock/kiosk/nfc`
- JSON routes:
- `/fusion_clock/kiosk/nfc/enroll`
- `/fusion_clock/kiosk/nfc/tap`
- `/fusion_clock/kiosk/nfc/employee_search`
- Requires `fusion_clock.group_fusion_clock_manager`.
- Controlled by:
- `fusion_clock.enable_nfc_kiosk`
- `fusion_clock.nfc_photo_required`
- `fusion_clock.nfc_enroll_password`
- `fusion_clock.nfc_kiosk_debug`
- `res.company.x_fclk_nfc_kiosk_location_id`
- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`.
- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard.
- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`.
- Photo data URLs are stripped before writing binary fields.
- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`.
Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint.
## 7. Reports And Payroll Export
`fusion.clock.report` supports:
- Employee reports when `employee_id` is set.
- Batch reports when `employee_id` is empty.
- PDF generation through QWeb reports:
- `fusion_clock.action_report_clock_employee`
- `fusion_clock.action_report_clock_batch`
- CSV export via `action_export_csv()`.
- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`.
- Email send with generated PDF attached.
Pay period types:
```
weekly, biweekly, semi_monthly, monthly
```
The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format.
Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end.
## 8. Scheduled Automation
Configured in `data/ir_cron_data.xml`:
| Cron | Model method | Frequency |
|---|---|---|
| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes |
| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily |
| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily |
| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes |
| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays |
Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again.
Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists.
## 9. Security
Groups:
- `group_fusion_clock_user`
- `group_fusion_clock_team_lead`
- `group_fusion_clock_manager`
Admin is auto-assigned to manager in `security/security.xml`.
Access pattern:
- Users and portal users can read their own clock data.
- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data.
- Managers have full model access and all configuration/kiosk/report menus.
- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`.
Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee.
## 10. Frontend Assets
Frontend bundle:
- `static/src/css/portal_clock.css`
- `static/src/scss/nfc_kiosk.scss`
- `static/src/js/fusion_clock_portal.js`
- `static/src/js/fusion_clock_kiosk.js`
- `static/src/js/fusion_clock_nfc_kiosk.js`
Backend bundle:
- `static/src/scss/fusion_clock.scss`
- `static/src/js/fusion_clock_systray.js`
- `static/src/xml/systray_clock.xml`
- `static/src/js/fusion_clock_dashboard.js`
- `static/src/xml/fusion_clock_dashboard.xml`
- `static/src/js/fusion_clock_location_map.js`
- `static/src/js/fusion_clock_location_places.js`
- `static/src/xml/fusion_clock_location.xml`
Patterns:
- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`.
- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`.
- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`.
- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`.
- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`.
Known technical debt:
- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern.
- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance.
- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work.
## 11. Settings Keys
Important `ir.config_parameter` keys:
```
fusion_clock.default_clock_in_time
fusion_clock.default_clock_out_time
fusion_clock.default_break_minutes
fusion_clock.auto_deduct_break
fusion_clock.break_threshold_hours
fusion_clock.enable_auto_clockout
fusion_clock.grace_period_minutes
fusion_clock.max_shift_hours
fusion_clock.enable_penalties
fusion_clock.penalty_grace_minutes
fusion_clock.penalty_deduction_minutes
fusion_clock.enable_overtime
fusion_clock.daily_overtime_threshold
fusion_clock.weekly_overtime_threshold
fusion_clock.office_user_id
fusion_clock.very_late_threshold_minutes
fusion_clock.max_monthly_absences
fusion_clock.enable_employee_notifications
fusion_clock.reminder_before_shift_minutes
fusion_clock.reminder_before_end_minutes
fusion_clock.send_weekly_summary
fusion_clock.enable_ip_fallback
fusion_clock.enable_photo_verification
fusion_clock.google_maps_api_key
fusion_clock.enable_kiosk
fusion_clock.kiosk_pin_required
fusion_clock.enable_correction_requests
fusion_clock.enable_sounds
fusion_clock.pay_period_type
fusion_clock.pay_period_start
fusion_clock.auto_generate_reports
fusion_clock.send_employee_reports
fusion_clock.report_recipient_user_ids
fusion_clock.report_recipient_emails
fusion_clock.csv_column_mapping
fusion_clock.enable_nfc_kiosk
fusion_clock.nfc_photo_required
fusion_clock.nfc_enroll_password
fusion_clock.nfc_kiosk_debug
```
`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`.
## 12. Routes
HTTP pages:
```
/my/clock
/my/clock/timesheets
/my/clock/reports
/my/clock/reports/<report_id>/download
/fusion_clock/kiosk
/fusion_clock/kiosk/nfc
```
JSON-RPC endpoints:
```
/fusion_clock/verify_location
/fusion_clock/clock_action
/fusion_clock/submit_reason
/fusion_clock/request_leave
/fusion_clock/request_correction
/fusion_clock/get_status
/fusion_clock/get_locations
/fusion_clock/get_settings
/fusion_clock/dashboard_data
/fusion_clock/kiosk/search
/fusion_clock/kiosk/verify_pin
/fusion_clock/kiosk/clock
/fusion_clock/kiosk/nfc/enroll
/fusion_clock/kiosk/nfc/tap
/fusion_clock/kiosk/nfc/employee_search
```
All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
## 13. Gotchas
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles.
## 14. Tests
Tests are post-install tagged:
```
@tagged('-at_install', 'post_install', 'fusion_clock')
```
Coverage currently focuses on NFC:
- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field.
- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search.
Run locally:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init
```
For a normal module upgrade:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init
```

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.3.0',
'version': '19.0.3.5.6',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml',
'views/clock_menus.xml',
@@ -89,15 +90,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
],
'web.assets_backend': [
'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss',
'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss',
'fusion_clock/static/src/scss/fusion_clock.scss',
'fusion_clock/static/src/js/fusion_clock_systray.js',
'fusion_clock/static/src/xml/systray_clock.xml',
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
'fusion_clock/static/src/js/fusion_clock_shift_planner.js',
'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml',
'fusion_clock/static/src/js/fusion_clock_location_map.js',
'fusion_clock/static/src/js/fusion_clock_location_places.js',
'fusion_clock/static/src/xml/fusion_clock_location.xml',
],
'web.assets_web_dark': [
'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss',
],
},
'installable': True,
'auto_install': False,

Binary file not shown.

View File

@@ -4,3 +4,4 @@ from . import portal_clock
from . import clock_api
from . import clock_kiosk
from . import clock_nfc_kiosk
from . import shift_planner

View File

@@ -5,6 +5,7 @@
import base64
import math
import logging
import pytz
from datetime import datetime, timedelta
from odoo import http, fields, _
from odoo.http import request
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
@@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller):
worked = attendance.worked_hours or 0.0
if worked >= threshold:
break_min = employee._get_fclk_break_minutes()
local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': latitude,
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
source=source,
)
if is_scheduled_off:
self._log_activity(
employee, 'unscheduled_shift',
f"Clocked in on a scheduled OFF day at {location.name}.",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
)
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id:
request.env['hr.attendance'].sudo()._fclk_notify_office(
office_user_id,
f"Unscheduled Shift: {employee.name}",
f"{employee.name} clocked in on a scheduled OFF day.",
'hr.attendance',
attendance.id,
)
return {
'success': True,
'action': 'clock_in',
'attendance_id': attendance.id,
'check_in': fields.Datetime.to_string(attendance.check_in),
'location_name': location.name,
'location_address': location.address or '',
'message': f'Clocked in at {location.name} (unscheduled shift)',
'streak': employee.x_fclk_ontime_streak,
}
# Check for late clock-in penalty
scheduled_in, _ = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
@@ -359,8 +402,9 @@ class FusionClockAPI(http.Controller):
self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
# Log clock-out
self._log_activity(
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
'pending_reason': employee.x_fclk_pending_reason,
'ontime_streak': employee.x_fclk_ontime_streak,
}
local_today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(local_today)
result.update({
'scheduled_shift': day_plan.get('label') or '',
'scheduled_hours': round(day_plan.get('hours') or 0.0, 2),
'scheduled_off': bool(day_plan.get('is_off')),
})
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
@@ -533,7 +584,6 @@ class FusionClockAPI(http.Controller):
'location_id': att.x_fclk_location_id.id or False,
})
local_today = get_local_today(request.env, employee)
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),

View File

@@ -5,6 +5,7 @@
import logging
from odoo import http, fields, _
from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__)
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': latitude,
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
source='kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',
)
else:
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',

View File

@@ -8,6 +8,7 @@ import time
import threading
from odoo import fields, http
from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__)
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
@@ -183,7 +184,9 @@ class FusionClockNfcKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': 0,
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
else:
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
'action': 'clock_in',
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",

View File

@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
], limit=1)
# Today stats
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
today = get_local_today(request.env, employee)
today_schedule = employee._get_fclk_day_plan(today)
today_start, _ = get_local_day_boundaries(request.env, today, employee)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_in', '>=', today_start),
@@ -109,7 +111,6 @@ class FusionClockPortal(CustomerPortal):
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
# Week stats
today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday())
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
week_atts = request.env['hr.attendance'].sudo().search([
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
'current_attendance': current_attendance,
'today_hours': round(today_hours, 1),
'week_hours': round(week_hours, 1),
'today_schedule': today_schedule,
'recent_attendances': recent,
'google_maps_key': google_maps_key,
'enable_sounds': enable_sounds,

View File

@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import io
from collections import defaultdict
from datetime import timedelta
from odoo import fields, http, _
from odoo.exceptions import ValidationError
from odoo.http import request
class FusionClockShiftPlanner(http.Controller):
"""Backend JSON-RPC API for the Excel-style weekly shift planner."""
def _check_manager(self):
return request.env.user.has_group('fusion_clock.group_fusion_clock_manager')
def _week_start(self, week_start=None):
date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today()
return date_obj - timedelta(days=date_obj.weekday())
def _manager_employees(self):
return request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
], order='department_id, name')
def _load_week_data(self, week_start=None):
start = self._week_start(week_start)
days = [start + timedelta(days=i) for i in range(7)]
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
schedules = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', days[-1]),
])
schedule_map = {
(schedule.employee_id.id, schedule.schedule_date): schedule
for schedule in schedules
}
grouped = defaultdict(list)
for employee in employees:
grouped[employee.department_id.id or 0].append(employee)
departments = []
employee_rows = []
for department_id, department_employees in grouped.items():
department = department_employees[0].department_id
departments.append({
'id': department_id,
'name': department.name if department else _('No Department'),
'employee_ids': [emp.id for emp in department_employees],
})
for employee in department_employees:
cells = {}
for day in days:
cells[str(day)] = Schedule.fclk_cell_payload(
employee,
day,
schedule_map.get((employee.id, day)),
)
employee_rows.append({
'id': employee.id,
'name': employee.name,
'department_id': department_id,
'department_name': department.name if department else _('No Department'),
'job_title': employee.job_title or '',
'cells': cells,
})
shifts = request.env['fusion.clock.shift'].sudo().search([
('active', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
], order='sequence, name')
return {
'week_start': str(start),
'week_end': str(days[-1]),
'days': [{
'date': str(day),
'weekday': day.strftime('%a').upper(),
'label': day.strftime('%d-%b'),
} for day in days],
'departments': departments,
'employees': employee_rows,
'shifts': [{
'id': shift.id,
'name': shift.name,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': shift.scheduled_hours,
'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours),
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
'option_label': '%s (%s - %s)' % (
shift.name,
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
} for shift in shifts],
}
@http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST'])
def load(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
return self._load_week_data(week_start)
@http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST'])
def save(self, week_start=None, changes=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
employees = self._manager_employees()
employee_map = {employee.id: employee for employee in employees}
Schedule = request.env['fusion.clock.schedule'].sudo()
errors = []
saved = 0
for change in changes or []:
employee_id = int(change.get('employee_id') or 0)
employee = employee_map.get(employee_id)
date_str = change.get('date')
if not employee:
errors.append({
'employee_id': employee_id,
'date': date_str,
'message': 'Employee not found or not allowed.',
})
continue
try:
Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user)
saved += 1
except ValidationError as exc:
errors.append({
'employee_id': employee_id,
'date': date_str,
'message': str(exc.args[0] if exc.args else exc),
})
if errors:
return {'success': False, 'saved': saved, 'errors': errors}
return {
'success': True,
'saved': saved,
'data': self._load_week_data(week_start),
}
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
def copy_previous_week(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
start = self._week_start(week_start)
prev_start = start - timedelta(days=7)
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
prev_schedules = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', prev_start),
('schedule_date', '<=', prev_start + timedelta(days=6)),
])
prev_map = {
(schedule.employee_id.id, schedule.schedule_date): schedule
for schedule in prev_schedules
}
before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
for employee in employees:
for offset in range(7):
source_date = prev_start + timedelta(days=offset)
target_date = start + timedelta(days=offset)
source = prev_map.get((employee.id, source_date))
if not source:
payload = {'input': ''}
elif source.is_off:
payload = {'input': 'OFF'}
elif source.shift_id:
payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()}
else:
payload = {
'input': source.fclk_display_value(),
'start_time': source.start_time,
'end_time': source.end_time,
'break_minutes': source.break_minutes,
}
Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user)
after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
return {
'success': True,
'changed': after_count - before_count,
'data': self._load_week_data(start),
}
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
def export_xlsx(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
data = self._load_week_data(week_start)
output = io.BytesIO()
import xlsxwriter
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet('Shift Planner')
fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1})
fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1})
fmt_employee = workbook.add_format({'bold': True, 'border': 1})
fmt_shift = workbook.add_format({'border': 1})
fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'})
fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1})
sheet.set_column(0, 0, 22)
for col in range(1, 15, 2):
sheet.set_column(col, col, 24)
sheet.set_column(col + 1, col + 1, 9)
sheet.write(0, 0, 'EMPLOYEE', fmt_day)
col = 1
for day in data['days']:
sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day)
sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day)
sheet.write(2, col, 'Shift', fmt_sub)
sheet.write(2, col + 1, 'Hours', fmt_sub)
col += 2
sheet.write(2, 0, 'EMPLOYEE', fmt_sub)
row = 3
employee_by_id = {emp['id']: emp for emp in data['employees']}
for department in data['departments']:
sheet.merge_range(row, 0, row, 14, department['name'], fmt_department)
row += 1
for employee_id in department['employee_ids']:
employee = employee_by_id[employee_id]
sheet.write(row, 0, employee['name'], fmt_employee)
col = 1
for day in data['days']:
cell = employee['cells'][day['date']]
sheet.write(row, col, cell.get('label') or '', fmt_shift)
sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours)
col += 2
row += 1
workbook.close()
output.seek(0)
filename = 'shift_planner_%s.xlsx' % data['week_start']
attachment = request.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(output.read()),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
return {
'success': True,
'attachment_id': attachment.id,
'filename': filename,
'url': '/web/content/%s?download=true' % attachment.id,
}

View File

@@ -9,5 +9,6 @@ from . import res_config_settings
from . import clock_activity_log
from . import clock_leave_request
from . import clock_shift
from . import clock_schedule
from . import clock_correction
from . import res_company

View File

@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'),
('unscheduled_shift', 'Unscheduled Shift'),
('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'),
],
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
'correction_request': 'Correction Request',
'ip_fallback': 'IP Fallback Used',
'streak_milestone': 'Streak Milestone',
'unscheduled_shift': 'Unscheduled Shift',
}
@api.depends('latitude', 'longitude')

View File

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import re
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
_description = 'Clock Shift Schedule Entry'
_order = 'schedule_date, employee_id'
_rec_name = 'display_name'
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Date',
required=True,
index=True,
)
shift_id = fields.Many2one(
'fusion.clock.shift',
string='Shift Template',
ondelete='set null',
)
is_off = fields.Boolean(
string='Off',
default=False,
index=True,
)
start_time = fields.Float(
string='Start Time',
default=9.0,
)
end_time = fields.Float(
string='End Time',
default=17.0,
)
break_minutes = fields.Float(
string='Break (min)',
default=30.0,
)
planned_hours = fields.Float(
string='Hours',
compute='_compute_planned_hours',
store=True,
)
note = fields.Char(string='Note')
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
readonly=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
related='employee_id.department_id',
store=True,
readonly=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
'Only one shift schedule is allowed per employee per day.',
)
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
def _compute_planned_hours(self):
for rec in self:
if rec.is_off:
rec.planned_hours = 0.0
continue
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
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')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = str(rec.schedule_date) if rec.schedule_date else ''
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
def _check_schedule_times(self):
for rec in self:
if rec.break_minutes < 0:
raise ValidationError(_("Break minutes cannot be negative."))
if rec.is_off:
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 <= 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
if rec.break_minutes >= shift_minutes:
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
@api.onchange('shift_id')
def _onchange_shift_id(self):
for rec in self:
if rec.shift_id:
rec.is_off = False
rec.start_time = rec.shift_id.start_time
rec.end_time = rec.shift_id.end_time
rec.break_minutes = rec.shift_id.break_minutes
@api.model
def fclk_float_to_display(self, value):
value = float(value or 0.0)
hour = int(value)
minute = int(round((value - hour) * 60))
if minute == 60:
hour += 1
minute = 0
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
display_hour = hour % 12
if display_hour == 0:
display_hour = 12
return f"{display_hour}:{minute:02d} {suffix}"
def fclk_display_value(self):
self.ensure_one()
if self.is_off:
return 'OFF'
return (
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
)
@api.model
def fclk_hours_display(self, hours):
hours = float(hours or 0.0)
whole = int(hours)
minutes = int(round((hours - whole) * 60))
if minutes == 60:
whole += 1
minutes = 0
return f"{whole}:{minutes:02d}"
@api.model
def _fclk_parse_time_part(self, raw):
text = (raw or '').strip().lower().replace('.', '')
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
if not match:
raise ValidationError(_("Could not understand time '%s'.") % raw)
hour = int(match.group(1))
minute = int(match.group(2) or 0)
meridiem = match.group(3)
if minute < 0 or minute > 59:
raise ValidationError(_("Minutes must be between 00 and 59."))
if meridiem:
if hour < 1 or hour > 12:
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
if meridiem == 'am':
hour = 0 if hour == 12 else hour
else:
hour = 12 if hour == 12 else hour + 12
elif hour > 24:
raise ValidationError(_("Hours must be between 0 and 24."))
return hour + (minute / 60.0)
@api.model
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
text = (input_value or '').strip()
if not text:
return {'clear': True}
if text.upper() == 'OFF':
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
normalized = (
text.replace('', '-')
.replace('', '-')
.replace(' to ', '-')
.replace(' TO ', '-')
)
parts = [p.strip() for p in normalized.split('-', 1)]
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
start = self._fclk_parse_time_part(parts[0])
end = self._fclk_parse_time_part(parts[1])
if end <= start and end + 12 <= 24:
end += 12
if end <= start:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': start,
'end_time': end,
'break_minutes': float(default_break_minutes or 0.0),
}
@api.model
def fclk_values_from_planner_payload(self, payload, employee):
payload = payload or {}
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
if payload.get('is_off'):
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': float(payload.get('start_time') or 0.0),
'end_time': float(payload.get('end_time') or 0.0),
'break_minutes': float(payload.get('break_minutes') or 0.0),
}
shift_id = int(payload.get('shift_id') or 0)
if shift_id:
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
if not shift.exists():
raise ValidationError(_("Selected shift template no longer exists."))
return {
'clear': False,
'shift_id': shift.id,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
}
default_break = employee._get_fclk_break_minutes() if employee else 30.0
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
@api.model
def fclk_snapshot(self, schedule):
if not schedule:
return ''
return schedule.fclk_display_value()
@api.model
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
self = self.sudo()
employee = employee.sudo()
date_obj = fields.Date.to_date(schedule_date)
if not employee.exists() or not date_obj:
raise ValidationError(_("Invalid employee or schedule date."))
existing = self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
old_value = self.fclk_snapshot(existing)
parsed = self.fclk_values_from_planner_payload(payload, employee)
if parsed.get('clear'):
if existing:
existing.unlink()
new_schedule = self.browse()
new_value = ''
else:
vals = {
'employee_id': employee.id,
'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or 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,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
}
if existing:
existing.write(vals)
new_schedule = existing
else:
new_schedule = self.create(vals)
new_value = new_schedule.fclk_display_value()
if old_value != new_value:
self.env['fusion.clock.schedule.audit'].sudo().create({
'schedule_id': new_schedule.id if new_schedule else False,
'employee_id': employee.id,
'schedule_date': date_obj,
'old_value': old_value,
'new_value': new_value,
'changed_by_id': (user or self.env.user).id,
'changed_at': fields.Datetime.now(),
'company_id': employee.company_id.id,
'department_id': employee.department_id.id,
})
return new_schedule
@api.model
def fclk_cell_payload(self, employee, date_obj, schedule=None):
schedule = schedule or self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
Schedule = self.env['fusion.clock.schedule']
if schedule:
return {
'schedule_id': schedule.id,
'source': 'schedule',
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
'shift_id': schedule.shift_id.id or False,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '',
}
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
'shift_id': False,
'start_time': plan.get('start_time') or 0.0,
'end_time': plan.get('end_time') or 0.0,
'break_minutes': plan.get('break_minutes') or 0.0,
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
}
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'
_description = 'Clock Schedule Change Audit'
_order = 'changed_at desc, id desc'
_rec_name = 'display_name'
schedule_id = fields.Many2one(
'fusion.clock.schedule',
string='Schedule',
ondelete='set null',
index=True,
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Schedule Date',
required=True,
index=True,
)
old_value = fields.Char(string='Old Value')
new_value = fields.Char(string='New Value')
changed_by_id = fields.Many2one(
'res.users',
string='Changed By',
required=True,
ondelete='restrict',
)
changed_at = fields.Datetime(
string='Changed At',
default=fields.Datetime.now,
required=True,
index=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
index=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
index=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
def _compute_display_name(self):
for rec in self:
rec.display_name = "%s - %s: %s -> %s" % (
rec.employee_id.name or '',
rec.schedule_date or '',
rec.old_value or 'blank',
rec.new_value or 'blank',
)

View File

@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
continue
employee = att.employee_id
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
scheduled_hours = daily_threshold
if employee:
local_date = get_local_today(self.env, employee)
if att.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date()
scheduled_hours = employee._get_fclk_scheduled_hours(local_date)
net = att.x_fclk_net_hours or 0.0
if net > scheduled_hours:
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
max_deadline = check_in + timedelta(hours=max_shift)
effective_deadline = min(deadline, max_deadline)
day_plan = employee._get_fclk_day_plan(check_in_date)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
effective_deadline = max_deadline
else:
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
effective_deadline = min(deadline, max_deadline)
if now > effective_deadline:
clock_out_time = min(effective_deadline, now)
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
# Apply break deduction
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
if (att.worked_hours or 0) >= threshold:
break_min = employee._get_fclk_break_minutes()
break_min = employee._get_fclk_break_minutes(check_in_date)
att.sudo().write({'x_fclk_break_minutes': break_min})
att.sudo().message_post(
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
if yesterday.weekday() >= 5:
continue
day_plan = emp._get_fclk_day_plan(yesterday)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
if today.weekday() >= 5:
continue
day_plan = emp._get_fclk_day_plan(today)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
if emp.x_fclk_last_reminder_date == today:
continue

View File

@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _get_fclk_break_minutes(self):
"""Return effective break minutes for this employee.
Priority: employee override > shift > global setting.
def _get_fclk_schedule_for_date(self, date):
"""Return this employee's dated Fusion Clock schedule for a local date."""
self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return self.env['fusion.clock.schedule']
return self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.id),
('schedule_date', '=', date_obj),
], limit=1)
def _get_fclk_day_plan(self, date):
"""Return the effective plan for a local date.
Dated schedules are the source of truth. If none exists, the legacy
employee shift/global settings remain the fallback.
"""
self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
schedule = self._get_fclk_schedule_for_date(date)
if schedule:
return {
'source': 'schedule',
'schedule_id': schedule.id,
'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(),
}
if self.x_fclk_shift_id:
shift = self.x_fclk_shift_id
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'schedule_id': False,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
}
ICP = self.env['ir.config_parameter'].sudo()
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'schedule_id': False,
'is_off': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(start_time),
Schedule.fclk_float_to_display(end_time),
),
}
def _get_fclk_break_minutes(self, date=None):
"""Return effective break minutes for this employee.
Priority: dated schedule > employee override > shift > global setting.
"""
self.ensure_one()
if date:
plan = self._get_fclk_day_plan(date)
if plan.get('source') == 'schedule' and not plan.get('is_off'):
return plan.get('break_minutes') or 0.0
if self.x_fclk_break_minutes > 0:
return self.x_fclk_break_minutes
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
@@ -138,7 +209,7 @@ class HrEmployee(models.Model):
def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
Uses employee shift if assigned, otherwise global settings.
Uses dated schedule first, employee shift second, then global settings.
The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``.
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
import pytz
self.ensure_one()
if self.x_fclk_shift_id:
in_hour = self.x_fclk_shift_id.start_time
out_hour = self.x_fclk_shift_id.end_time
else:
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
plan = self._get_fclk_day_plan(date)
in_hour = plan.get('start_time') or 0.0
out_hour = plan.get('end_time') or 0.0
in_h = int(in_hour)
in_m = int((in_hour - in_h) * 60)
@@ -179,16 +246,13 @@ class HrEmployee(models.Model):
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self):
def _get_fclk_scheduled_hours(self, date=None):
"""Return the expected work hours for this employee's shift."""
self.ensure_one()
if self.x_fclk_shift_id:
return self.x_fclk_shift_id.scheduled_hours
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_hrs = self._get_fclk_break_minutes() / 60.0
return max((out_hour - in_hour) - break_hrs, 0.0)
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
if plan.get('is_off'):
return 0.0
return plan.get('hours') or 0.0
def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo()

View File

@@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0
access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
@@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fusion_clock_leave_request_manager fusion.clock.leave.request.manager model_fusion_clock_leave_request group_fusion_clock_manager 1 1 1 1
12 access_fusion_clock_shift_user fusion.clock.shift.user model_fusion_clock_shift group_fusion_clock_user 1 0 0 0
13 access_fusion_clock_shift_manager fusion.clock.shift.manager model_fusion_clock_shift group_fusion_clock_manager 1 1 1 1
14 access_fusion_clock_schedule_user fusion.clock.schedule.user model_fusion_clock_schedule group_fusion_clock_user 1 0 0 0
15 access_fusion_clock_schedule_manager fusion.clock.schedule.manager model_fusion_clock_schedule group_fusion_clock_manager 1 1 1 1
16 access_fusion_clock_schedule_audit_manager fusion.clock.schedule.audit.manager model_fusion_clock_schedule_audit group_fusion_clock_manager 1 0 0 0
17 access_fusion_clock_correction_user fusion.clock.correction.user model_fusion_clock_correction group_fusion_clock_user 1 0 0 0
18 access_fusion_clock_correction_manager fusion.clock.correction.manager model_fusion_clock_correction group_fusion_clock_manager 1 1 1 1
19 access_fusion_clock_location_portal fusion.clock.location.portal model_fusion_clock_location base.group_portal 1 0 0 0
25 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
26 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 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

View File

@@ -174,6 +174,49 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Record Rules - Dated Schedules
================================================================ -->
<record id="rule_schedule_user" model="ir.rule">
<field name="name">Schedule: User sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_team_lead" model="ir.rule">
<field name="name">Schedule: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_manager" model="ir.rule">
<field name="name">Schedule: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<record id="rule_schedule_audit_manager" model="ir.rule">
<field name="name">Schedule Audit: Manager reads all</field>
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================
Record Rules - Correction Request
================================================================ -->
@@ -286,4 +329,15 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_portal" model="ir.rule">
<field name="name">Schedule: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

View File

@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
opacity: 0.5;
}
/* ---- Scheduled Shift Card ---- */
.fclk-schedule-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 14px;
padding: 14px 16px;
margin: -14px 0 28px;
box-shadow: var(--fclk-shadow);
}
.fclk-schedule-icon {
width: 38px;
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(59, 130, 246, 0.12);
color: var(--fclk-blue);
font-size: 16px;
}
.fclk-schedule-info {
min-width: 0;
flex: 1;
}
.fclk-schedule-label {
color: var(--fclk-text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fclk-schedule-value {
color: var(--fclk-text);
font-size: 14px;
font-weight: 650;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-schedule-hours {
color: var(--fclk-text);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ---- Timer Section ---- */
.fclk-timer-section {
text-align: center;

View File

@@ -0,0 +1,741 @@
/** @odoo-module **/
import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class FusionClockShiftPlanner extends Component {
static template = "fusion_clock.ShiftPlanner";
static props = [];
setup() {
this.notification = useService("notification");
this.dirtyCells = {};
this.root = useRef("root");
this.editorRef = useRef("shiftEditor");
this.activeCellAnchor = null;
this.activeEditorEmployee = null;
this.activeEditorDay = null;
this.timeOptions = this._buildTimeOptions();
this.state = useState({
loading: true,
saving: false,
weekStart: "",
weekEnd: "",
days: [],
departments: [],
employees: [],
shifts: [],
error: "",
dirtyCount: 0,
invalidCount: 0,
collapsed: {},
editor: {
open: false,
employeeId: false,
employeeName: "",
date: "",
dayLabel: "",
startValue: "9.00",
endValue: "17.00",
breakMinutes: 30,
hoursDisplay: "7:30",
error: "",
top: 0,
left: 0,
},
});
onWillStart(async () => {
await this.loadWeek();
});
useExternalListener(
window,
"click",
(ev) => this.onGlobalClick(ev),
{ capture: true }
);
useExternalListener(window, "resize", () => this._positionActiveEditor());
useExternalListener(window, "scroll", () => this._positionActiveEditor(), true);
onPatched(() => {
this._positionActiveEditor();
});
}
async loadWeek(weekStart = null) {
this.state.loading = true;
this.state.error = "";
try {
const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart });
if (data.error) {
this.state.error = data.error;
} else {
this._applyData(data);
}
} catch (error) {
this.state.error = error.message || "Failed to load shift planner.";
}
this.state.loading = false;
}
_applyData(data) {
this.dirtyCells = {};
this.state.weekStart = data.week_start;
this.state.weekEnd = data.week_end;
this.state.days = data.days || [];
this.state.departments = data.departments || [];
this.state.employees = data.employees || [];
this.state.shifts = data.shifts || [];
this.state.dirtyCount = 0;
this.state.invalidCount = 0;
this.state.error = "";
this.closeCellEditor();
}
get weekTitle() {
if (!this.state.weekStart || !this.state.weekEnd) {
return "";
}
return `${this.state.weekStart} to ${this.state.weekEnd}`;
}
getDepartmentEmployees(department) {
const ids = new Set(department.employee_ids || []);
return this.state.employees.filter((employee) => ids.has(employee.id));
}
isCollapsed(department) {
return !!this.state.collapsed[department.id];
}
toggleDepartment(department) {
this.state.collapsed[department.id] = !this.state.collapsed[department.id];
this.closeCellEditor();
}
async previousWeek() {
await this.loadWeek(this._dateAdd(this.state.weekStart, -7));
}
async nextWeek() {
await this.loadWeek(this._dateAdd(this.state.weekStart, 7));
}
async currentWeek() {
await this.loadWeek();
}
async copyPreviousWeek() {
if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) {
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not copy previous week.", { type: "danger" });
}
this.state.saving = false;
}
async save() {
this._recountInvalid();
if (this.state.invalidCount) {
this.notification.add("Fix invalid shift cells before saving.", { type: "danger" });
return;
}
const changes = Object.values(this.dirtyCells);
if (!changes.length) {
this.notification.add("No shift changes to save.", { type: "info" });
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/save", {
week_start: this.state.weekStart,
changes,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else if (!result.success) {
this._markServerErrors(result.errors || []);
this.notification.add("Some shift cells could not be saved.", { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not save shift planner.", { type: "danger" });
}
this.state.saving = false;
}
async exportXlsx() {
try {
const result = await rpc("/fusion_clock/shift_planner/export_xlsx", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
return;
}
window.location = result.url;
} catch (error) {
this.notification.add(error.message || "Could not export shift planner.", { type: "danger" });
}
}
openCellEditor(employee, day, ev) {
if (this.state.loading || this.state.saving) {
return;
}
const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget;
this.activeCellAnchor = anchor;
this.activeEditorEmployee = employee;
this.activeEditorDay = day;
const cell = employee.cells[day.date] || {};
const fallback = this._defaultTimes(employee, day);
const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start);
const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end);
const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30);
const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0);
this.state.editor.open = true;
this.state.editor.employeeId = employee.id;
this.state.editor.employeeName = employee.name;
this.state.editor.date = day.date;
this.state.editor.dayLabel = `${day.weekday} ${day.label}`;
this.state.editor.startValue = this._timeValue(start);
this.state.editor.endValue = this._timeValue(end);
this.state.editor.breakMinutes = breakMinutes;
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
this.state.editor.error = cell.error || "";
this._positionActiveEditor(anchor);
}
closeCellEditor() {
this.state.editor.open = false;
this.activeCellAnchor = null;
this.activeEditorEmployee = null;
this.activeEditorDay = null;
}
onGlobalClick(ev) {
if (!this.state.editor.open) {
return;
}
const target = ev.target;
const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target);
const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target);
if (!clickedEditor && !clickedCell) {
this.closeCellEditor();
}
}
isActiveCell(employee, day) {
return this.state.editor.open
&& this.state.editor.employeeId === employee.id
&& this.state.editor.date === day.date;
}
onCellInput(employee, day, ev) {
this._setCellFromInput(employee, day, ev.target.value, ev.target);
}
onCellKeydown(employee, day, ev) {
if (ev.key === "Escape") {
ev.preventDefault();
this.closeCellEditor();
return;
}
if (ev.key === "Tab") {
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
this.closeCellEditor();
return;
}
if (ev.key === "Enter") {
ev.preventDefault();
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
if (!employee.cells[day.date]?.error) {
this.closeCellEditor();
this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length);
}
}
}
selectQuickShift(option) {
const context = this._activeEditorContext();
if (!context) {
return;
}
let parsed;
if (option.type === "template") {
parsed = {
is_off: false,
shift_id: option.shiftId,
start_time: option.start,
end_time: option.end,
break_minutes: option.breakMinutes,
hours: option.hours,
hours_display: option.hoursDisplay,
label: option.input,
normalized_input: option.input,
};
} else {
parsed = this._parseInput(option.input, context.cell);
}
this._applyParsedToCell(context.employee, context.day, parsed, option.input);
this._syncEditorFromCell(context.employee, context.day);
this.closeCellEditor();
}
clearActiveCell() {
const context = this._activeEditorContext();
if (!context) {
return;
}
this._setCellFromInput(context.employee, context.day, "");
this.closeCellEditor();
}
onEditorStartChange(ev) {
this.state.editor.startValue = ev.target.value;
this.applyEditorRange(false);
}
onEditorEndChange(ev) {
this.state.editor.endValue = ev.target.value;
this.applyEditorRange(false);
}
applyEditorRange(close = true) {
const context = this._activeEditorContext();
if (!context) {
return;
}
const start = Number(this.state.editor.startValue);
let end = Number(this.state.editor.endValue);
if (end <= start) {
end = Math.min(start + 0.5, 24);
this.state.editor.endValue = this._timeValue(end);
}
const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0);
if (parsed.error) {
context.cell.error = parsed.error;
this.state.editor.error = parsed.error;
} else {
this._applyParsedToCell(context.employee, context.day, parsed, parsed.label);
this._syncEditorFromCell(context.employee, context.day);
}
this._recountInvalid();
if (close && !parsed.error) {
this.closeCellEditor();
}
}
_setCellFromInput(employee, day, input, target = null) {
const cell = employee.cells[day.date];
cell.input = input;
const parsed = this._parseInput(input, cell);
this._applyParsedToCell(employee, day, parsed, input);
if (!parsed.error && target && parsed.normalized_input !== undefined) {
target.value = parsed.normalized_input;
}
this._syncEditorFromCell(employee, day);
}
_applyParsedToCell(employee, day, parsed, input) {
const cell = employee.cells[day.date];
cell.error = parsed.error || "";
if (parsed.error) {
cell.input = input;
this.state.editor.error = parsed.error;
this._markDirty(employee, day);
this._recountInvalid();
return;
}
cell.is_off = parsed.is_off || false;
cell.shift_id = parsed.shift_id || false;
cell.start_time = parsed.start_time || 0;
cell.end_time = parsed.end_time || 0;
cell.break_minutes = parsed.break_minutes || 0;
cell.hours = parsed.hours || 0;
cell.hours_display = parsed.hours_display || "0:00";
cell.label = parsed.label || "";
cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input;
this.state.editor.error = "";
this._markDirty(employee, day);
this._recountInvalid();
}
_markDirty(employee, day) {
const cell = employee.cells[day.date];
const key = `${employee.id}:${day.date}`;
const payload = {
employee_id: employee.id,
date: day.date,
input: cell.input,
shift_id: cell.shift_id || false,
note: cell.note || "",
};
if ((cell.input || "").trim()) {
payload.is_off = !!cell.is_off;
payload.start_time = cell.start_time || 0;
payload.end_time = cell.end_time || 0;
payload.break_minutes = cell.break_minutes || 0;
}
this.dirtyCells[key] = payload;
this.state.dirtyCount = Object.keys(this.dirtyCells).length;
}
_markServerErrors(errors) {
for (const error of errors) {
const employee = this.state.employees.find((emp) => emp.id === error.employee_id);
const cell = employee && employee.cells[error.date];
if (cell) {
cell.error = error.message;
}
}
this._recountInvalid();
}
_recountInvalid() {
let invalid = 0;
for (const employee of this.state.employees) {
for (const day of this.state.days) {
if (employee.cells[day.date]?.error) {
invalid++;
}
}
}
this.state.invalidCount = invalid;
}
_parseInput(value, currentCell = {}) {
const text = (value || "").trim();
if (!text) {
return {
is_off: false,
shift_id: false,
start_time: 0,
end_time: 0,
break_minutes: 0,
label: "",
hours: 0,
hours_display: "0:00",
normalized_input: "",
};
}
if (text.toUpperCase() === "OFF") {
return {
is_off: true,
shift_id: false,
start_time: 0,
end_time: 0,
break_minutes: 0,
hours: 0,
hours_display: "0:00",
label: "OFF",
normalized_input: "OFF",
};
}
const lowerText = text.toLowerCase();
const template = this.state.shifts.find((shift) =>
[shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText)
);
if (template) {
return {
is_off: false,
shift_id: template.id,
start_time: template.start_time,
end_time: template.end_time,
break_minutes: template.break_minutes,
hours: template.hours,
hours_display: template.hours_display,
label: template.label,
normalized_input: template.label,
};
}
try {
const parsed = this._parseTypedShift(text, currentCell);
return parsed;
} catch (error) {
return { error: error.message };
}
}
_parseTypedShift(value, currentCell = {}) {
const normalized = value.replaceAll("", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-");
const parts = normalized.split("-");
if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF.");
}
const start = this._parseTimePart(parts[0]);
let end = this._parseTimePart(parts[1]);
if (end <= start && end + 12 <= 24) {
end += 12;
}
if (end <= start) {
throw new Error("End must be after start.");
}
const breakMinutes = currentCell.break_minutes || 30;
const hours = Math.max(end - start - breakMinutes / 60, 0);
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
return {
is_off: false,
shift_id: false,
start_time: start,
end_time: end,
break_minutes: breakMinutes,
hours,
hours_display: this._formatHours(hours),
label,
normalized_input: label,
};
}
_rangeToParsed(start, end, breakMinutes) {
if (Number.isNaN(start) || Number.isNaN(end)) {
return { error: "Choose a start and end time." };
}
if (end <= start) {
return { error: "End must be after start." };
}
const hours = Math.max(end - start - breakMinutes / 60, 0);
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
return {
is_off: false,
shift_id: false,
start_time: start,
end_time: end,
break_minutes: breakMinutes,
hours,
hours_display: this._formatHours(hours),
label,
normalized_input: label,
};
}
_parseTimePart(raw) {
const text = raw.trim().toLowerCase().replaceAll(".", "");
const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/);
if (!match) {
throw new Error(`Could not read "${raw.trim()}".`);
}
let hour = Number(match[1]);
const minute = Number(match[2] || 0);
const meridiem = match[3];
if (minute < 0 || minute > 59) {
throw new Error("Minutes must be 00-59.");
}
if (meridiem) {
if (hour < 1 || hour > 12) {
throw new Error("Use 1-12 with am/pm.");
}
if (meridiem === "am") {
hour = hour === 12 ? 0 : hour;
} else {
hour = hour === 12 ? 12 : hour + 12;
}
}
if (hour < 0 || hour > 24) {
throw new Error("Hours must be 0-24.");
}
return hour + minute / 60;
}
_formatFloatTime(value) {
let hour = Math.floor(value);
let minute = Math.round((value - hour) * 60);
if (minute === 60) {
hour += 1;
minute = 0;
}
const suffix = hour < 12 || hour === 24 ? "am" : "pm";
let displayHour = hour % 12;
if (displayHour === 0) {
displayHour = 12;
}
return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`;
}
_formatHours(value) {
let hour = Math.floor(value);
let minute = Math.round((value - hour) * 60);
if (minute === 60) {
hour += 1;
minute = 0;
}
return `${hour}:${String(minute).padStart(2, "0")}`;
}
_timeValue(value) {
const rounded = Math.round(Number(value || 0) * 4) / 4;
return rounded.toFixed(2);
}
_buildTimeOptions() {
const options = [];
for (let minutes = 0; minutes <= 24 * 60; minutes += 15) {
const value = minutes / 60;
options.push({
value: this._timeValue(value),
label: this._formatFloatTime(value),
});
}
return options;
}
_defaultTimes(employee, day) {
const dayIndex = this.state.days.findIndex((item) => item.date === day.date);
if (dayIndex > 0) {
const previousDay = this.state.days[dayIndex - 1];
const previousCell = employee.cells[previousDay.date];
if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) {
return {
start: previousCell.start_time,
end: previousCell.end_time,
breakMinutes: previousCell.break_minutes || 30,
};
}
}
const firstShift = this.state.shifts[0];
if (firstShift) {
return {
start: firstShift.start_time,
end: firstShift.end_time,
breakMinutes: firstShift.break_minutes || 30,
};
}
return { start: 9, end: 17, breakMinutes: 30 };
}
get quickShiftOptions() {
const options = [{
key: "off",
type: "input",
input: "OFF",
label: "OFF",
detail: "0:00",
}];
const seen = new Set(["OFF"]);
for (const shift of this.state.shifts) {
if (seen.has(shift.label)) {
continue;
}
seen.add(shift.label);
options.push({
key: `shift-${shift.id}`,
type: "template",
shiftId: shift.id,
input: shift.label,
label: shift.name || shift.label,
detail: `${shift.label} - ${shift.hours_display}`,
start: shift.start_time,
end: shift.end_time,
breakMinutes: shift.break_minutes,
hours: shift.hours,
hoursDisplay: shift.hours_display,
});
}
for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) {
if (seen.has(input)) {
continue;
}
const parsed = this._parseInput(input, { break_minutes: 30 });
seen.add(input);
options.push({
key: `common-${input}`,
type: "input",
input,
label: input,
detail: parsed.hours_display || "0:00",
});
}
return options.slice(0, 10);
}
_activeEditorContext() {
if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) {
return null;
}
return {
employee: this.activeEditorEmployee,
day: this.activeEditorDay,
cell: this.activeEditorEmployee.cells[this.activeEditorDay.date],
};
}
_syncEditorFromCell(employee, day) {
if (!this.isActiveCell(employee, day)) {
return;
}
const cell = employee.cells[day.date] || {};
if (!cell.is_off && cell.start_time && cell.end_time) {
this.state.editor.startValue = this._timeValue(cell.start_time);
this.state.editor.endValue = this._timeValue(cell.end_time);
}
this.state.editor.breakMinutes = cell.break_minutes || 0;
this.state.editor.hoursDisplay = cell.hours_display || "0:00";
this.state.editor.error = cell.error || "";
}
_focusRelativeCell(input, offset) {
const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input"));
const index = inputs.indexOf(input);
const next = inputs[index + offset];
if (next) {
next.focus();
next.select();
}
}
_positionActiveEditor(anchor = null) {
if (!this.state.editor.open) {
return;
}
const target = anchor || this.activeCellAnchor;
if (!target || !target.isConnected) {
this.closeCellEditor();
return;
}
const rect = target.getBoundingClientRect();
const editorWidth = Math.min(380, window.innerWidth - 16);
const editorHeight = this.editorRef.el?.offsetHeight || 300;
let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8));
let top = rect.bottom + 8;
if (top + editorHeight > window.innerHeight - 8) {
top = Math.max(8, rect.top - editorHeight - 8);
}
left = Math.round(left);
top = Math.round(top);
if (this.state.editor.left !== left) {
this.state.editor.left = left;
}
if (this.state.editor.top !== top) {
this.state.editor.top = top;
}
}
_dateAdd(dateString, days) {
const date = new Date(`${dateString}T12:00:00`);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
}
registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner);

View File

@@ -0,0 +1,77 @@
$o-webclient-color-scheme: bright !default;
$_fclk-planner-page: #f3f4f6;
$_fclk-planner-panel: #eef1f4;
$_fclk-planner-card: #ffffff;
$_fclk-planner-text: #1f2937;
$_fclk-planner-muted: #6b7280;
$_fclk-planner-border: #d8dadd;
$_fclk-planner-border-strong: #9ca3af;
$_fclk-planner-day: #b7dff5;
$_fclk-planner-subhead: #d8e9bd;
$_fclk-planner-hours: #f5d39b;
$_fclk-planner-fallback: #fff8e5;
$_fclk-planner-row-hover: #f9fafb;
$_fclk-planner-error: #dc2626;
$_fclk-planner-focus: #2563eb;
$_fclk-planner-shadow: rgba(15, 23, 42, 0.08);
$_fclk-planner-editor: #111827;
$_fclk-planner-editor-text: #f9fafb;
$_fclk-planner-editor-muted: #cbd5e1;
$_fclk-planner-editor-border: #374151;
$_fclk-planner-editor-control: #ffffff;
$_fclk-planner-editor-control-text: #111827;
$_fclk-planner-editor-chip: #1f2937;
$_fclk-planner-editor-chip-hover: #334155;
@if $o-webclient-color-scheme == dark {
$_fclk-planner-page: #171a1f !global;
$_fclk-planner-panel: #20242b !global;
$_fclk-planner-card: #262b33 !global;
$_fclk-planner-text: #f3f4f6 !global;
$_fclk-planner-muted: #a3aab8 !global;
$_fclk-planner-border: #3b424c !global;
$_fclk-planner-border-strong: #647082 !global;
$_fclk-planner-day: #21465f !global;
$_fclk-planner-subhead: #394b2d !global;
$_fclk-planner-hours: #6f4f22 !global;
$_fclk-planner-fallback: #393326 !global;
$_fclk-planner-row-hover: #2b313a !global;
$_fclk-planner-error: #f87171 !global;
$_fclk-planner-focus: #60a5fa !global;
$_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global;
$_fclk-planner-editor: #0f172a !global;
$_fclk-planner-editor-text: #f9fafb !global;
$_fclk-planner-editor-muted: #cbd5e1 !global;
$_fclk-planner-editor-border: #475569 !global;
$_fclk-planner-editor-control: #1f2937 !global;
$_fclk-planner-editor-control-text: #f9fafb !global;
$_fclk-planner-editor-chip: #1e293b !global;
$_fclk-planner-editor-chip-hover: #334155 !global;
}
:root {
--fclk-planner-page: #{$_fclk-planner-page};
--fclk-planner-panel: #{$_fclk-planner-panel};
--fclk-planner-card: #{$_fclk-planner-card};
--fclk-planner-text: #{$_fclk-planner-text};
--fclk-planner-muted: #{$_fclk-planner-muted};
--fclk-planner-border: #{$_fclk-planner-border};
--fclk-planner-border-strong: #{$_fclk-planner-border-strong};
--fclk-planner-day: #{$_fclk-planner-day};
--fclk-planner-subhead: #{$_fclk-planner-subhead};
--fclk-planner-hours: #{$_fclk-planner-hours};
--fclk-planner-fallback: #{$_fclk-planner-fallback};
--fclk-planner-row-hover: #{$_fclk-planner-row-hover};
--fclk-planner-error: #{$_fclk-planner-error};
--fclk-planner-focus: #{$_fclk-planner-focus};
--fclk-planner-shadow: #{$_fclk-planner-shadow};
--fclk-planner-editor: #{$_fclk-planner-editor};
--fclk-planner-editor-text: #{$_fclk-planner-editor-text};
--fclk-planner-editor-muted: #{$_fclk-planner-editor-muted};
--fclk-planner-editor-border: #{$_fclk-planner-editor-border};
--fclk-planner-editor-control: #{$_fclk-planner-editor-control};
--fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text};
--fclk-planner-editor-chip: #{$_fclk-planner-editor-chip};
--fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover};
}

View File

@@ -0,0 +1,25 @@
:root {
--fclk-planner-page: #171a1f;
--fclk-planner-panel: #20242b;
--fclk-planner-card: #262b33;
--fclk-planner-text: #f3f4f6;
--fclk-planner-muted: #a3aab8;
--fclk-planner-border: #3b424c;
--fclk-planner-border-strong: #647082;
--fclk-planner-day: #21465f;
--fclk-planner-subhead: #394b2d;
--fclk-planner-hours: #6f4f22;
--fclk-planner-fallback: #393326;
--fclk-planner-row-hover: #2b313a;
--fclk-planner-error: #f87171;
--fclk-planner-focus: #60a5fa;
--fclk-planner-shadow: rgba(0, 0, 0, 0.32);
--fclk-planner-editor: #0f172a;
--fclk-planner-editor-text: #f9fafb;
--fclk-planner-editor-muted: #cbd5e1;
--fclk-planner-editor-border: #475569;
--fclk-planner-editor-control: #1f2937;
--fclk-planner-editor-control-text: #f9fafb;
--fclk-planner-editor-chip: #1e293b;
--fclk-planner-editor-chip-hover: #334155;
}

View File

@@ -0,0 +1,447 @@
.fclk-planner {
min-height: 100%;
background: var(--fclk-planner-page, #f3f4f6);
color: var(--fclk-planner-text, #1f2937);
display: flex;
flex-direction: column;
}
.fclk-planner__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background: var(--fclk-planner-card, #ffffff);
border-bottom: 1px solid var(--fclk-planner-border, #d8dadd);
box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
}
.fclk-planner__title {
margin: 0;
font-size: 20px;
font-weight: 650;
line-height: 1.2;
}
.fclk-planner__subtitle {
color: var(--fclk-planner-muted, #6b7280);
font-size: 13px;
margin-top: 3px;
}
.fclk-planner__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.fclk-planner__warning {
margin: 12px 16px 0;
padding: 10px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 6px;
color: #9a3412;
font-size: 13px;
}
.fclk-planner__loading {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 340px;
color: var(--fclk-planner-muted, #6b7280);
}
.fclk-planner__table-wrap {
flex: 1;
margin: 16px;
overflow: auto;
background: var(--fclk-planner-panel, #eef1f4);
border: 1px solid var(--fclk-planner-border, #d8dadd);
border-radius: 6px;
box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
}
.fclk-planner__table {
--fclk-planner-shift-width: 135px;
--fclk-planner-hours-width: 55px;
--fclk-planner-days-width: 1330px;
width: 100%;
min-width: 1600px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: var(--fclk-planner-card, #ffffff);
font-size: 13px;
}
.fclk-planner__employee-col {
width: calc(100% - var(--fclk-planner-days-width));
}
.fclk-planner__shift-col {
width: var(--fclk-planner-shift-width);
}
.fclk-planner__hours-col {
width: var(--fclk-planner-hours-width);
}
.fclk-planner__table th,
.fclk-planner__table td {
border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af);
border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__employee-head,
.fclk-planner__day-head,
.fclk-planner__sub-head {
position: sticky;
top: 0;
z-index: 6;
color: var(--fclk-planner-text, #1f2937);
}
.fclk-planner__employee-head {
left: 0;
z-index: 8;
width: calc(100% - var(--fclk-planner-days-width));
background: var(--fclk-planner-day, #b7dff5);
text-align: left;
padding: 10px 12px;
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__day-head {
background: var(--fclk-planner-day, #b7dff5);
text-align: center;
padding: 6px 8px;
font-weight: 700;
}
.fclk-planner__sub-head {
top: 47px;
background: var(--fclk-planner-subhead, #d8e9bd);
text-align: left;
padding: 5px 8px;
font-weight: 650;
}
.fclk-planner__hours-head {
width: var(--fclk-planner-hours-width);
text-align: center;
padding-left: 2px;
padding-right: 2px;
}
.fclk-planner__weekday {
font-size: 14px;
line-height: 1.1;
}
.fclk-planner__date {
font-size: 12px;
font-weight: 500;
margin-top: 2px;
}
.fclk-planner__department-row td {
background: var(--fclk-planner-panel, #eef1f4);
padding: 0;
position: sticky;
left: 0;
z-index: 5;
}
.fclk-planner__department-toggle {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: var(--fclk-planner-text, #1f2937);
font-weight: 650;
padding: 7px 12px;
text-align: left;
}
.fclk-planner__department-count {
color: var(--fclk-planner-muted, #6b7280);
font-weight: 500;
font-size: 12px;
}
.fclk-planner__employee-row {
background: var(--fclk-planner-card, #ffffff);
}
.fclk-planner__employee-row:hover {
background: var(--fclk-planner-row-hover, #f9fafb);
}
.fclk-planner__employee-cell {
position: sticky;
left: 0;
z-index: 4;
width: calc(100% - var(--fclk-planner-days-width));
background: inherit;
padding: 8px 12px;
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__employee-name {
font-weight: 650;
line-height: 1.2;
}
.fclk-planner__employee-role {
margin-top: 2px;
color: var(--fclk-planner-muted, #6b7280);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-planner__shift-cell {
width: var(--fclk-planner-shift-width);
min-height: 42px;
padding: 4px;
vertical-align: top;
background: var(--fclk-planner-card, #ffffff);
}
.fclk-planner__shift-cell--fallback {
background: var(--fclk-planner-fallback, #fff8e5);
}
.fclk-planner__shift-cell--error {
background: #fef2f2;
}
.fclk-planner__shift-cell--active {
box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb);
}
.fclk-planner__shift-input {
width: 100%;
height: 32px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--fclk-planner-text, #1f2937);
padding: 4px 6px;
font-size: 13px;
line-height: 1.2;
outline: none;
white-space: nowrap;
}
.fclk-planner__shift-input:focus {
background: var(--fclk-planner-card, #ffffff);
border-color: var(--fclk-planner-focus, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16);
}
.fclk-planner__cell-error {
color: var(--fclk-planner-error, #dc2626);
font-size: 11px;
line-height: 1.2;
padding: 3px 5px 0;
}
.fclk-planner__hours-cell {
width: var(--fclk-planner-hours-width);
background: var(--fclk-planner-hours, #f5d39b);
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 650;
vertical-align: middle;
padding: 6px 2px;
}
.fclk-planner__cell-editor {
position: fixed;
z-index: 1080;
width: calc(100vw - 16px);
max-width: 380px;
padding: 14px;
color: var(--fclk-planner-editor-text, #f9fafb);
background: var(--fclk-planner-editor, #111827);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 8px;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32);
}
.fclk-planner__cell-editor::before {
content: "";
position: absolute;
top: -7px;
left: 28px;
width: 14px;
height: 14px;
background: var(--fclk-planner-editor, #111827);
border-left: 1px solid var(--fclk-planner-editor-border, #374151);
border-top: 1px solid var(--fclk-planner-editor-border, #374151);
transform: rotate(45deg);
}
.fclk-planner__editor-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.fclk-planner__editor-name {
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.fclk-planner__editor-day {
margin-top: 2px;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 12px;
}
.fclk-planner__editor-hours {
min-width: 56px;
padding: 5px 8px;
text-align: center;
color: #111827;
background: var(--fclk-planner-hours, #f5d39b);
border-radius: 6px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fclk-planner__quick-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.fclk-planner__quick-chip {
min-height: 46px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
padding: 7px 9px;
color: var(--fclk-planner-editor-text, #f9fafb);
background: var(--fclk-planner-editor-chip, #1f2937);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 6px;
text-align: left;
}
.fclk-planner__quick-chip:hover,
.fclk-planner__quick-chip:focus {
background: var(--fclk-planner-editor-chip-hover, #334155);
outline: none;
}
.fclk-planner__quick-label {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
line-height: 1.15;
}
.fclk-planner__quick-detail {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 11px;
line-height: 1.15;
}
.fclk-planner__time-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.fclk-planner__time-field {
display: flex;
flex-direction: column;
gap: 5px;
margin: 0;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 12px;
font-weight: 650;
}
.fclk-planner__time-field select {
width: 100%;
height: 34px;
color: var(--fclk-planner-editor-control-text, #111827);
background: var(--fclk-planner-editor-control, #ffffff);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
}
.fclk-planner__editor-error {
position: relative;
z-index: 1;
margin-top: 10px;
padding: 7px 8px;
color: #991b1b;
background: #fee2e2;
border-radius: 6px;
font-size: 12px;
line-height: 1.25;
}
.fclk-planner__editor-actions {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 900px) {
.fclk-planner__toolbar {
align-items: flex-start;
flex-direction: column;
}
.fclk-planner__actions {
justify-content: flex-start;
}
.fclk-planner__table-wrap {
margin: 10px;
}
.fclk-planner__cell-editor {
width: calc(100vw - 16px);
}
}

View File

@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.ShiftPlanner">
<div class="o_action fclk-planner" t-ref="root">
<div class="fclk-planner__toolbar">
<div>
<h2 class="fclk-planner__title">Shift Planner</h2>
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
</div>
<div class="fclk-planner__actions">
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-left"/>
</button>
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-right"/>
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-copy me-1"/> Copy Previous Week
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-file-excel-o me-1"/> Export XLSX
</button>
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-save me-1"/></t>
Save
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
</button>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
</t>
<t t-if="state.invalidCount">
<div class="fclk-planner__warning">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-esc="state.invalidCount"/> invalid cells need attention.
</div>
</t>
<t t-if="state.loading">
<div class="fclk-planner__loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<span>Loading shift planner...</span>
</div>
</t>
<t t-if="!state.loading and !state.error">
<div class="fclk-planner__table-wrap">
<table class="fclk-planner__table">
<colgroup>
<col class="fclk-planner__employee-col"/>
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
<col class="fclk-planner__shift-col"/>
<col class="fclk-planner__hours-col"/>
</t>
</colgroup>
<thead>
<tr>
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
<t t-foreach="state.days" t-as="day" t-key="day.date">
<th class="fclk-planner__day-head" colspan="2">
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
</th>
</t>
</tr>
<tr>
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
<th class="fclk-planner__sub-head">Shift</th>
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="state.departments" t-as="department" t-key="department.id">
<tr class="fclk-planner__department-row">
<td t-att-colspan="1 + state.days.length * 2">
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
<span><t t-esc="department.name"/></span>
<span class="fclk-planner__department-count">
<t t-esc="department.employee_ids.length"/> employees
</span>
</button>
</td>
</tr>
<t t-if="!isCollapsed(department)">
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
<tr class="fclk-planner__employee-row">
<td class="fclk-planner__employee-cell">
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
<div class="fclk-planner__employee-role" t-if="employee.job_title">
<t t-esc="employee.job_title"/>
</div>
</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' : '')"
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
<input class="fclk-planner__shift-input"
t-att-value="cell.input"
t-att-title="cell.error || cell.label"
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
<div class="fclk-planner__cell-error" t-if="cell.error">
<t t-esc="cell.error"/>
</div>
</td>
<td class="fclk-planner__hours-cell">
<t t-esc="cell.hours_display || '0:00'"/>
</td>
</t>
</tr>
</t>
</t>
</t>
</tbody>
</table>
</div>
<div t-if="state.editor.open"
t-ref="shiftEditor"
class="fclk-planner__cell-editor"
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
<div class="fclk-planner__editor-head">
<div class="fclk-planner__editor-person">
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
</div>
<div class="fclk-planner__editor-hours">
<span><t t-esc="state.editor.hoursDisplay"/></span>
</div>
</div>
<div class="fclk-planner__quick-grid">
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
<button type="button"
class="fclk-planner__quick-chip"
t-on-click="() => this.selectQuickShift(option)">
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
</button>
</t>
</div>
<div class="fclk-planner__time-row">
<label class="fclk-planner__time-field">
<span>Start</span>
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.startValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
<label class="fclk-planner__time-field">
<span>End</span>
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.endValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
</div>
<div class="fclk-planner__editor-error" t-if="state.editor.error">
<t t-esc="state.editor.error"/>
</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"
class="btn btn-sm btn-primary"
t-on-click="() => this.applyEditorRange(true)">
<i class="fa fa-check me-1"/> Done
</button>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -2,3 +2,4 @@
from . import test_nfc_models
from . import test_clock_nfc_kiosk
from . import test_shift_planner

View File

@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
import json
from datetime import date, timedelta
from psycopg2 import IntegrityError
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests.common import HttpCase, TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestShiftPlannerModels(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Schedule = cls.env['fusion.clock.schedule'].sudo()
cls.Shift = cls.env['fusion.clock.shift'].sudo()
cls.employee = cls.env['hr.employee'].sudo().create({
'name': 'Planner Model Employee',
'company_id': cls.env.company.id,
'x_fclk_enable_clock': True,
})
cls.default_shift = cls.Shift.create({
'name': 'Default Planner Shift',
'start_time': 8.0,
'end_time': 16.5,
'break_minutes': 30,
'company_id': cls.env.company.id,
})
cls.employee.x_fclk_shift_id = cls.default_shift.id
cls.schedule_date = date(2026, 1, 5)
def test_unique_employee_date_schedule(self):
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'is_off': True,
})
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'is_off': True,
})
def test_off_schedule_has_zero_hours(self):
schedule = self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 6),
'is_off': True,
})
self.assertEqual(schedule.planned_hours, 0)
self.assertEqual(schedule.fclk_display_value(), 'OFF')
def test_working_schedule_computes_hours_minus_break(self):
schedule = self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 7),
'start_time': 9.0,
'end_time': 17.5,
'break_minutes': 30,
})
self.assertEqual(schedule.planned_hours, 8.0)
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
def test_invalid_same_day_range_is_rejected(self):
with self.assertRaises(ValidationError):
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 8),
'start_time': 17.0,
'end_time': 9.0,
'break_minutes': 30,
})
def test_apply_planner_cell_creates_audit(self):
schedule_date = date(2026, 1, 9)
self.Schedule.fclk_apply_planner_cell(
self.employee,
schedule_date,
{'input': '9:00 am - 5:30 pm'},
self.env.user,
)
audit = self.env['fusion.clock.schedule.audit'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', schedule_date),
], limit=1)
self.assertTrue(audit)
self.assertFalse(audit.old_value)
self.assertEqual(audit.new_value, '9:00 am - 5:30 pm')
def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self):
planned_date = date(2026, 1, 12)
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': planned_date,
'start_time': 10.0,
'end_time': 18.0,
'break_minutes': 60,
})
planned = self.employee._get_fclk_day_plan(planned_date)
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
self.assertEqual(planned['source'], 'schedule')
self.assertEqual(planned['start_time'], 10.0)
self.assertEqual(planned['hours'], 7.0)
self.assertEqual(fallback['source'], 'fallback')
self.assertEqual(fallback['start_time'], 8.0)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestShiftPlannerApi(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager')
user_group = cls.env.ref('fusion_clock.group_fusion_clock_user')
cls.manager_user = cls.env['res.users'].sudo().create({
'name': 'Planner Manager',
'login': 'planner-manager',
'password': 'plannerpass',
'company_id': cls.env.company.id,
'company_ids': [(6, 0, [cls.env.company.id])],
'group_ids': [(6, 0, [manager_group.id])],
})
cls.employee_user = cls.env['res.users'].sudo().create({
'name': 'Planner Employee User',
'login': 'planner-employee-user',
'password': 'plannerpass',
'company_id': cls.env.company.id,
'company_ids': [(6, 0, [cls.env.company.id])],
'group_ids': [(6, 0, [user_group.id])],
'tz': 'UTC',
})
cls.employee = cls.env['hr.employee'].sudo().create({
'name': 'Planner API Employee',
'user_id': cls.employee_user.id,
'company_id': cls.env.company.id,
'x_fclk_enable_clock': True,
})
cls.shift = cls.env['fusion.clock.shift'].sudo().create({
'name': 'API Morning',
'start_time': 7.0,
'end_time': 15.5,
'break_minutes': 30,
'company_id': cls.env.company.id,
})
cls.week_start = '2026-01-19'
def _json_call(self, route, payload, login='planner-manager'):
self.authenticate(login, 'plannerpass')
response = self.url_open(
route,
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_manager_can_load_save_and_export_planner(self):
load_result = self._json_call('/fusion_clock/shift_planner/load', {
'week_start': self.week_start,
})
self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']])
save_result = self._json_call('/fusion_clock/shift_planner/save', {
'week_start': self.week_start,
'changes': [{
'employee_id': self.employee.id,
'date': self.week_start,
'input': '9-5',
'shift_id': False,
}],
})
self.assertTrue(save_result.get('success'))
schedule = self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', fields.Date.to_date(self.week_start)),
], limit=1)
self.assertTrue(schedule)
self.assertEqual(schedule.start_time, 9.0)
self.assertEqual(schedule.end_time, 17.0)
export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', {
'week_start': self.week_start,
})
self.assertTrue(export_result.get('success'))
self.assertTrue(export_result.get('url', '').startswith('/web/content/'))
self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists())
def test_copy_previous_week(self):
previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7)
self.env['fusion.clock.schedule'].sudo().create({
'employee_id': self.employee.id,
'schedule_date': previous_monday,
'shift_id': self.shift.id,
'start_time': self.shift.start_time,
'end_time': self.shift.end_time,
'break_minutes': self.shift.break_minutes,
})
result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', {
'week_start': self.week_start,
})
self.assertTrue(result.get('success'))
copied = self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', fields.Date.to_date(self.week_start)),
], limit=1)
self.assertEqual(copied.shift_id, self.shift)
def test_non_manager_cannot_mutate_planner(self):
result = self._json_call('/fusion_clock/shift_planner/save', {
'week_start': self.week_start,
'changes': [],
}, login='planner-employee-user')
self.assertEqual(result.get('error'), 'Access denied.')
def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self):
today = fields.Date.today()
location = self.env['fusion.clock.location'].sudo().create({
'name': 'Planner Test Location',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
'company_id': self.env.company.id,
'all_employees': True,
})
self.env['fusion.clock.schedule'].sudo().create({
'employee_id': self.employee.id,
'schedule_date': today,
'is_off': True,
})
result = self._json_call('/fusion_clock/clock_action', {
'latitude': location.latitude,
'longitude': location.longitude,
'source': 'portal',
}, login='planner-employee-user')
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_in')
self.assertIn('unscheduled', result.get('message', ''))
log = self.env['fusion.clock.activity.log'].sudo().search([
('employee_id', '=', self.employee.id),
('log_type', '=', 'unscheduled_shift'),
], limit=1)
self.assertTrue(log)

View File

@@ -16,6 +16,34 @@
sequence="5"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Scheduling -->
<menuitem id="menu_fusion_clock_scheduling"
name="Scheduling"
parent="menu_fusion_clock_root"
sequence="8"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_shift_planner"
name="Shift Planner"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_shift_planner"
sequence="5"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_scheduled_shifts"
name="Scheduled Shifts"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule"
sequence="10"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_schedule_audit"
name="Schedule Audit"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule_audit"
sequence="20"
groups="group_fusion_clock_manager"/>
<!-- Attendance Sub-Menu -->
<menuitem id="menu_fusion_clock_attendance"
name="Attendance"

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_fusion_clock_shift_planner" model="ir.actions.client">
<field name="name">Shift Planner</field>
<field name="tag">fusion_clock.ShiftPlanner</field>
</record>
<record id="view_fusion_clock_schedule_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.list</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<list>
<field name="schedule_date"/>
<field name="employee_id"/>
<field name="department_id"/>
<field name="is_off"/>
<field name="shift_id"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.form</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="is_off"/>
<field name="shift_id"/>
</group>
<group>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours" readonly="1"/>
</group>
</group>
<group>
<field name="note"/>
<field name="department_id" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fusion_clock_schedule_search" model="ir.ui.view">
<field name="name">fusion.clock.schedule.search</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<search>
<field name="employee_id"/>
<field name="department_id"/>
<field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
</search>
</field>
</record>
<record id="action_fusion_clock_schedule" model="ir.actions.act_window">
<field name="name">Scheduled Shifts</field>
<field name="res_model">fusion.clock.schedule</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_fusion_clock_schedule_audit_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.list</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<list create="0" edit="0" delete="0">
<field name="changed_at"/>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="old_value"/>
<field name="new_value"/>
<field name="changed_by_id"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_audit_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.form</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<form create="0" edit="0" delete="0">
<sheet>
<group>
<group>
<field name="changed_at"/>
<field name="changed_by_id"/>
<field name="employee_id"/>
<field name="schedule_date"/>
</group>
<group>
<field name="old_value"/>
<field name="new_value"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_schedule_audit" model="ir.actions.act_window">
<field name="name">Schedule Audit</field>
<field name="res_model">fusion.clock.schedule.audit</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -142,6 +142,28 @@
</div>
</div>
<!-- Scheduled Shift -->
<div class="fclk-schedule-card">
<div class="fclk-schedule-icon">
<i class="fa fa-calendar-check-o"/>
</div>
<div class="fclk-schedule-info">
<div class="fclk-schedule-label">Today's Shift</div>
<div class="fclk-schedule-value">
<t t-if="today_schedule.get('is_off')">OFF</t>
<t t-else="">
<t t-esc="today_schedule.get('label') or 'Not scheduled'"/>
</t>
</div>
</div>
<div class="fclk-schedule-hours">
<t t-if="today_schedule.get('is_off')">0:00</t>
<t t-else="">
<t t-esc="'%.1f' % (today_schedule.get('hours') or 0.0)"/>h
</t>
</div>
</div>
<!-- Timer Section -->
<div class="fclk-timer-section">
<div class="fclk-timer-label" id="fclk-timer-label">