From ea4f216c1a1361216024170c6467ab9bfb536323 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 31 May 2026 02:17:54 -0400 Subject: [PATCH] docs(fusion_clock): dashboard redesign implementation plan (TDD, 8 tasks) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-05-31-dashboard-redesign.md | 1142 +++++++++++++++++ 1 file changed, 1142 insertions(+) create mode 100644 fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md diff --git a/fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md b/fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md new file mode 100644 index 00000000..b3bf8edb --- /dev/null +++ b/fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md @@ -0,0 +1,1142 @@ +# Fusion Clock Dashboard Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the manager-only aggregate dashboard with one layered, role-aware dashboard — a personal band for everyone, a team band for leads (their direct reports), and an org band for managers — styled with vibrant gradient KPI cards that work in light and dark mode, never leaking another employee's data. + +**Architecture:** One reworked JSON endpoint (`/fusion_clock/dashboard_data`) builds a `personal` block (always) plus a `team` block (only for lead/manager, server-scoped). The OWL client action renders a single stacked layout that conditionally shows the team band. SCSS uses compile-time `$o-webclient-color-scheme` branching (per repo rule) — gradient cards are identical in both modes; only page/section/text tokens swap. + +**Tech Stack:** Odoo 19, Python HTTP controller (`type='jsonrpc'`), OWL (`@odoo/owl` + `@web/core/network/rpc`), SCSS compiled into `web.assets_backend` + `web.assets_web_dark`, Odoo `HttpCase` tests. + +**Reference (read before coding):** the spec at `fusion_clock/docs/superpowers/specs/2026-05-31-dashboard-redesign-design.md`; repo-root `CLAUDE.md` dark-mode + test-port rules; `fusion_clock/CLAUDE.md`. + +**Test command** (fusion_clock module; substitute `odoo-modsdev-app` if that is your dev container name): +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \ + -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 +``` + +**Commit discipline (shared working tree — critical):** every commit uses `git commit --only -- ` (never `git add -A`). After staging, verify `git diff --cached --name-only` shows ONLY the intended files. Repo wrongly tracks `.DS_Store`/`*.pyc` — never stage them. Two remotes: push to BOTH `origin` and `gitea` at the end. + +--- + +## Task 1: Backend — personal block + endpoint opens to all users + +**Files:** +- Create: `fusion_clock/tests/test_dashboard.py` +- Modify: `fusion_clock/controllers/clock_api.py` (rework `dashboard_data` at line ~673; add `_dashboard_personal` helper) +- Modify: `fusion_clock/tests/__init__.py` (register the new test module) + +- [ ] **Step 1: Register the test module** + +Check `fusion_clock/tests/__init__.py` and add the import if missing: + +```python +from . import test_dashboard +``` + +- [ ] **Step 2: Write the failing test (employee sees only personal)** + +Create `fusion_clock/tests/test_dashboard.py`: + +```python +# -*- coding: utf-8 -*- +import json +from odoo.tests import tagged, HttpCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestFusionClockDashboard(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + Users = cls.env['res.users'] + Emp = cls.env['hr.employee'] + + g_user = cls.env.ref('fusion_clock.group_fusion_clock_user') + g_lead = cls.env.ref('fusion_clock.group_fusion_clock_team_lead') + g_mgr = cls.env.ref('fusion_clock.group_fusion_clock_manager') + g_internal = cls.env.ref('base.group_user') + + def mk_user(login, group): + return Users.create({ + 'name': login, + 'login': login, + 'password': login, + 'group_ids': [(6, 0, (g_internal + group).ids)], + }) + + cls.mgr_user = mk_user('fc_mgr', g_mgr) + cls.mgr_emp = Emp.create({ + 'name': 'Manager Mae', 'user_id': cls.mgr_user.id, + 'x_fclk_enable_clock': True, + }) + cls.lead_user = mk_user('fc_lead', g_lead) + cls.lead_emp = Emp.create({ + 'name': 'Lead Leo', 'user_id': cls.lead_user.id, + 'x_fclk_enable_clock': True, + }) + cls.rep1_user = mk_user('fc_rep1', g_user) + cls.rep1_emp = Emp.create({ + 'name': 'Report Rita', 'user_id': cls.rep1_user.id, + 'parent_id': cls.lead_emp.id, 'x_fclk_enable_clock': True, + }) + cls.rep2_user = mk_user('fc_rep2', g_user) + cls.rep2_emp = Emp.create({ + 'name': 'Report Raj', 'user_id': cls.rep2_user.id, + 'parent_id': cls.lead_emp.id, 'x_fclk_enable_clock': True, + }) + cls.other_user = mk_user('fc_other', g_user) + cls.other_emp = Emp.create({ + 'name': 'Outsider Olga', 'user_id': cls.other_user.id, + 'parent_id': cls.mgr_emp.id, 'x_fclk_enable_clock': True, + }) + + def _call(self): + resp = self.url_open( + '/fusion_clock/dashboard_data', + data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': {}}), + headers={'Content-Type': 'application/json'}, + ) + self.assertEqual(resp.status_code, 200) + result = resp.json()['result'] + self.assertFalse(isinstance(result, dict) and result.get('error'), msg=result) + return result + + def test_employee_sees_only_personal(self): + self.authenticate('fc_rep1', 'fc_rep1') + data = self._call() + self.assertEqual(data['role'], 'employee') + self.assertIsNone(data['team']) + self.assertEqual(data['personal']['employee_name'], 'Report Rita') + # personal block has the expected KPI keys + for key in ('today_hours', 'week_hours', 'overtime_week', 'ontime_streak', 'shift'): + self.assertIn(key, data['personal']) +``` + +- [ ] **Step 3: Run the test, verify it FAILS** + +Run the test command above. Expected: `test_employee_sees_only_personal` FAILS — the current endpoint returns `{'error': 'Access denied.'}` for a non-manager/non-lead, so `_call` raises on the `result.get('error')` assertion. + +- [ ] **Step 4: Implement `_dashboard_personal` + rework the endpoint** + +In `fusion_clock/controllers/clock_api.py`, add the helper method to the `FusionClockAPI` class (place it just before the existing `dashboard_data` route, ~line 673): + +```python + def _dashboard_personal(self, employee): + """Build the always-present personal block. Caller's own employee + only — never another employee's data.""" + env = request.env + local_today = get_local_today(env, employee) + day_plan = employee._get_fclk_day_plan(local_today) + + is_checked_in = employee.attendance_state == 'checked_in' + check_in = False + location_name = '' + if is_checked_in: + att = env['hr.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('check_out', '=', False), + ], limit=1) + if att: + check_in = fields.Datetime.to_string(att.check_in) + location_name = att.x_fclk_location_id.name or '' + + today_start_utc, today_end_utc = get_local_day_boundaries(env, local_today, employee) + today_atts = env['hr.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('check_in', '>=', fields.Datetime.to_string(today_start_utc)), + ('check_in', '<', fields.Datetime.to_string(today_end_utc)), + ('check_out', '!=', False), + ]) + today_hours = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2) + + week_start = local_today - timedelta(days=local_today.weekday()) + week_start_utc, _ignore = get_local_day_boundaries(env, week_start, employee) + week_atts = env['hr.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('check_in', '>=', fields.Datetime.to_string(week_start_utc)), + ('check_in', '<', fields.Datetime.to_string(today_end_utc)), + ('check_out', '!=', False), + ]) + week_hours = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2) + + if not employee.x_fclk_enable_clock: + status_note = 'Clock disabled' + elif day_plan.get('is_off'): + status_note = 'Day off' + elif not day_plan.get('scheduled'): + status_note = 'Not scheduled today' + elif is_checked_in: + status_note = 'Clocked in' + else: + status_note = 'Not clocked in' + + recent = env['hr.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('check_out', '!=', False), + ], order='check_in desc', limit=6) + recent_activity = [{ + 'check_in': fields.Datetime.to_string(a.check_in), + 'check_out': fields.Datetime.to_string(a.check_out), + 'worked_hours': round(a.worked_hours or 0, 2), + 'overtime_hours': round(a.x_fclk_overtime_hours or 0, 2), + 'location': a.x_fclk_location_id.name or '', + } for a in recent] + + leaves = env['fusion.clock.leave.request'].sudo().search([ + ('employee_id', '=', employee.id), + ('leave_date', '>=', local_today), + ], order='leave_date asc', limit=5) + leave_sel = dict(env['fusion.clock.leave.request']._fields['state'].selection) + leave_list = [{ + 'label': lv._fclk_date_label(), + 'state': leave_sel.get(lv.state, lv.state), + } for lv in leaves] + + month_start = local_today.replace(day=1) + penalties = env['fusion.clock.penalty'].sudo().search([ + ('employee_id', '=', employee.id), + ('date', '>=', month_start), + ], order='date desc', limit=5) + pen_sel = dict(env['fusion.clock.penalty']._fields['penalty_type'].selection) + penalty_list = [{ + 'type': pen_sel.get(p.penalty_type, p.penalty_type), + 'date': fields.Date.to_string(p.date), + 'minutes': round(p.penalty_minutes or 0, 1), + } for p in penalties] + + return { + 'employee_name': employee.name, + 'enable_clock': employee.x_fclk_enable_clock, + 'is_checked_in': is_checked_in, + 'check_in': check_in, + 'location_name': location_name, + 'pending_reason': employee.x_fclk_pending_reason, + 'today_hours': today_hours, + 'week_hours': week_hours, + 'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2), + 'ontime_streak': employee.x_fclk_ontime_streak, + 'shift': { + 'label': day_plan.get('label') or '', + 'hours': round(day_plan.get('hours') or 0.0, 2), + 'source': day_plan.get('source') or 'none', + 'scheduled_off': bool(day_plan.get('is_off')), + 'scheduled': bool(day_plan.get('scheduled')), + 'status_note': status_note, + }, + 'recent_activity': recent_activity, + 'leaves': leave_list, + 'penalties': penalty_list, + } +``` + +Then REPLACE the entire existing `dashboard_data` method body (from `@http.route('/fusion_clock/dashboard_data'...` through its `return {...}` at line ~747) with this simpler version (the team block is added in Task 2): + +```python + @http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST']) + def dashboard_data(self, **kw): + """Layered, role-aware dashboard payload. + + Everyone gets their own ``personal`` block. The ``team`` block is + added ONLY for team leads (their direct reports) and managers + (org-wide) — see Task 2. A regular employee's payload never contains + another employee's data. + """ + user = request.env.user + employee = self._get_employee() + if not employee: + return {'error': 'No employee profile is linked to your account.'} + + is_manager = user.has_group('fusion_clock.group_fusion_clock_manager') + is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead') + role = 'manager' if is_manager else ('team_lead' if is_team_lead else 'employee') + + return { + 'role': role, + 'personal': self._dashboard_personal(employee), + 'team': None, + } +``` + +- [ ] **Step 5: Run the test, verify it PASSES** + +Run the test command. Expected: `test_employee_sees_only_personal` PASSES. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/controllers/clock_api.py fusion_clock/tests/test_dashboard.py fusion_clock/tests/__init__.py +git diff --cached --name-only # verify ONLY these three files +git commit --only -- fusion_clock/controllers/clock_api.py fusion_clock/tests/test_dashboard.py fusion_clock/tests/__init__.py \ + -m "feat(fusion_clock): dashboard personal block + open endpoint to all users" +``` +(Append the `Co-Authored-By: Claude Opus 4.8 ` trailer to the message.) + +--- + +## Task 2: Backend — team/org block, scoped by role + +**Files:** +- Modify: `fusion_clock/controllers/clock_api.py` (add `_dashboard_team`; extend `dashboard_data`) +- Modify: `fusion_clock/tests/test_dashboard.py` (add lead + manager tests) + +- [ ] **Step 1: Write the failing tests (lead scoped to reports; manager org-wide)** + +Append to `TestFusionClockDashboard` in `fusion_clock/tests/test_dashboard.py`: + +```python + def test_team_lead_scoped_to_direct_reports(self): + self.authenticate('fc_lead', 'fc_lead') + data = self._call() + self.assertEqual(data['role'], 'team_lead') + self.assertIsNotNone(data['team']) + self.assertEqual(data['team']['scope'], 'team') + # exactly the two direct reports of this lead + self.assertEqual(data['team']['total_employees'], 2) + for key in ('present_count', 'absent_count', 'on_leave_count', + 'late_count', 'pending_reasons', 'pending_approvals', 'clocked_in'): + self.assertIn(key, data['team']) + + def test_manager_sees_org_wide(self): + self.authenticate('fc_mgr', 'fc_mgr') + data = self._call() + self.assertEqual(data['role'], 'manager') + self.assertIsNotNone(data['team']) + self.assertEqual(data['team']['scope'], 'org') + # at least our 5 enabled employees (DB may hold more) + self.assertGreaterEqual(data['team']['total_employees'], 5) +``` + +- [ ] **Step 2: Run the tests, verify they FAIL** + +Run the test command. Expected: both new tests FAIL — `data['team']` is `None` (assertIsNotNone fails). + +- [ ] **Step 3: Implement `_dashboard_team` + wire it into the endpoint** + +In `fusion_clock/controllers/clock_api.py`, add this helper next to `_dashboard_personal`: + +```python + def _dashboard_team(self, emp_ids, scope): + """Build the team/org block for the given (already role-scoped) + employee ids. ``scope`` is 'team' (lead's direct reports) or 'org'.""" + env = request.env + today = get_local_today(env) + today_start, _ignore = get_local_day_boundaries(env, today) + Attendance = env['hr.attendance'].sudo() + + open_atts = Attendance.search([ + ('employee_id', 'in', emp_ids), + ('check_out', '=', False), + ]) + + ActivityLog = env['fusion.clock.activity.log'].sudo() + late_logs = ActivityLog.search([ + ('employee_id', 'in', emp_ids), + ('log_type', '=', 'late_clock_in'), + ('log_date', '>=', today_start), + ]) + late_emp_ids = set(late_logs.mapped('employee_id').ids) + + clocked_in = [{ + 'employee': a.employee_id.name, + 'check_in': fields.Datetime.to_string(a.check_in), + 'location': a.x_fclk_location_id.name or '', + 'late': a.employee_id.id in late_emp_ids, + } for a in open_atts] + + today_atts = Attendance.search([ + ('employee_id', 'in', emp_ids), + ('check_in', '>=', today_start), + ]) + present_ids = set(today_atts.mapped('employee_id').ids) + + # employees on an approved leave covering today + leave_recs = env['fusion.clock.leave.request'].sudo().search([ + ('employee_id', 'in', emp_ids), + ('leave_date', '<=', today), + ]) + on_leave_ids = set() + for lv in leave_recs: + end = lv.date_to or lv.leave_date + if lv.leave_date and lv.leave_date <= today <= end: + on_leave_ids.add(lv.employee_id.id) + + present_count = len(present_ids) + on_leave_count = len(on_leave_ids - present_ids) + absent_count = max(len(emp_ids) - present_count - on_leave_count, 0) + + pending_reasons = env['hr.employee'].sudo().search_count([ + ('id', 'in', emp_ids), + ('x_fclk_pending_reason', '=', True), + ]) + pending_approvals = env['fusion.clock.correction'].sudo().search_count([ + ('employee_id', 'in', emp_ids), + ('state', '=', 'pending'), + ]) + + return { + 'scope': scope, + 'total_employees': len(emp_ids), + 'present_count': present_count, + 'on_leave_count': on_leave_count, + 'absent_count': absent_count, + 'late_count': len(late_emp_ids), + 'pending_reasons': pending_reasons, + 'pending_approvals': pending_approvals, + 'clocked_in': clocked_in, + } +``` + +Then edit `dashboard_data` to populate `team` for leads/managers. Replace the `return {...}` block from Task 1 with: + +```python + result = { + 'role': role, + 'personal': self._dashboard_personal(employee), + 'team': None, + } + + Employee = request.env['hr.employee'].sudo() + if is_manager: + emp_ids = Employee.search([('x_fclk_enable_clock', '=', True)]).ids + result['team'] = self._dashboard_team(emp_ids, 'org') + elif is_team_lead: + emp_ids = Employee.search([ + ('parent_id', '=', employee.id), + ('x_fclk_enable_clock', '=', True), + ]).ids + result['team'] = self._dashboard_team(emp_ids, 'team') + + return result +``` + +- [ ] **Step 4: Run the tests, verify they PASS** + +Run the test command. Expected: `test_team_lead_scoped_to_direct_reports` and `test_manager_sees_org_wide` PASS (and Task 1's test still passes). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/controllers/clock_api.py fusion_clock/tests/test_dashboard.py +git diff --cached --name-only +git commit --only -- fusion_clock/controllers/clock_api.py fusion_clock/tests/test_dashboard.py \ + -m "feat(fusion_clock): role-scoped team/org dashboard block" +``` + +--- + +## Task 3: Backend — prove no cross-employee leak + no-employee path + +**Files:** +- Modify: `fusion_clock/tests/test_dashboard.py` (add leak + error tests) + +- [ ] **Step 1: Write the tests** + +Append to `TestFusionClockDashboard`: + +```python + def test_no_cross_employee_leak_for_employee(self): + self.authenticate('fc_rep1', 'fc_rep1') + data = self._call() + blob = json.dumps(data) + self.assertIn('Report Rita', blob) # own name present + self.assertNotIn('Report Raj', blob) # sibling absent + self.assertNotIn('Outsider Olga', blob) # unrelated absent + self.assertNotIn('Lead Leo', blob) # lead absent + + def test_team_lead_roster_excludes_outsiders(self): + self.authenticate('fc_lead', 'fc_lead') + data = self._call() + blob = json.dumps(data['team']) + self.assertNotIn('Outsider Olga', blob) + self.assertNotIn('Manager Mae', blob) + + def test_user_without_employee_gets_error(self): + g_user = self.env.ref('fusion_clock.group_fusion_clock_user') + g_internal = self.env.ref('base.group_user') + self.env['res.users'].create({ + 'name': 'No Emp', 'login': 'fc_noemp', 'password': 'fc_noemp', + 'group_ids': [(6, 0, (g_internal + g_user).ids)], + }) + self.authenticate('fc_noemp', 'fc_noemp') + resp = self.url_open( + '/fusion_clock/dashboard_data', + data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': {}}), + headers={'Content-Type': 'application/json'}, + ) + self.assertEqual(resp.status_code, 200) + self.assertIn('error', resp.json()['result']) +``` + +- [ ] **Step 2: Run the tests, verify they PASS** + +Run the test command. Expected: all three PASS with no code change (they assert behaviour the Task 1/2 implementation already guarantees). If `test_no_cross_employee_leak_for_employee` fails, the personal block is including someone else's name — fix `_dashboard_personal` to use only `employee` before continuing. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/tests/test_dashboard.py +git diff --cached --name-only +git commit --only -- fusion_clock/tests/test_dashboard.py \ + -m "test(fusion_clock): assert dashboard never leaks other employees' data" +``` + +--- + +## Task 4: Open the Dashboard menu to all clock users + +**Files:** +- Modify: `fusion_clock/views/clock_menus.xml:28-33` + +- [ ] **Step 1: Change the menu gate** + +In `fusion_clock/views/clock_menus.xml`, the Dashboard `menuitem` currently reads `groups="group_fusion_clock_manager,group_fusion_clock_team_lead"`. Change it to the base group so every clock user sees it (lead/manager imply user): + +```xml + + +``` + +- [ ] **Step 2: Apply the module and verify no parse error** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20 +``` +Expected: upgrade completes, no `ParseError`/`ValueError`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/views/clock_menus.xml +git diff --cached --name-only +git commit --only -- fusion_clock/views/clock_menus.xml \ + -m "feat(fusion_clock): show Dashboard menu to all clock users" +``` + +--- + +## Task 5: SCSS — gradient KPI cards, stacked layout, compile-time dark/light + +**Files:** +- Modify: `fusion_clock/static/src/scss/fusion_clock.scss:555-668` (replace the old dashboard block) + +- [ ] **Step 1: Replace the old dashboard summary-card block** + +In `fusion_clock/static/src/scss/fusion_clock.scss`, DELETE everything from line 555 (`// ====... Dashboard Summary Cards`) through line 668 (the closing `}` of the `html.o_dark { ... }` dashboard overrides) and replace it with the block below. (This removes the runtime `html.o_dark` dashboard rules in favour of compile-time branching, per the repo dark-mode rule.) + +```scss +// =========================================================== +// Dashboard — layered, role-aware (gradient KPIs + stacked layout) +// Dark/light resolved at COMPILE TIME via $o-webclient-color-scheme. +// Gradient KPI cards are identical in both modes (white on gradient); +// only page / section-card / text tokens swap. +// =========================================================== +$o-webclient-color-scheme: bright !default; + +$_fclk-dash-page-hex: #f3f4f6; +$_fclk-dash-card-hex: #ffffff; +$_fclk-dash-border-hex: #e5e7eb; +$_fclk-dash-text-hex: #1f2937; +$_fclk-dash-muted-hex: #6b7280; +$_fclk-dash-row-hex: #f0f0f2; + +@if $o-webclient-color-scheme == dark { + $_fclk-dash-page-hex: #0f1117 !global; + $_fclk-dash-card-hex: #1a1d23 !global; + $_fclk-dash-border-hex: #2a2d35 !global; + $_fclk-dash-text-hex: #f1f1f4 !global; + $_fclk-dash-muted-hex: #9ca3af !global; + $_fclk-dash-row-hex: #2a2d35 !global; +} + +$fclk-dash-page: var(--fclk-dash-page, #{$_fclk-dash-page-hex}); +$fclk-dash-card: var(--fclk-dash-card, #{$_fclk-dash-card-hex}); +$fclk-dash-border: var(--fclk-dash-border, #{$_fclk-dash-border-hex}); +$fclk-dash-text: var(--fclk-dash-text, #{$_fclk-dash-text-hex}); +$fclk-dash-muted: var(--fclk-dash-muted, #{$_fclk-dash-muted-hex}); +$fclk-dash-row: var(--fclk-dash-row, #{$_fclk-dash-row-hex}); + +$fclk-g-today: linear-gradient(135deg, #10b981 0%, #0ea5a4 100%); +$fclk-g-week: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); +$fclk-g-ot: linear-gradient(135deg, #8b5cf6 0%, #d946ef 100%); +$fclk-g-streak: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%); +$fclk-g-present: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%); +$fclk-g-absent: linear-gradient(135deg, #ef4444 0%, #f97316 100%); +$fclk-g-late: linear-gradient(135deg, #eab308 0%, #f59e0b 100%); +$fclk-g-pending: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + +.fclk-dash { + background: $fclk-dash-page; + min-height: 100%; + + .fclk-dash-wrap { max-width: 1200px; margin: 0 auto; padding: 20px; } + + .fclk-dash-header { + display: flex; justify-content: space-between; align-items: flex-start; + flex-wrap: wrap; gap: 12px; margin-bottom: 20px; + } + .fclk-dash-hello { font-size: 22px; font-weight: 800; color: $fclk-dash-text; } + .fclk-dash-date { font-size: 13px; color: $fclk-dash-muted; margin-top: 2px; } + .fclk-dash-headctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .fclk-dash-statusbadge { + border-radius: 999px; padding: 7px 14px; font-size: 13px; font-weight: 700; + background: rgba(16, 185, 129, .15); color: #10b981; + border: 1px solid rgba(16, 185, 129, .35); + &.is-out { + background: rgba(107, 114, 128, .15); color: $fclk-dash-muted; + border-color: rgba(107, 114, 128, .3); + } + } + .fclk-dash-btn-primary { + background: $fclk-g-today; color: #fff; border: none; border-radius: 10px; + padding: 8px 16px; font-weight: 700; font-size: 13px; cursor: pointer; + } + .fclk-dash-btn-ghost { + background: transparent; color: $fclk-dash-muted; border: 1px solid $fclk-dash-border; + border-radius: 10px; padding: 8px 12px; font-size: 13px; cursor: pointer; + } + + .fclk-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; } + .fclk-kpi { + border-radius: 16px; padding: 16px; color: #fff; + box-shadow: 0 6px 20px rgba(0, 0, 0, .10); + } + .fclk-kpi-ic { + width: 34px; height: 34px; border-radius: 10px; background: rgba(255, 255, 255, .22); + display: flex; align-items: center; justify-content: center; font-size: 16px; margin-bottom: 12px; + } + .fclk-kpi-val { font-size: 26px; font-weight: 800; line-height: 1; } + .fclk-kpi-lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; opacity: .85; margin-top: 6px; } + + .fclk-kpi--today { background: $fclk-g-today; } + .fclk-kpi--week { background: $fclk-g-week; } + .fclk-kpi--ot { background: $fclk-g-ot; } + .fclk-kpi--streak { background: $fclk-g-streak; } + .fclk-kpi--present { background: $fclk-g-present; } + .fclk-kpi--absent { background: $fclk-g-absent; } + .fclk-kpi--late { background: $fclk-g-late; } + .fclk-kpi--pending { background: $fclk-g-pending; } + + .fclk-dash-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; } + .fclk-dash-card { + background: $fclk-dash-card; border: 1px solid $fclk-dash-border; + border-radius: 14px; padding: 16px; + } + .fclk-dash-card h4 { + margin: 0 0 12px; font-size: 14px; color: $fclk-dash-text; + display: flex; justify-content: space-between; align-items: center; + } + .fclk-dash-line { + display: flex; justify-content: space-between; align-items: center; + font-size: 13px; padding: 7px 0; border-top: 1px solid $fclk-dash-row; color: $fclk-dash-text; + &:first-of-type { border-top: none; } + } + .fclk-dash-muted { color: $fclk-dash-muted; } + .fclk-pin { color: #10b981; font-weight: 700; } + .fclk-pyel { color: #d97706; font-weight: 700; } + .fclk-pred { color: #dc2626; font-weight: 700; } + .fclk-dash-empty { text-align: center; color: $fclk-dash-muted; padding: 18px 0; font-size: 13px; } + + .fclk-dash-divider { + display: flex; align-items: center; gap: 12px; margin: 22px 0 14px; + span { + font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; + color: $fclk-dash-muted; font-weight: 700; white-space: nowrap; + } + &::before, &::after { content: ""; flex: 1; height: 1px; background: $fclk-dash-border; } + } + + .fclk-dash-av { + display: inline-flex; width: 26px; height: 26px; border-radius: 50%; + background: $fclk-dash-row; color: $fclk-dash-text; font-size: 11px; font-weight: 700; + align-items: center; justify-content: center; margin-right: 8px; + } + .fclk-dash-late-badge { + background: rgba(217, 119, 6, .15); color: #d97706; border-radius: 999px; + padding: 2px 8px; font-size: 11px; font-weight: 700; margin-left: 6px; + } + + .fclk-dash-actions { display: flex; flex-wrap: wrap; gap: 10px; } + .fclk-dash-act { + background: $fclk-dash-page; border: 1px solid $fclk-dash-border; border-radius: 10px; + padding: 9px 14px; font-size: 13px; color: $fclk-dash-text; cursor: pointer; + &:hover { border-color: $fclk-blue; } + } + + @media (max-width: 992px) { + .fclk-kpi-row { grid-template-columns: repeat(2, 1fr); } + .fclk-dash-2col { grid-template-columns: 1fr; } + } + @media (max-width: 576px) { + .fclk-kpi-row { grid-template-columns: 1fr; } + } +} +``` + +- [ ] **Step 2: Force-compile both bundles to verify the SCSS is valid in light AND dark** + +The compiler errors out on bad SCSS (e.g. `@if` typos or mixed units). Compile both bundles: + +```bash +docker exec odoo-dev-app odoo shell -d fusion-dev --no-http 2>/dev/null <<'PY' +env['ir.qweb']._get_asset_bundle('web.assets_backend').css() +env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() +print('OK both bundles compiled') +PY +``` +Expected: `OK both bundles compiled`, no `SCSS`/`Sass`/`Incompatible units` traceback. (If it errors, fix the SCSS before moving on — a broken bundle takes down the whole backend.) + +- [ ] **Step 3: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/static/src/scss/fusion_clock.scss +git diff --cached --name-only +git commit --only -- fusion_clock/static/src/scss/fusion_clock.scss \ + -m "feat(fusion_clock): gradient dashboard SCSS + compile-time dark/light" +``` + +--- + +## Task 6: OWL template — stacked layout with conditional team band + +**Files:** +- Modify: `fusion_clock/static/src/xml/fusion_clock_dashboard.xml` (full rewrite) + +- [ ] **Step 1: Replace the template file contents** + +Overwrite `fusion_clock/static/src/xml/fusion_clock_dashboard.xml` with: + +```xml + + + + +
+
+ + +
+ +

Loading dashboard…

+
+
+ + +
+
+ + + +
+
+
, 👋
+
+
+
+ + ● Clocked in + ○ Not clocked in + + + +
+
+ + +
+
+
+
h
+
Today
+
+
+
📅
+
h
+
This Week
+
+
+
+
h
+
OT This Week
+
+
+
🔥
+
+
On-time Streak
+
+
+ + +
+
+

Today's Shift

+
+ Scheduled + + (h) + Not scheduled today + +
+
+ Status + +
+
+ Source + +
+
+
+

My Recent Activity

+ +
No recent activity
+
+ +
+ + + h · + OT + +
+
+
+
+ +
+
+

Upcoming Leave

+ +
No upcoming leave
+
+ +
+
+
+
+

Recent Penalties

+ +
None this month 🎉
+
+ +
· min
+
+
+
+ + + +
Team / Org
+ +
+
+
+
+
Present now
+
+
+
🚫
+
+
Absent today
+
+
+
+
+
Late today
+
+
+
📨
+
+
Pending approvals
+
+
+ +
+
+

Currently Clocked In of

+ +
No one is clocked in right now
+
+ +
+ + late + + · +
+
+
+
+

Needs Attention

+
+ absent (no leave) + review → +
+
+ on approved leave + today +
+
+ auto clock-out — reason pending + view → +
+
+ correction requests + open → +
+
+
+
+ + +
+

Quick Actions

+
+ 🕒 Open My Clock + 📄 My Timesheets + + 📋 All Attendances + 📨 Approvals + ⚠ Penalties + 🗒 Activity Logs + + + 📅 Shift Planner + 📊 Reports + +
+
+
+ +
+
+
+ +
+``` + +- [ ] **Step 2: Validate the template by loading the module** + +Odoo's QWeb loader parses and validates the template on upgrade — that's the check that matters (and it doesn't use a vulnerable stdlib parser). If `xmllint` is handy you can pre-check well-formedness offline first (`xmllint --noout --nonet fusion_clock/static/src/xml/fusion_clock_dashboard.xml`), but the authoritative check is: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20 +``` +Expected: upgrade completes with no `ParseError`/`QWebException` referencing `fusion_clock.Dashboard`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/static/src/xml/fusion_clock_dashboard.xml +git diff --cached --name-only +git commit --only -- fusion_clock/static/src/xml/fusion_clock_dashboard.xml \ + -m "feat(fusion_clock): stacked role-aware dashboard template" +``` + +--- + +## Task 7: JS — role/personal/team state + helpers + actions + +**Files:** +- Modify: `fusion_clock/static/src/js/fusion_clock_dashboard.js` (full rewrite) + +- [ ] **Step 1: Replace the component** + +Overwrite `fusion_clock/static/src/js/fusion_clock_dashboard.js` with: + +```javascript +/** @odoo-module **/ + +import { Component, useState, onWillStart } 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 FusionClockDashboard extends Component { + static template = "fusion_clock.Dashboard"; + static props = { "*": true }; + + setup() { + this.action = useService("action"); + this.state = useState({ + loading: true, + error: "", + role: "employee", + personal: {}, + team: null, + }); + onWillStart(async () => { + await this._fetchData(); + }); + } + + async _fetchData() { + this.state.loading = true; + this.state.error = ""; + try { + const data = await rpc("/fusion_clock/dashboard_data", {}); + if (data.error) { + this.state.error = data.error; + } else { + this.state.role = data.role; + this.state.personal = data.personal; + this.state.team = data.team; + } + } catch (e) { + this.state.error = "Failed to load dashboard data."; + } + this.state.loading = false; + } + + // ---- display helpers ---- + get greeting() { + const h = new Date().getHours(); + if (h < 12) return "Good morning"; + if (h < 17) return "Good afternoon"; + return "Good evening"; + } + get todayLabel() { + return new Date().toLocaleDateString(undefined, { + weekday: "long", month: "long", day: "numeric", + }); + } + sourceLabel(source) { + return { schedule: "Posted schedule", shift: "Recurring shift", none: "—" }[source] || "—"; + } + initials(name) { + return (name || "") + .split(" ").filter(Boolean).slice(0, 2) + .map((p) => p[0].toUpperCase()).join(""); + } + fmtDate(s) { + if (!s) return ""; + const d = new Date(s.replace(" ", "T") + "Z"); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + fmtTime(s) { + if (!s) return ""; + const d = new Date(s.replace(" ", "T") + "Z"); + return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + } + + // ---- actions ---- + onRefresh() { return this._fetchData(); } + onOpenClock() { this.action.doAction({ type: "ir.actions.act_url", url: "/my/clock", target: "self" }); } + onViewTimesheets() { this.action.doAction({ type: "ir.actions.act_url", url: "/my/clock/timesheets", target: "self" }); } + onViewAttendances() { this.action.doAction("hr_attendance.hr_attendance_action"); } + onViewCorrections() { this.action.doAction("fusion_clock.action_fusion_clock_correction"); } + onViewActivityLogs() { this.action.doAction("fusion_clock.action_fusion_clock_activity_log"); } + onViewPenalties() { this.action.doAction("fusion_clock.action_fusion_clock_penalty"); } + onViewShiftPlanner() { this.action.doAction("fusion_clock.action_fusion_clock_shift_planner"); } + onViewReports() { this.action.doAction("fusion_clock.action_fusion_clock_report"); } +} + +registry.category("actions").add("fusion_clock.Dashboard", FusionClockDashboard); +``` + +- [ ] **Step 2: Lint for syntax / undefined names** + +```bash +docker exec odoo-dev-app node --check /mnt/extra-addons/custom/fusion_clock/static/src/js/fusion_clock_dashboard.js 2>&1 | tail -5 || echo "(node unavailable — rely on browser load in Task 8)" +``` +Expected: no syntax error. (If `node` isn't in the container, the browser smoke check in Task 8 covers it.) + +Confirm every template handler exists on the component: `greeting`, `todayLabel`, `sourceLabel`, `initials`, `fmtDate`, `fmtTime`, `onRefresh`, `onOpenClock`, `onViewTimesheets`, `onViewAttendances`, `onViewCorrections`, `onViewActivityLogs`, `onViewPenalties`, `onViewShiftPlanner`, `onViewReports`. All present above. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/static/src/js/fusion_clock_dashboard.js +git diff --cached --name-only +git commit --only -- fusion_clock/static/src/js/fusion_clock_dashboard.js \ + -m "feat(fusion_clock): dashboard JS — role/personal/team state + helpers" +``` + +--- + +## Task 8: Version bump, full upgrade, manual verification, deploy + +**Files:** +- Modify: `fusion_clock/__manifest__.py` (version `19.0.3.13.2` → `19.0.3.14.0`) + +- [ ] **Step 1: Bump the version** + +In `fusion_clock/__manifest__.py`, change the `version` string to `19.0.3.14.0` (forces asset bundle rebuild). + +- [ ] **Step 2: Full upgrade + run the whole test suite** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \ + -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 +``` +Expected: upgrade succeeds; all `test_dashboard` tests pass; existing NFC tests still pass; `0 failed, 0 error`. + +- [ ] **Step 3: Manual browser smoke (local) — three roles** + +Open http://localhost:8082 (or :8069), log in as an admin/manager: +- Manager: Fusion Clock → Dashboard shows personal KPIs + Team/Org band (org-wide roster), Reports/Shift Planner actions visible. +- Toggle dark mode (user profile → Dark) and reload: gradient cards stay vibrant; page/cards/text invert correctly (no white-on-white, no invisible borders). +- Impersonate / log in as a plain clock user (a `group_fusion_clock_user` member with no lead/manager group): Dashboard shows ONLY the personal bands — no Team/Org divider, no roster, no other names. + +If the dashboard doesn't refresh visually, hard-refresh (DevTools → Empty Cache and Hard Reload) — version bump should already bust the bundle hash. + +- [ ] **Step 4: Commit the version bump** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add -- fusion_clock/__manifest__.py +git diff --cached --name-only +git commit --only -- fusion_clock/__manifest__.py \ + -m "chore(fusion_clock): bump to 19.0.3.14.0 (dashboard redesign)" +``` + +- [ ] **Step 5: Push to BOTH remotes** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git log origin/main..HEAD --oneline # confirm only the dashboard commits are ahead +git push origin main +git push gitea main +``` +If either push is rejected (the concurrent session pushed first), `git pull --rebase` then re-verify `git show HEAD:fusion_clock/static/src/js/fusion_clock_dashboard.js | head` still shows the new component before pushing again. + +- [ ] **Step 6: Deploy to entech** + +Get the whole module dir onto pve-worker5, then into LXC 111, then upgrade the native service (per `fusion_clock/CLAUDE.md` / memory `entech-fusion-clock-kiosk`): + +```bash +# from repo root — push the module to the host, then into the container +rsync -az --delete fusion_clock/ pve-worker5:/tmp/fusion_clock/ +ssh pve-worker5 "pct exec 111 -- rm -rf /tmp/fusion_clock && true; tar -C /tmp -cf - fusion_clock | pct exec 111 -- tar -C /mnt/extra-addons/custom -xf -" +ssh pve-worker5 "pct exec 111 -- bash -lc 'systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo'" +ssh pve-worker5 "pct exec 111 -- tail -20 /tmp/up.log" +``` +Expected: upgrade log shows the module loaded with no traceback; `systemctl start odoo` brings the service back. Then hard-refresh the entech browser (and clear iOS website data on the tablet) to bust the asset cache. + +(If your established entech file-transfer path differs from the above, use that — the key points are: deploy the **whole** `fusion_clock` dir, upgrade as the `odoo` user with `--http-port=0 --gevent-port=0`, and `;` not `&&` before `systemctl start` so the service always restarts.) + +- [ ] **Step 7: Verify live** + +As a manager on entech: Dashboard renders, dark + light both correct, team band populated. As a plain kiosk/clock user: personal-only, no other names. Confirm no errors in `/var/log/odoo/odoo-server.log` (LXC 111). + +--- + +## Self-Review (completed inline) + +- **Spec coverage:** §2 permission model → Tasks 1–3 (+ no-leak test) and Task 4 menu; §3 look/feel → Tasks 5–6; §3 dark/light → Task 5 compile-time branching + Task 8 dark smoke; §4 data contract → Tasks 1–2 (`personal` + `team`, on_leave_count, pending_approvals=corrections); §6 edge cases → `status_note` ladder, no-employee error, empty states in template; §7 testing → Tasks 1–3 + Task 8 full run; §9 deploy → Task 8. +- **Placeholder scan:** none — every code step contains complete code; commands have expected output. +- **Type/name consistency:** payload keys (`role`, `personal.*`, `team.*`) match between controller (Tasks 1–2), JS state (Task 7), and template bindings (Task 6). Template handlers all exist on the component (verified in Task 7 Step 2). SCSS class names (`fclk-kpi--*`, `fclk-dash-*`) match the template (Task 6) and the SCSS (Task 5). Action xmlids (`action_fusion_clock_correction/_penalty/_activity_log/_shift_planner/_report`) exist in `views/clock_menus.xml`. +- **Scope:** single focused feature, one implementation plan.