1143 lines
52 KiB
Markdown
1143 lines
52 KiB
Markdown
# 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 > 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 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.
|