# 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.