Files
Odoo-Modules/fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md
2026-05-31 02:17:54 -04:00

1143 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 -- <explicit paths>` (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 <noreply@anthropic.com>` 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
<!-- Dashboard -->
<menuitem id="menu_fusion_clock_dashboard"
name="Dashboard"
parent="menu_fusion_clock_root"
action="action_fusion_clock_dashboard"
sequence="5"
groups="group_fusion_clock_user"/>
```
- [ ] **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
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.Dashboard">
<div class="o_action fclk-dash">
<div class="fclk-dash-wrap">
<t t-if="state.loading">
<div class="fclk-dash-empty">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading dashboard…</p>
</div>
</t>
<t t-if="state.error">
<div class="fclk-dash-card"><t t-esc="state.error"/></div>
</t>
<t t-if="!state.loading and !state.error">
<!-- HEADER -->
<div class="fclk-dash-header">
<div>
<div class="fclk-dash-hello"><t t-esc="greeting"/>, <t t-esc="state.personal.employee_name"/> 👋</div>
<div class="fclk-dash-date"><t t-esc="todayLabel"/></div>
</div>
<div class="fclk-dash-headctl">
<span class="fclk-dash-statusbadge" t-att-class="{'is-out': !state.personal.is_checked_in}">
<t t-if="state.personal.is_checked_in">● Clocked in</t>
<t t-else="">○ Not clocked in</t>
</span>
<button class="fclk-dash-btn-primary" t-on-click="onOpenClock">Open My Clock</button>
<button class="fclk-dash-btn-ghost" t-on-click="onRefresh"><i class="fa fa-refresh"/></button>
</div>
</div>
<!-- PERSONAL KPIs -->
<div class="fclk-kpi-row">
<div class="fclk-kpi fclk-kpi--today">
<div class="fclk-kpi-ic"></div>
<div class="fclk-kpi-val"><t t-esc="state.personal.today_hours"/>h</div>
<div class="fclk-kpi-lbl">Today</div>
</div>
<div class="fclk-kpi fclk-kpi--week">
<div class="fclk-kpi-ic">📅</div>
<div class="fclk-kpi-val"><t t-esc="state.personal.week_hours"/>h</div>
<div class="fclk-kpi-lbl">This Week</div>
</div>
<div class="fclk-kpi fclk-kpi--ot">
<div class="fclk-kpi-ic"></div>
<div class="fclk-kpi-val"><t t-esc="state.personal.overtime_week"/>h</div>
<div class="fclk-kpi-lbl">OT This Week</div>
</div>
<div class="fclk-kpi fclk-kpi--streak">
<div class="fclk-kpi-ic">🔥</div>
<div class="fclk-kpi-val"><t t-esc="state.personal.ontime_streak"/></div>
<div class="fclk-kpi-lbl">On-time Streak</div>
</div>
</div>
<!-- PERSONAL DETAIL -->
<div class="fclk-dash-2col">
<div class="fclk-dash-card">
<h4>Today's Shift</h4>
<div class="fclk-dash-line">
<span>Scheduled</span>
<span class="fclk-dash-muted">
<t t-if="state.personal.shift.label"><t t-esc="state.personal.shift.label"/> (<t t-esc="state.personal.shift.hours"/>h)</t>
<t t-else="">Not scheduled today</t>
</span>
</div>
<div class="fclk-dash-line">
<span>Status</span>
<span t-att-class="state.personal.is_checked_in ? 'fclk-pin' : 'fclk-dash-muted'"><t t-esc="state.personal.shift.status_note"/></span>
</div>
<div class="fclk-dash-line">
<span>Source</span>
<span class="fclk-dash-muted"><t t-esc="sourceLabel(state.personal.shift.source)"/></span>
</div>
</div>
<div class="fclk-dash-card">
<h4>My Recent Activity</h4>
<t t-if="state.personal.recent_activity.length === 0">
<div class="fclk-dash-empty">No recent activity</div>
</t>
<t t-foreach="state.personal.recent_activity" t-as="a" t-key="a_index">
<div class="fclk-dash-line">
<span><t t-esc="fmtDate(a.check_in)"/></span>
<span class="fclk-dash-muted">
<t t-esc="a.worked_hours"/>h<t t-if="a.overtime_hours &gt; 0"> · +<t t-esc="a.overtime_hours"/> OT</t>
</span>
</div>
</t>
</div>
</div>
<div class="fclk-dash-2col">
<div class="fclk-dash-card">
<h4>Upcoming Leave</h4>
<t t-if="state.personal.leaves.length === 0">
<div class="fclk-dash-empty">No upcoming leave</div>
</t>
<t t-foreach="state.personal.leaves" t-as="lv" t-key="lv_index">
<div class="fclk-dash-line"><span><t t-esc="lv.label"/></span><span class="fclk-dash-muted"><t t-esc="lv.state"/></span></div>
</t>
</div>
<div class="fclk-dash-card">
<h4>Recent Penalties</h4>
<t t-if="state.personal.penalties.length === 0">
<div class="fclk-dash-empty">None this month 🎉</div>
</t>
<t t-foreach="state.personal.penalties" t-as="p" t-key="p_index">
<div class="fclk-dash-line"><span><t t-esc="p.type"/> · <t t-esc="p.date"/></span><span class="fclk-pyel"><t t-esc="p.minutes"/> min</span></div>
</t>
</div>
</div>
<!-- TEAM / ORG BAND -->
<t t-if="state.team">
<div class="fclk-dash-divider"><span>Team / Org</span></div>
<div class="fclk-kpi-row">
<div class="fclk-kpi fclk-kpi--present">
<div class="fclk-kpi-ic"></div>
<div class="fclk-kpi-val"><t t-esc="state.team.present_count"/></div>
<div class="fclk-kpi-lbl">Present now</div>
</div>
<div class="fclk-kpi fclk-kpi--absent">
<div class="fclk-kpi-ic">🚫</div>
<div class="fclk-kpi-val"><t t-esc="state.team.absent_count"/></div>
<div class="fclk-kpi-lbl">Absent today</div>
</div>
<div class="fclk-kpi fclk-kpi--late">
<div class="fclk-kpi-ic"></div>
<div class="fclk-kpi-val"><t t-esc="state.team.late_count"/></div>
<div class="fclk-kpi-lbl">Late today</div>
</div>
<div class="fclk-kpi fclk-kpi--pending">
<div class="fclk-kpi-ic">📨</div>
<div class="fclk-kpi-val"><t t-esc="state.team.pending_approvals"/></div>
<div class="fclk-kpi-lbl">Pending approvals</div>
</div>
</div>
<div class="fclk-dash-2col">
<div class="fclk-dash-card">
<h4>Currently Clocked In <span class="fclk-dash-muted"><t t-esc="state.team.present_count"/> of <t t-esc="state.team.total_employees"/></span></h4>
<t t-if="state.team.clocked_in.length === 0">
<div class="fclk-dash-empty">No one is clocked in right now</div>
</t>
<t t-foreach="state.team.clocked_in" t-as="emp" t-key="emp_index">
<div class="fclk-dash-line">
<span><span class="fclk-dash-av"><t t-esc="initials(emp.employee)"/></span><t t-esc="emp.employee"/>
<span t-if="emp.late" class="fclk-dash-late-badge">late</span>
</span>
<span class="fclk-dash-muted"><t t-esc="fmtTime(emp.check_in)"/> · <t t-esc="emp.location"/></span>
</div>
</t>
</div>
<div class="fclk-dash-card">
<h4>Needs Attention</h4>
<div class="fclk-dash-line" t-on-click="onViewActivityLogs" style="cursor:pointer;">
<span class="fclk-pred"><t t-esc="state.team.absent_count"/> absent (no leave)</span>
<span class="fclk-dash-muted">review →</span>
</div>
<div class="fclk-dash-line">
<span><t t-esc="state.team.on_leave_count"/> on approved leave</span>
<span class="fclk-dash-muted">today</span>
</div>
<div class="fclk-dash-line" t-on-click="onViewActivityLogs" style="cursor:pointer;">
<span class="fclk-pyel"><t t-esc="state.team.pending_reasons"/> auto clock-out — reason pending</span>
<span class="fclk-dash-muted">view →</span>
</div>
<div class="fclk-dash-line" t-on-click="onViewCorrections" style="cursor:pointer;">
<span><t t-esc="state.team.pending_approvals"/> correction requests</span>
<span class="fclk-dash-muted">open →</span>
</div>
</div>
</div>
</t>
<!-- QUICK ACTIONS -->
<div class="fclk-dash-card">
<h4>Quick Actions</h4>
<div class="fclk-dash-actions">
<span class="fclk-dash-act" t-on-click="onOpenClock">🕒 Open My Clock</span>
<span class="fclk-dash-act" t-on-click="onViewTimesheets">📄 My Timesheets</span>
<t t-if="state.team">
<span class="fclk-dash-act" t-on-click="onViewAttendances">📋 All Attendances</span>
<span class="fclk-dash-act" t-on-click="onViewCorrections">📨 Approvals</span>
<span class="fclk-dash-act" t-on-click="onViewPenalties">⚠ Penalties</span>
<span class="fclk-dash-act" t-on-click="onViewActivityLogs">🗒 Activity Logs</span>
</t>
<t t-if="state.role === 'manager'">
<span class="fclk-dash-act" t-on-click="onViewShiftPlanner">📅 Shift Planner</span>
<span class="fclk-dash-act" t-on-click="onViewReports">📊 Reports</span>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>
```
- [ ] **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 13 (+ no-leak test) and Task 4 menu; §3 look/feel → Tasks 56; §3 dark/light → Task 5 compile-time branching + Task 8 dark smoke; §4 data contract → Tasks 12 (`personal` + `team`, on_leave_count, pending_approvals=corrections); §6 edge cases → `status_note` ladder, no-employee error, empty states in template; §7 testing → Tasks 13 + 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 12), 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.