Compare commits
6 Commits
phase6_2-l
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005daade55 | ||
|
|
27e12dd544 | ||
|
|
5f03080374 | ||
|
|
efaf16dffb | ||
|
|
e4000374ca | ||
|
|
fee4219703 |
7
fusion-plating/.claude/settings.local.json
Normal file
7
fusion-plating/.claude/settings.local.json
Normal 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
358
fusion_clock/CLAUDE.md
Normal 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
|
||||||
|
```
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.3.0',
|
'version': '19.0.3.5.6',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'views/clock_correction_views.xml',
|
'views/clock_correction_views.xml',
|
||||||
'views/clock_dashboard_views.xml',
|
'views/clock_dashboard_views.xml',
|
||||||
'views/hr_employee_views.xml',
|
'views/hr_employee_views.xml',
|
||||||
|
'views/clock_schedule_views.xml',
|
||||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||||
'wizard/clock_nfc_enrollment_views.xml',
|
'wizard/clock_nfc_enrollment_views.xml',
|
||||||
'views/clock_menus.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',
|
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||||
],
|
],
|
||||||
'web.assets_backend': [
|
'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/scss/fusion_clock.scss',
|
||||||
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
||||||
'fusion_clock/static/src/xml/systray_clock.xml',
|
'fusion_clock/static/src/xml/systray_clock.xml',
|
||||||
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
||||||
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
|
'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_map.js',
|
||||||
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
||||||
'fusion_clock/static/src/xml/fusion_clock_location.xml',
|
'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,
|
'installable': True,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -4,3 +4,4 @@ from . import portal_clock
|
|||||||
from . import clock_api
|
from . import clock_api
|
||||||
from . import clock_kiosk
|
from . import clock_kiosk
|
||||||
from . import clock_nfc_kiosk
|
from . import clock_nfc_kiosk
|
||||||
|
from . import shift_planner
|
||||||
|
|||||||
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import math
|
import math
|
||||||
import logging
|
import logging
|
||||||
|
import pytz
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from odoo import http, fields, _
|
from odoo import http, fields, _
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||||
return
|
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'))
|
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||||
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
|
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
|
worked = attendance.worked_hours or 0.0
|
||||||
|
|
||||||
if worked >= threshold:
|
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
|
current = attendance.x_fclk_break_minutes or 0.0
|
||||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||||
new_val = max(break_min, current)
|
new_val = max(break_min, current)
|
||||||
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
|
|||||||
|
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = get_local_today(request.env, employee)
|
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 = {
|
geo_info = {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
|
|||||||
source=source,
|
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
|
# Check for late clock-in penalty
|
||||||
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
||||||
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
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)
|
self._apply_break_deduction(attendance, employee)
|
||||||
|
|
||||||
# Check for early clock-out penalty
|
# Check for early clock-out penalty
|
||||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
if not is_scheduled_off:
|
||||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||||
|
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
|
||||||
# Log clock-out
|
# Log clock-out
|
||||||
self._log_activity(
|
self._log_activity(
|
||||||
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
|
|||||||
'pending_reason': employee.x_fclk_pending_reason,
|
'pending_reason': employee.x_fclk_pending_reason,
|
||||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
'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:
|
if is_checked_in:
|
||||||
att = request.env['hr.attendance'].sudo().search([
|
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,
|
'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_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
||||||
today_atts = request.env['hr.attendance'].sudo().search([
|
today_atts = request.env['hr.attendance'].sudo().search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from odoo import http, fields, _
|
from odoo import http, fields, _
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
|
|||||||
|
|
||||||
is_checked_in = employee.attendance_state == 'checked_in'
|
is_checked_in = employee.attendance_state == 'checked_in'
|
||||||
now = fields.Datetime.now()
|
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 = {
|
geo_info = {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
|
|||||||
source='kiosk',
|
source='kiosk',
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
if is_scheduled_off:
|
||||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
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 {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
|
|||||||
})
|
})
|
||||||
api._apply_break_deduction(attendance, employee)
|
api._apply_break_deduction(attendance, employee)
|
||||||
|
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
if not is_scheduled_off:
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
|
||||||
api._log_activity(
|
api._log_activity(
|
||||||
employee, 'clock_out',
|
employee, 'clock_out',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import time
|
|||||||
import threading
|
import threading
|
||||||
from odoo import fields, http
|
from odoo import fields, http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
_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'
|
is_checked_in = employee.attendance_state == 'checked_in'
|
||||||
now = fields.Datetime.now()
|
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 = {
|
geo_info = {
|
||||||
'latitude': 0,
|
'latitude': 0,
|
||||||
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
latitude=0, longitude=0, distance=0,
|
latitude=0, longitude=0, distance=0,
|
||||||
source='nfc_kiosk',
|
source='nfc_kiosk',
|
||||||
)
|
)
|
||||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
if is_scheduled_off:
|
||||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
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 {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'action': 'clock_in',
|
'action': 'clock_in',
|
||||||
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||||
})
|
})
|
||||||
api._apply_break_deduction(attendance, employee)
|
api._apply_break_deduction(attendance, employee)
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
if not is_scheduled_off:
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
api._log_activity(
|
api._log_activity(
|
||||||
employee, 'clock_out',
|
employee, 'clock_out',
|
||||||
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
|
|||||||
], limit=1)
|
], limit=1)
|
||||||
|
|
||||||
# Today stats
|
# 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([
|
today_atts = request.env['hr.attendance'].sudo().search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
('check_in', '>=', today_start),
|
('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)
|
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
||||||
|
|
||||||
# Week stats
|
# Week stats
|
||||||
today = get_local_today(request.env, employee)
|
|
||||||
week_start = today - timedelta(days=today.weekday())
|
week_start = today - timedelta(days=today.weekday())
|
||||||
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||||
week_atts = request.env['hr.attendance'].sudo().search([
|
week_atts = request.env['hr.attendance'].sudo().search([
|
||||||
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
|
|||||||
'current_attendance': current_attendance,
|
'current_attendance': current_attendance,
|
||||||
'today_hours': round(today_hours, 1),
|
'today_hours': round(today_hours, 1),
|
||||||
'week_hours': round(week_hours, 1),
|
'week_hours': round(week_hours, 1),
|
||||||
|
'today_schedule': today_schedule,
|
||||||
'recent_attendances': recent,
|
'recent_attendances': recent,
|
||||||
'google_maps_key': google_maps_key,
|
'google_maps_key': google_maps_key,
|
||||||
'enable_sounds': enable_sounds,
|
'enable_sounds': enable_sounds,
|
||||||
|
|||||||
269
fusion_clock/controllers/shift_planner.py
Normal file
269
fusion_clock/controllers/shift_planner.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@ from . import res_config_settings
|
|||||||
from . import clock_activity_log
|
from . import clock_activity_log
|
||||||
from . import clock_leave_request
|
from . import clock_leave_request
|
||||||
from . import clock_shift
|
from . import clock_shift
|
||||||
|
from . import clock_schedule
|
||||||
from . import clock_correction
|
from . import clock_correction
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
|||||||
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
|
|||||||
('correction_request', 'Correction Request'),
|
('correction_request', 'Correction Request'),
|
||||||
('ip_fallback', 'IP Fallback Used'),
|
('ip_fallback', 'IP Fallback Used'),
|
||||||
('streak_milestone', 'Streak Milestone'),
|
('streak_milestone', 'Streak Milestone'),
|
||||||
|
('unscheduled_shift', 'Unscheduled Shift'),
|
||||||
('card_enrollment', 'Card Enrollment'),
|
('card_enrollment', 'Card Enrollment'),
|
||||||
('unknown_card_tap', 'Unknown Card Tap'),
|
('unknown_card_tap', 'Unknown Card Tap'),
|
||||||
],
|
],
|
||||||
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
|
|||||||
'correction_request': 'Correction Request',
|
'correction_request': 'Correction Request',
|
||||||
'ip_fallback': 'IP Fallback Used',
|
'ip_fallback': 'IP Fallback Used',
|
||||||
'streak_milestone': 'Streak Milestone',
|
'streak_milestone': 'Streak Milestone',
|
||||||
|
'unscheduled_shift': 'Unscheduled Shift',
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.depends('latitude', 'longitude')
|
@api.depends('latitude', 'longitude')
|
||||||
|
|||||||
414
fusion_clock/models/clock_schedule.py
Normal file
414
fusion_clock/models/clock_schedule.py
Normal 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',
|
||||||
|
)
|
||||||
@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
employee = att.employee_id
|
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
|
net = att.x_fclk_net_hours or 0.0
|
||||||
|
|
||||||
if net > scheduled_hours:
|
if net > scheduled_hours:
|
||||||
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
|
|||||||
employee = att.employee_id
|
employee = att.employee_id
|
||||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
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()
|
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)
|
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:
|
if now > effective_deadline:
|
||||||
clock_out_time = min(effective_deadline, now)
|
clock_out_time = min(effective_deadline, now)
|
||||||
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
|
|||||||
# Apply break deduction
|
# Apply break deduction
|
||||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||||
if (att.worked_hours or 0) >= threshold:
|
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().write({'x_fclk_break_minutes': break_min})
|
||||||
|
|
||||||
att.sudo().message_post(
|
att.sudo().message_post(
|
||||||
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
if yesterday.weekday() >= 5:
|
if yesterday.weekday() >= 5:
|
||||||
continue
|
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)
|
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||||
|
|
||||||
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
if today.weekday() >= 5:
|
if today.weekday() >= 5:
|
||||||
continue
|
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:
|
if emp.x_fclk_last_reminder_date == today:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
|
|||||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_fclk_break_minutes(self):
|
def _get_fclk_schedule_for_date(self, date):
|
||||||
"""Return effective break minutes for this employee.
|
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||||
Priority: employee override > shift > global setting.
|
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()
|
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:
|
if self.x_fclk_break_minutes > 0:
|
||||||
return self.x_fclk_break_minutes
|
return self.x_fclk_break_minutes
|
||||||
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
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):
|
def _get_fclk_scheduled_times(self, date):
|
||||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given 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
|
The configured hours are interpreted in the employee's local
|
||||||
timezone and converted to naive-UTC datetimes so they can be
|
timezone and converted to naive-UTC datetimes so they can be
|
||||||
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
||||||
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.x_fclk_shift_id:
|
plan = self._get_fclk_day_plan(date)
|
||||||
in_hour = self.x_fclk_shift_id.start_time
|
in_hour = plan.get('start_time') or 0.0
|
||||||
out_hour = self.x_fclk_shift_id.end_time
|
out_hour = plan.get('end_time') or 0.0
|
||||||
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'))
|
|
||||||
|
|
||||||
in_h = int(in_hour)
|
in_h = int(in_hour)
|
||||||
in_m = int((in_hour - in_h) * 60)
|
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)
|
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
||||||
return scheduled_in, scheduled_out
|
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."""
|
"""Return the expected work hours for this employee's shift."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.x_fclk_shift_id:
|
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
|
||||||
return self.x_fclk_shift_id.scheduled_hours
|
if plan.get('is_off'):
|
||||||
ICP = self.env['ir.config_parameter'].sudo()
|
return 0.0
|
||||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
return plan.get('hours') or 0.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)
|
|
||||||
|
|
||||||
def _compute_absence_counts(self):
|
def _compute_absence_counts(self):
|
||||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||||
|
|||||||
@@ -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_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_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_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_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_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
|
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_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_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_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
|
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
|
||||||
|
|||||||
|
@@ -174,6 +174,49 @@
|
|||||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||||
</record>
|
</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
|
Record Rules - Correction Request
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
@@ -286,4 +329,15 @@
|
|||||||
<field name="perm_unlink" eval="False"/>
|
<field name="perm_unlink" eval="False"/>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
|
|||||||
opacity: 0.5;
|
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 ---- */
|
/* ---- Timer Section ---- */
|
||||||
.fclk-timer-section {
|
.fclk-timer-section {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal 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);
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal 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>
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
from . import test_nfc_models
|
from . import test_nfc_models
|
||||||
from . import test_clock_nfc_kiosk
|
from . import test_clock_nfc_kiosk
|
||||||
|
from . import test_shift_planner
|
||||||
|
|||||||
254
fusion_clock/tests/test_shift_planner.py
Normal file
254
fusion_clock/tests/test_shift_planner.py
Normal 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)
|
||||||
@@ -16,6 +16,34 @@
|
|||||||
sequence="5"
|
sequence="5"
|
||||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
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 -->
|
<!-- Attendance Sub-Menu -->
|
||||||
<menuitem id="menu_fusion_clock_attendance"
|
<menuitem id="menu_fusion_clock_attendance"
|
||||||
name="Attendance"
|
name="Attendance"
|
||||||
|
|||||||
128
fusion_clock/views/clock_schedule_views.xml
Normal file
128
fusion_clock/views/clock_schedule_views.xml
Normal 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>
|
||||||
@@ -142,6 +142,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Timer Section -->
|
||||||
<div class="fclk-timer-section">
|
<div class="fclk-timer-section">
|
||||||
<div class="fclk-timer-label" id="fclk-timer-label">
|
<div class="fclk-timer-label" id="fclk-timer-label">
|
||||||
|
|||||||
BIN
fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -166,6 +166,19 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
|||||||
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
||||||
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
||||||
|
|
||||||
|
## Shop-floor action endpoints — credit the correct tech via `tablet_tech_id`
|
||||||
|
The tablet sits on a long-lived "shopfloor service" Odoo session shared by many techs. The actual tech-of-record is established via the PIN unlock (Phase 6); their id lives in the OWL `fp_shopfloor_tech_store` service and is sent as `tablet_tech_id` on every action RPC.
|
||||||
|
|
||||||
|
When writing a NEW shop-floor controller endpoint that **writes** (creates a record, calls a `button_*` method, posts to chatter):
|
||||||
|
1. Add `tablet_tech_id=None` as a kwarg on the route handler.
|
||||||
|
2. At the top, call: `env = env_for_tablet_tech(request.env, tablet_tech_id)` (from `fusion_plating_shopfloor/controllers/_tablet_audit.py`).
|
||||||
|
3. Use `env` (not `request.env`) for all subsequent writes. `env.with_user(...)` is applied internally so `create_uid` / `write_uid` / chatter authorship carry the right uid.
|
||||||
|
4. Read-only endpoints (load / kanban / funnel / overview) don't need this — leave them as `request.env`.
|
||||||
|
|
||||||
|
On the client side: use `fpRpc()` from `services/fp_rpc.js` (drop-in for `rpc()`) for action calls. It auto-injects `tablet_tech_id`. Read calls can keep using plain `rpc()`.
|
||||||
|
|
||||||
|
If `tablet_tech_id` is missing or invalid, `env_for_tablet_tech` falls back to the session uid — old callers and pre-Phase-6.3 endpoints continue working.
|
||||||
|
|
||||||
## Removing menus/records — Odoo does NOT auto-delete orphans
|
## Removing menus/records — Odoo does NOT auto-delete orphans
|
||||||
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
||||||
```xml
|
```xml
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.30.1.0',
|
'version': '19.0.30.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'first-piece inspection gates.',
|
||||||
@@ -85,6 +85,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# ---- Phase 6.2 tablet PIN gate ----
|
# ---- Phase 6.2 tablet PIN gate ----
|
||||||
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
|
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
|
||||||
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
|
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
|
||||||
|
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
|
||||||
|
# (job_workspace, shopfloor_landing, manager_dashboard,
|
||||||
|
# hold_composer) so `import { fpRpc }` resolves.
|
||||||
|
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
|
||||||
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
||||||
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
|
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
|
||||||
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
|
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Helper for audit-credit propagation (Phase 6.3 tablet redesign).
|
||||||
|
|
||||||
|
Controllers that accept an optional `tablet_tech_id` kwarg use this
|
||||||
|
helper to switch their `env` to the tech-of-record before performing
|
||||||
|
writes. The result: chatter posts + create_uid/write_uid carry the
|
||||||
|
unlocked tech's identity, not the tablet's persistent session user.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def env_for_tablet_tech(env, tablet_tech_id):
|
||||||
|
"""Return an env scoped to `tablet_tech_id` if it's a valid user;
|
||||||
|
otherwise return the original env unchanged.
|
||||||
|
|
||||||
|
Validation: the user must exist and be active. We deliberately do
|
||||||
|
NOT cross-check that they actually unlocked recently — the OWL
|
||||||
|
component is the source of truth for "who's at the tablet right
|
||||||
|
now", and the only path that produces a tablet_tech_id is a
|
||||||
|
successful /fp/tablet/unlock followed by an active session in the
|
||||||
|
OWL tech_store.
|
||||||
|
"""
|
||||||
|
if not tablet_tech_id:
|
||||||
|
return env
|
||||||
|
try:
|
||||||
|
tech_id = int(tablet_tech_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return env
|
||||||
|
User = env['res.users'].sudo()
|
||||||
|
tech = User.browse(tech_id)
|
||||||
|
if not tech.exists() or not tech.active:
|
||||||
|
_logger.warning(
|
||||||
|
"tablet_tech_id %s invalid (not found or inactive); "
|
||||||
|
"falling back to session uid %s",
|
||||||
|
tablet_tech_id, env.uid,
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
return env(user=tech_id)
|
||||||
@@ -25,6 +25,8 @@ from odoo import fields, http
|
|||||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
|
from ._tablet_audit import env_for_tablet_tech
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -387,7 +389,8 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
# Assign a worker to a step
|
# Assign a worker to a step
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
||||||
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs):
|
def assign_worker(self, step_id=None, user_id=None, workorder_id=None,
|
||||||
|
tablet_tech_id=None, **kwargs):
|
||||||
"""Assign an operator to a step. ``step_id`` is the canonical
|
"""Assign an operator to a step. ``step_id`` is the canonical
|
||||||
kwarg; ``workorder_id`` is accepted as a deprecated alias for
|
kwarg; ``workorder_id`` is accepted as a deprecated alias for
|
||||||
one release so any caller we missed doesn't break.
|
one release so any caller we missed doesn't break.
|
||||||
@@ -400,7 +403,8 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
step_id = workorder_id
|
step_id = workorder_id
|
||||||
if not step_id:
|
if not step_id:
|
||||||
return {'ok': False, 'error': 'step_id required'}
|
return {'ok': False, 'error': 'step_id required'}
|
||||||
step = request.env['fp.job.step'].browse(int(step_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
step = env['fp.job.step'].browse(int(step_id))
|
||||||
if not step.exists():
|
if not step.exists():
|
||||||
return {'ok': False, 'error': 'Step not found.'}
|
return {'ok': False, 'error': 'Step not found.'}
|
||||||
step.assigned_user_id = int(user_id) if user_id else False
|
step.assigned_user_id = int(user_id) if user_id else False
|
||||||
@@ -415,7 +419,8 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
# Reassign or swap tank on a step
|
# Reassign or swap tank on a step
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
|
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
|
||||||
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs):
|
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None,
|
||||||
|
tablet_tech_id=None, **kwargs):
|
||||||
"""Swap the tank on a step. ``step_id`` is the canonical kwarg;
|
"""Swap the tank on a step. ``step_id`` is the canonical kwarg;
|
||||||
``workorder_id`` is accepted as a deprecated alias.
|
``workorder_id`` is accepted as a deprecated alias.
|
||||||
"""
|
"""
|
||||||
@@ -427,7 +432,8 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
step_id = workorder_id
|
step_id = workorder_id
|
||||||
if not step_id:
|
if not step_id:
|
||||||
return {'ok': False, 'error': 'step_id required'}
|
return {'ok': False, 'error': 'step_id required'}
|
||||||
step = request.env['fp.job.step'].browse(int(step_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
step = env['fp.job.step'].browse(int(step_id))
|
||||||
if not step.exists():
|
if not step.exists():
|
||||||
return {'ok': False, 'error': 'Step not found.'}
|
return {'ok': False, 'error': 'Step not found.'}
|
||||||
step.tank_id = int(tank_id) if tank_id else False
|
step.tank_id = int(tank_id) if tank_id else False
|
||||||
@@ -442,7 +448,8 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
# Manager takes over a step (no-show coverage)
|
# Manager takes over a step (no-show coverage)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
|
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
|
||||||
def take_over(self, step_id=None, workorder_id=None, **kwargs):
|
def take_over(self, step_id=None, workorder_id=None,
|
||||||
|
tablet_tech_id=None, **kwargs):
|
||||||
"""Manager takes over a step. ``step_id`` is the canonical kwarg;
|
"""Manager takes over a step. ``step_id`` is the canonical kwarg;
|
||||||
``workorder_id`` is accepted as a deprecated alias.
|
``workorder_id`` is accepted as a deprecated alias.
|
||||||
"""
|
"""
|
||||||
@@ -454,10 +461,11 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
step_id = workorder_id
|
step_id = workorder_id
|
||||||
if not step_id:
|
if not step_id:
|
||||||
return {'ok': False, 'error': 'step_id required'}
|
return {'ok': False, 'error': 'step_id required'}
|
||||||
step = request.env['fp.job.step'].browse(int(step_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
step = env['fp.job.step'].browse(int(step_id))
|
||||||
if not step.exists():
|
if not step.exists():
|
||||||
return {'ok': False, 'error': 'Step not found.'}
|
return {'ok': False, 'error': 'Step not found.'}
|
||||||
user = request.env.user
|
user = env.user
|
||||||
previous = step.assigned_user_id.name or '—'
|
previous = step.assigned_user_id.name or '—'
|
||||||
step.assigned_user_id = user.id
|
step.assigned_user_id = user.id
|
||||||
step.message_post(
|
step.message_post(
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from odoo.addons.fusion_plating.models.fp_tz import (
|
|||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
|
from ._tablet_audit import env_for_tablet_tech
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -255,11 +257,13 @@ class FpShopfloorController(http.Controller):
|
|||||||
# Quick chemistry log from the tablet
|
# Quick chemistry log from the tablet
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
|
||||||
def log_chemistry(self, bath_id, readings, shift=None, notes=None):
|
def log_chemistry(self, bath_id, readings, shift=None, notes=None,
|
||||||
|
tablet_tech_id=None):
|
||||||
"""Create a fusion.plating.bath.log with one line per reading."""
|
"""Create a fusion.plating.bath.log with one line per reading."""
|
||||||
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
if not bath_id:
|
if not bath_id:
|
||||||
raise UserError("bath_id required")
|
raise UserError("bath_id required")
|
||||||
bath = request.env['fusion.plating.bath'].browse(int(bath_id))
|
bath = env['fusion.plating.bath'].browse(int(bath_id))
|
||||||
if not bath.exists():
|
if not bath.exists():
|
||||||
raise UserError(f"Bath {bath_id} not found")
|
raise UserError(f"Bath {bath_id} not found")
|
||||||
|
|
||||||
@@ -274,7 +278,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'value': float(value) if value not in (None, '') else 0.0,
|
'value': float(value) if value not in (None, '') else 0.0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
log = request.env['fusion.plating.bath.log'].create({
|
log = env['fusion.plating.bath.log'].create({
|
||||||
'bath_id': bath.id,
|
'bath_id': bath.id,
|
||||||
'shift': shift or False,
|
'shift': shift or False,
|
||||||
'notes': notes or False,
|
'notes': notes or False,
|
||||||
@@ -291,10 +295,11 @@ class FpShopfloorController(http.Controller):
|
|||||||
# Bake window controls
|
# Bake window controls
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
|
||||||
def start_bake(self, bake_window_id, oven_id=None):
|
def start_bake(self, bake_window_id, oven_id=None, tablet_tech_id=None):
|
||||||
# action_start_bake raises UserError for S6 missed_window. Wrap
|
# action_start_bake raises UserError for S6 missed_window. Wrap
|
||||||
# the same way as start_wo so operator gets a clean flash.
|
# the same way as start_wo so operator gets a clean flash.
|
||||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||||
if not bw.exists():
|
if not bw.exists():
|
||||||
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
||||||
if oven_id:
|
if oven_id:
|
||||||
@@ -306,12 +311,13 @@ class FpShopfloorController(http.Controller):
|
|||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'state': bw.state,
|
'state': bw.state,
|
||||||
'bake_start_time': fp_format(request.env, bw.bake_start_time),
|
'bake_start_time': fp_format(env, bw.bake_start_time),
|
||||||
}
|
}
|
||||||
|
|
||||||
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
|
||||||
def end_bake(self, bake_window_id):
|
def end_bake(self, bake_window_id, tablet_tech_id=None):
|
||||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||||
if not bw.exists():
|
if not bw.exists():
|
||||||
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
|
||||||
try:
|
try:
|
||||||
@@ -321,7 +327,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'state': bw.state,
|
'state': bw.state,
|
||||||
'bake_end_time': fp_format(request.env, bw.bake_end_time),
|
'bake_end_time': fp_format(env, bw.bake_end_time),
|
||||||
'bake_duration_hours': bw.bake_duration_hours,
|
'bake_duration_hours': bw.bake_duration_hours,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +346,15 @@ class FpShopfloorController(http.Controller):
|
|||||||
step = request.env['fp.job.step'].browse(int(sid))
|
step = request.env['fp.job.step'].browse(int(sid))
|
||||||
return step if step.exists() else False
|
return step if step.exists() else False
|
||||||
|
|
||||||
|
def _resolve_step_in_env(self, env, step_id=None, workorder_id=None):
|
||||||
|
sid = step_id if step_id else workorder_id
|
||||||
|
if not sid:
|
||||||
|
return False
|
||||||
|
step = env['fp.job.step'].browse(int(sid))
|
||||||
|
return step if step.exists() else False
|
||||||
|
|
||||||
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
|
||||||
def start_wo(self, workorder_id=None, step_id=None):
|
def start_wo(self, workorder_id=None, step_id=None, tablet_tech_id=None):
|
||||||
"""Start the timer on a fp.job.step (called from the tablet).
|
"""Start the timer on a fp.job.step (called from the tablet).
|
||||||
|
|
||||||
button_start() can raise UserError for any guarded condition
|
button_start() can raise UserError for any guarded condition
|
||||||
@@ -350,7 +363,8 @@ class FpShopfloorController(http.Controller):
|
|||||||
the explicit state check, so the tablet flashes a clean toast
|
the explicit state check, so the tablet flashes a clean toast
|
||||||
instead of popping a stack-trace dialog at the operator.
|
instead of popping a stack-trace dialog at the operator.
|
||||||
"""
|
"""
|
||||||
step = self._resolve_step(step_id, workorder_id)
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
step = self._resolve_step_in_env(env, step_id, workorder_id)
|
||||||
if not step:
|
if not step:
|
||||||
return {'ok': False, 'error': 'Step not found'}
|
return {'ok': False, 'error': 'Step not found'}
|
||||||
if not _step_can_start(step):
|
if not _step_can_start(step):
|
||||||
@@ -369,7 +383,8 @@ class FpShopfloorController(http.Controller):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
|
||||||
def stop_wo(self, workorder_id=None, step_id=None, finish=False):
|
def stop_wo(self, workorder_id=None, step_id=None, finish=False,
|
||||||
|
tablet_tech_id=None):
|
||||||
"""Finish the timer on a fp.job.step.
|
"""Finish the timer on a fp.job.step.
|
||||||
|
|
||||||
finish=True calls button_finish(); other values are no-ops for
|
finish=True calls button_finish(); other values are no-ops for
|
||||||
@@ -380,7 +395,8 @@ class FpShopfloorController(http.Controller):
|
|||||||
not provided). Wrapped same as start_wo so the operator gets a
|
not provided). Wrapped same as start_wo so the operator gets a
|
||||||
clean flash, not a stack-trace dialog.
|
clean flash, not a stack-trace dialog.
|
||||||
"""
|
"""
|
||||||
step = self._resolve_step(step_id, workorder_id)
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
step = self._resolve_step_in_env(env, step_id, workorder_id)
|
||||||
if not step:
|
if not step:
|
||||||
return {'ok': False, 'error': 'Step not found'}
|
return {'ok': False, 'error': 'Step not found'}
|
||||||
if finish:
|
if finish:
|
||||||
@@ -409,11 +425,12 @@ class FpShopfloorController(http.Controller):
|
|||||||
# both with a single tap. Scrap auto-spawns a hold via fp.job.write
|
# both with a single tap. Scrap auto-spawns a hold via fp.job.write
|
||||||
# (S17 hook, no extra wiring needed here).
|
# (S17 hook, no extra wiring needed here).
|
||||||
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
|
||||||
def bump_qty_done(self, job_id, delta=1):
|
def bump_qty_done(self, job_id, delta=1, tablet_tech_id=None):
|
||||||
"""Increment job.qty_done by `delta` (defaults to +1).
|
"""Increment job.qty_done by `delta` (defaults to +1).
|
||||||
Returns the new totals so the tablet can update without a full refresh.
|
Returns the new totals so the tablet can update without a full refresh.
|
||||||
"""
|
"""
|
||||||
job = request.env['fp.job'].browse(int(job_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
job = env['fp.job'].browse(int(job_id))
|
||||||
if not job.exists():
|
if not job.exists():
|
||||||
return {'ok': False, 'error': 'Job not found'}
|
return {'ok': False, 'error': 'Job not found'}
|
||||||
try:
|
try:
|
||||||
@@ -433,13 +450,15 @@ class FpShopfloorController(http.Controller):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
|
||||||
def bump_qty_scrapped(self, job_id, delta=1, reason=None):
|
def bump_qty_scrapped(self, job_id, delta=1, reason=None,
|
||||||
|
tablet_tech_id=None):
|
||||||
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on
|
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on
|
||||||
fp.job auto-spawns a fusion.plating.quality.hold for the delta;
|
fp.job auto-spawns a fusion.plating.quality.hold for the delta;
|
||||||
the operator can edit the description on that hold later.
|
the operator can edit the description on that hold later.
|
||||||
`reason` is optional — passed through to the hold's description.
|
`reason` is optional — passed through to the hold's description.
|
||||||
"""
|
"""
|
||||||
job = request.env['fp.job'].browse(int(job_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
job = env['fp.job'].browse(int(job_id))
|
||||||
if not job.exists():
|
if not job.exists():
|
||||||
return {'ok': False, 'error': 'Job not found'}
|
return {'ok': False, 'error': 'Job not found'}
|
||||||
try:
|
try:
|
||||||
@@ -470,20 +489,22 @@ class FpShopfloorController(http.Controller):
|
|||||||
position_label=None, reading_number=None,
|
position_label=None, reading_number=None,
|
||||||
equipment_model=None, calibration_std_ref=None,
|
equipment_model=None, calibration_std_ref=None,
|
||||||
microscope_image=None,
|
microscope_image=None,
|
||||||
microscope_image_filename=None):
|
microscope_image_filename=None,
|
||||||
|
tablet_tech_id=None):
|
||||||
"""Record a single Fischerscope reading against a job.
|
"""Record a single Fischerscope reading against a job.
|
||||||
|
|
||||||
`job_id` is the canonical kwarg; `production_id` is accepted as an
|
`job_id` is the canonical kwarg; `production_id` is accepted as an
|
||||||
alias for older clients. The reading auto-links to an existing
|
alias for older clients. The reading auto-links to an existing
|
||||||
CoC certificate for the job when one exists.
|
CoC certificate for the job when one exists.
|
||||||
"""
|
"""
|
||||||
Reading = request.env.get('fp.thickness.reading')
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
Reading = env.get('fp.thickness.reading')
|
||||||
if Reading is None:
|
if Reading is None:
|
||||||
return {'ok': False, 'error': 'Certificates module not installed'}
|
return {'ok': False, 'error': 'Certificates module not installed'}
|
||||||
target_id = job_id or production_id
|
target_id = job_id or production_id
|
||||||
if not target_id:
|
if not target_id:
|
||||||
return {'ok': False, 'error': 'job_id required'}
|
return {'ok': False, 'error': 'job_id required'}
|
||||||
job = request.env['fp.job'].browse(int(target_id))
|
job = env['fp.job'].browse(int(target_id))
|
||||||
if not job.exists():
|
if not job.exists():
|
||||||
return {'ok': False, 'error': f'Job {target_id} not found'}
|
return {'ok': False, 'error': f'Job {target_id} not found'}
|
||||||
|
|
||||||
@@ -508,7 +529,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'ni_percent': float(ni_percent or 0.0),
|
'ni_percent': float(ni_percent or 0.0),
|
||||||
'p_percent': float(p_percent or 0.0),
|
'p_percent': float(p_percent or 0.0),
|
||||||
'position_label': position_label or '',
|
'position_label': position_label or '',
|
||||||
'operator_id': request.env.user.id,
|
'operator_id': env.user.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if equipment_model:
|
if equipment_model:
|
||||||
@@ -516,7 +537,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
if calibration_std_ref:
|
if calibration_std_ref:
|
||||||
vals['calibration_std_ref'] = calibration_std_ref
|
vals['calibration_std_ref'] = calibration_std_ref
|
||||||
if microscope_image:
|
if microscope_image:
|
||||||
att = request.env['ir.attachment'].create({
|
att = env['ir.attachment'].create({
|
||||||
'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
|
'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
|
||||||
'datas': microscope_image,
|
'datas': microscope_image,
|
||||||
'res_model': 'fp.thickness.reading',
|
'res_model': 'fp.thickness.reading',
|
||||||
@@ -525,7 +546,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
vals['microscope_image_id'] = att.id
|
vals['microscope_image_id'] = att.id
|
||||||
|
|
||||||
# Auto-link to an existing CoC if there is one for this job.
|
# Auto-link to an existing CoC if there is one for this job.
|
||||||
Cert = request.env.get('fp.certificate')
|
Cert = env.get('fp.certificate')
|
||||||
if Cert is not None:
|
if Cert is not None:
|
||||||
if 'x_fc_job_id' in Cert._fields:
|
if 'x_fc_job_id' in Cert._fields:
|
||||||
cert_field = 'x_fc_job_id'
|
cert_field = 'x_fc_job_id'
|
||||||
@@ -557,7 +578,8 @@ class FpShopfloorController(http.Controller):
|
|||||||
part_ref=None, qty_on_hold=0, qty_original=0,
|
part_ref=None, qty_on_hold=0, qty_original=0,
|
||||||
hold_reason='other', description=None,
|
hold_reason='other', description=None,
|
||||||
mark_for_scrap=False, facility_id=None,
|
mark_for_scrap=False, facility_id=None,
|
||||||
work_center_id=None, current_process_node=None):
|
work_center_id=None, current_process_node=None,
|
||||||
|
tablet_tech_id=None):
|
||||||
"""Create a quality hold record, splitting qty from the original lot.
|
"""Create a quality hold record, splitting qty from the original lot.
|
||||||
|
|
||||||
The hold is linked to the fp.job and (when provided) the
|
The hold is linked to the fp.job and (when provided) the
|
||||||
@@ -566,7 +588,8 @@ class FpShopfloorController(http.Controller):
|
|||||||
if not qty_on_hold or int(qty_on_hold) <= 0:
|
if not qty_on_hold or int(qty_on_hold) <= 0:
|
||||||
raise UserError("qty_on_hold must be a positive integer.")
|
raise UserError("qty_on_hold must be a positive integer.")
|
||||||
|
|
||||||
Hold = request.env['fusion.plating.quality.hold']
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
Hold = env['fusion.plating.quality.hold']
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
'part_ref': part_ref or '',
|
'part_ref': part_ref or '',
|
||||||
@@ -583,7 +606,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
if work_center_id:
|
if work_center_id:
|
||||||
vals['work_center_id'] = int(work_center_id)
|
vals['work_center_id'] = int(work_center_id)
|
||||||
if portal_job_id:
|
if portal_job_id:
|
||||||
pj = request.env['fusion.plating.portal.job'].browse(
|
pj = env['fusion.plating.portal.job'].browse(
|
||||||
int(portal_job_id),
|
int(portal_job_id),
|
||||||
)
|
)
|
||||||
if pj.exists():
|
if pj.exists():
|
||||||
@@ -594,7 +617,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
# via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`.
|
# via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`.
|
||||||
step_target_id = step_id or workorder_id
|
step_target_id = step_id or workorder_id
|
||||||
if step_target_id:
|
if step_target_id:
|
||||||
step = request.env['fp.job.step'].browse(int(step_target_id))
|
step = env['fp.job.step'].browse(int(step_target_id))
|
||||||
if step.exists():
|
if step.exists():
|
||||||
if 'x_fc_step_id' in Hold._fields:
|
if 'x_fc_step_id' in Hold._fields:
|
||||||
vals['x_fc_step_id'] = step.id
|
vals['x_fc_step_id'] = step.id
|
||||||
@@ -605,7 +628,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
# set it through the step.
|
# set it through the step.
|
||||||
if (job_id and 'x_fc_job_id' in Hold._fields
|
if (job_id and 'x_fc_job_id' in Hold._fields
|
||||||
and not vals.get('x_fc_job_id')):
|
and not vals.get('x_fc_job_id')):
|
||||||
j = request.env['fp.job'].browse(int(job_id))
|
j = env['fp.job'].browse(int(job_id))
|
||||||
if j.exists():
|
if j.exists():
|
||||||
vals['x_fc_job_id'] = j.id
|
vals['x_fc_job_id'] = j.id
|
||||||
|
|
||||||
@@ -995,8 +1018,9 @@ class FpShopfloorController(http.Controller):
|
|||||||
# Mark a first-piece gate result from the tablet
|
# Mark a first-piece gate result from the tablet
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
|
||||||
def mark_gate(self, gate_id, result):
|
def mark_gate(self, gate_id, result, tablet_tech_id=None):
|
||||||
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
||||||
if not gate.exists():
|
if not gate.exists():
|
||||||
return {'ok': False, 'error': 'Gate not found.'}
|
return {'ok': False, 'error': 'Gate not found.'}
|
||||||
try:
|
try:
|
||||||
@@ -1084,21 +1108,23 @@ class FpShopfloorController(http.Controller):
|
|||||||
@http.route('/fp/shopfloor/plant_overview/move_card',
|
@http.route('/fp/shopfloor/plant_overview/move_card',
|
||||||
type='jsonrpc', auth='user')
|
type='jsonrpc', auth='user')
|
||||||
def plant_overview_move_card(self, card_id, source_model=None,
|
def plant_overview_move_card(self, card_id, source_model=None,
|
||||||
target_workcenter_id=None):
|
target_workcenter_id=None,
|
||||||
|
tablet_tech_id=None):
|
||||||
"""Move a step card to a different work centre (drag & drop).
|
"""Move a step card to a different work centre (drag & drop).
|
||||||
|
|
||||||
`source_model` is accepted for backward compatibility but ignored —
|
`source_model` is accepted for backward compatibility but ignored —
|
||||||
Plant Overview now only ever serves fp.job.step cards. A target
|
Plant Overview now only ever serves fp.job.step cards. A target
|
||||||
of 0 / falsy clears the work centre.
|
of 0 / falsy clears the work centre.
|
||||||
"""
|
"""
|
||||||
Step = request.env['fp.job.step']
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
|
Step = env['fp.job.step']
|
||||||
step = Step.browse(int(card_id))
|
step = Step.browse(int(card_id))
|
||||||
if not step.exists():
|
if not step.exists():
|
||||||
return {'ok': False, 'error': f'Step {card_id} not found.'}
|
return {'ok': False, 'error': f'Step {card_id} not found.'}
|
||||||
|
|
||||||
wc_id = int(target_workcenter_id) if target_workcenter_id else False
|
wc_id = int(target_workcenter_id) if target_workcenter_id else False
|
||||||
if wc_id:
|
if wc_id:
|
||||||
wc = request.env['fp.work.centre'].browse(wc_id)
|
wc = env['fp.work.centre'].browse(wc_id)
|
||||||
if not wc.exists():
|
if not wc.exists():
|
||||||
return {'ok': False,
|
return {'ok': False,
|
||||||
'error': f'Work centre {target_workcenter_id} not found.'}
|
'error': f'Work centre {target_workcenter_id} not found.'}
|
||||||
@@ -1108,7 +1134,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
_logger.info(
|
_logger.info(
|
||||||
'Plant Overview: moved step %s (%s) → WC %s by uid %s',
|
'Plant Overview: moved step %s (%s) → WC %s by uid %s',
|
||||||
step.id, step.name, wc_id or 'unassigned',
|
step.id, step.name, wc_id or 'unassigned',
|
||||||
request.env.uid,
|
env.uid,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('Plant Overview move_card failed')
|
_logger.exception('Plant Overview move_card failed')
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from odoo import fields, http
|
|||||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
|
from ._tablet_audit import env_for_tablet_tech
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,8 +192,8 @@ class FpWorkspaceController(http.Controller):
|
|||||||
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
|
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
|
||||||
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
|
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
|
||||||
part_ref='', step_id=None, mark_for_scrap=False,
|
part_ref='', step_id=None, mark_for_scrap=False,
|
||||||
photo_data=None, photo_filename=None):
|
photo_data=None, photo_filename=None, tablet_tech_id=None):
|
||||||
env = request.env
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
job = env['fp.job'].browse(int(job_id))
|
job = env['fp.job'].browse(int(job_id))
|
||||||
if not job.exists():
|
if not job.exists():
|
||||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||||
@@ -253,8 +255,8 @@ class FpWorkspaceController(http.Controller):
|
|||||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||||
def sign_off(self, step_id, signature_data_uri):
|
def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
|
||||||
env = request.env
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
sig = (signature_data_uri or '').strip()
|
sig = (signature_data_uri or '').strip()
|
||||||
if not sig:
|
if not sig:
|
||||||
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
|
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
|
||||||
@@ -302,8 +304,8 @@ class FpWorkspaceController(http.Controller):
|
|||||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
|
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
|
||||||
def advance_milestone(self, job_id):
|
def advance_milestone(self, job_id, tablet_tech_id=None):
|
||||||
env = request.env
|
env = env_for_tablet_tech(request.env, tablet_tech_id)
|
||||||
job = env['fp.job'].browse(int(job_id))
|
job = env['fp.job'].browse(int(job_id))
|
||||||
if not job.exists():
|
if not job.exists():
|
||||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { Component, useState } from "@odoo/owl";
|
import { Component, useState } from "@odoo/owl";
|
||||||
import { Dialog } from "@web/core/dialog/dialog";
|
import { Dialog } from "@web/core/dialog/dialog";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { fpRpc } from "../services/fp_rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
// Hold reasons kept here so the picker doesn't need a server roundtrip.
|
// Hold reasons kept here so the picker doesn't need a server roundtrip.
|
||||||
@@ -75,7 +75,7 @@ export class FpHoldComposer extends Component {
|
|||||||
}
|
}
|
||||||
this.state.submitting = true;
|
this.state.submitting = true;
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/workspace/hold", {
|
const res = await fpRpc("/fp/workspace/hold", {
|
||||||
job_id: this.props.jobId,
|
job_id: this.props.jobId,
|
||||||
step_id: this.props.stepId || null,
|
step_id: this.props.stepId || null,
|
||||||
part_ref: this.props.partRef || "",
|
part_ref: this.props.partRef || "",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { fpRpc } from "./services/fp_rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { WorkflowChip } from "./components/workflow_chip";
|
import { WorkflowChip } from "./components/workflow_chip";
|
||||||
import { GateViz } from "./components/gate_viz";
|
import { GateViz } from "./components/gate_viz";
|
||||||
@@ -116,7 +117,7 @@ export class FpJobWorkspace extends Component {
|
|||||||
// ---- Step actions ------------------------------------------------------
|
// ---- Step actions ------------------------------------------------------
|
||||||
async onStartStep(stepId) {
|
async onStartStep(stepId) {
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
const res = await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
this.notification.add("Step started.", { type: "success" });
|
this.notification.add("Step started.", { type: "success" });
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
@@ -135,7 +136,7 @@ export class FpJobWorkspace extends Component {
|
|||||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||||
onSubmit: async (dataUri) => {
|
onSubmit: async (dataUri) => {
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/workspace/sign_off", {
|
const res = await fpRpc("/fp/workspace/sign_off", {
|
||||||
step_id: step.id,
|
step_id: step.id,
|
||||||
signature_data_uri: dataUri,
|
signature_data_uri: dataUri,
|
||||||
});
|
});
|
||||||
@@ -154,7 +155,7 @@ export class FpJobWorkspace extends Component {
|
|||||||
}
|
}
|
||||||
// Plain finish — no signature required
|
// Plain finish — no signature required
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/shopfloor/stop_wo", {
|
const res = await fpRpc("/fp/shopfloor/stop_wo", {
|
||||||
workorder_id: step.id, finish: true,
|
workorder_id: step.id, finish: true,
|
||||||
});
|
});
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
@@ -201,7 +202,7 @@ export class FpJobWorkspace extends Component {
|
|||||||
|
|
||||||
async onAdvanceMilestone() {
|
async onAdvanceMilestone() {
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/workspace/advance_milestone", {
|
const res = await fpRpc("/fp/workspace/advance_milestone", {
|
||||||
job_id: this.state.jobId,
|
job_id: this.state.jobId,
|
||||||
});
|
});
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { fpRpc } from "./services/fp_rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { QrScanner } from "./qr_scanner";
|
import { QrScanner } from "./qr_scanner";
|
||||||
import { FpTabletLock } from "./tablet_lock";
|
import { FpTabletLock } from "./tablet_lock";
|
||||||
@@ -208,7 +209,7 @@ export class ManagerDashboard extends Component {
|
|||||||
async onAssignWorker(step, userIdRaw) {
|
async onAssignWorker(step, userIdRaw) {
|
||||||
const userId = parseInt(userIdRaw) || null;
|
const userId = parseInt(userIdRaw) || null;
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/manager/assign_worker", {
|
const res = await fpRpc("/fp/manager/assign_worker", {
|
||||||
step_id: step.id, user_id: userId,
|
step_id: step.id, user_id: userId,
|
||||||
});
|
});
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
@@ -226,7 +227,7 @@ export class ManagerDashboard extends Component {
|
|||||||
async onAssignTank(step, tankIdRaw) {
|
async onAssignTank(step, tankIdRaw) {
|
||||||
const tankId = parseInt(tankIdRaw) || null;
|
const tankId = parseInt(tankIdRaw) || null;
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/manager/assign_tank", {
|
const res = await fpRpc("/fp/manager/assign_tank", {
|
||||||
step_id: step.id, tank_id: tankId,
|
step_id: step.id, tank_id: tankId,
|
||||||
});
|
});
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
@@ -243,7 +244,7 @@ export class ManagerDashboard extends Component {
|
|||||||
|
|
||||||
async onTakeOver(step) {
|
async onTakeOver(step) {
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/manager/take_over", {
|
const res = await fpRpc("/fp/manager/take_over", {
|
||||||
step_id: step.id,
|
step_id: step.id,
|
||||||
});
|
});
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
// =============================================================================
|
||||||
|
// Fusion Plating — fpRpc() wrapper
|
||||||
|
//
|
||||||
|
// Drop-in replacement for the standard `rpc()` import. Automatically
|
||||||
|
// injects the current tablet_tech_id from the tech_store into every
|
||||||
|
// call, so server-side endpoints can attribute the action to the right
|
||||||
|
// user via env.with_user() (see env_for_tablet_tech in
|
||||||
|
// controllers/_tablet_audit.py).
|
||||||
|
//
|
||||||
|
// USE for any RPC that WRITES (start step, finish step, hold create,
|
||||||
|
// sign-off, milestone advance). For read-only loads (kanban, workspace
|
||||||
|
// load, manager funnel), plain rpc() is fine.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// import { fpRpc } from "../services/fp_rpc";
|
||||||
|
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||||
|
//
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { rpc as baseRpc } from "@web/core/network/rpc";
|
||||||
|
|
||||||
|
function _getTechStore() {
|
||||||
|
// Lazy-resolve via the global debug API — avoids circular service init
|
||||||
|
try {
|
||||||
|
const env = odoo.__WOWL_DEBUG__?.root?.env;
|
||||||
|
if (env && env.services && env.services.fp_shopfloor_tech_store) {
|
||||||
|
return env.services.fp_shopfloor_tech_store;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fpRpc(url, params = {}) {
|
||||||
|
const techStore = _getTechStore();
|
||||||
|
if (techStore && techStore.currentTechId) {
|
||||||
|
params = { ...params, tablet_tech_id: techStore.currentTechId };
|
||||||
|
}
|
||||||
|
return baseRpc(url, params);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { fpRpc } from "./services/fp_rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { QrScanner } from "./qr_scanner";
|
import { QrScanner } from "./qr_scanner";
|
||||||
import { FpKanbanCard } from "./components/kanban_card";
|
import { FpKanbanCard } from "./components/kanban_card";
|
||||||
@@ -254,7 +255,7 @@ export class FpShopfloorLanding extends Component {
|
|||||||
this._movesInFlight += 1;
|
this._movesInFlight += 1;
|
||||||
this._lastDropAt = Date.now();
|
this._lastDropAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/shopfloor/plant_overview/move_card", {
|
const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
|
||||||
card_id: dragged.id,
|
card_id: dragged.id,
|
||||||
target_workcenter_id: col.work_center_id,
|
target_workcenter_id: col.work_center_id,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user