feat(fusion_clock): redesign dashboard — layered, role-aware, gradient cards (dark+light)

- Rework /fusion_clock/dashboard_data into a personal block (everyone)
  plus a team block (team lead = direct reports, manager = org-wide).
  A regular employee's payload never contains another employee's data.
- New OWL stacked layout: gradient KPI cards (Today/Week/OT/Streak),
  Today's Shift, Recent Activity, Upcoming Leave, Recent Penalties; team
  band adds Present/Absent/Late/Pending, roster, and Needs Attention.
- Dark/light via compile-time $o-webclient-color-scheme branching;
  drop the old runtime html.o_dark dashboard block.
- Open the Dashboard menu to group_fusion_clock_user (lead/manager imply).
- Add HttpCase permission/no-leak tests. Bump 3.13.2 -> 3.14.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 02:28:53 -04:00
parent ea4f216c1a
commit fef99809e5
8 changed files with 681 additions and 296 deletions

View File

@@ -670,78 +670,216 @@ class FusionClockAPI(http.Controller):
'enable_corrections': ICP.get_param('fusion_clock.enable_correction_requests', 'True') == 'True',
}
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
def dashboard_data(self, **kw):
"""Return dashboard data for managers."""
user = request.env.user
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
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)
if not is_manager and not is_team_lead:
return {'error': 'Access denied.'}
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 ''
now = fields.Datetime.now()
today = get_local_today(request.env)
today_start, _ = get_local_day_boundaries(request.env, today)
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)
Attendance = request.env['hr.attendance'].sudo()
Employee = request.env['hr.employee'].sudo()
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)
# Filter employees by access
if is_manager:
employees = Employee.search([('x_fclk_enable_clock', '=', True)])
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:
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found.'}
employees = Employee.search([
('parent_id', '=', employee.id),
('x_fclk_enable_clock', '=', True),
])
status_note = 'Not clocked in'
emp_ids = employees.ids
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,
}
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()
# Currently clocked in
open_atts = Attendance.search([
('employee_id', 'in', emp_ids),
('check_out', '=', False),
])
clocked_in = [{
'employee': a.employee_id.name,
'check_in': fields.Datetime.to_string(a.check_in),
'location': a.x_fclk_location_id.name or '',
} for a in open_atts]
# Today stats
today_atts = Attendance.search([
('employee_id', 'in', emp_ids),
('check_in', '>=', today_start),
])
present_ids = set(a.employee_id.id for a in today_atts)
ActivityLog = request.env['fusion.clock.activity.log'].sudo()
late_count = ActivityLog.search_count([
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)
# Pending alerts
pending_reasons = Employee.search_count([
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_corrections = request.env['fusion.clock.correction'].sudo().search_count([
pending_approvals = env['fusion.clock.correction'].sudo().search_count([
('employee_id', 'in', emp_ids),
('state', '=', 'pending'),
])
return {
'clocked_in': clocked_in,
'scope': scope,
'total_employees': len(emp_ids),
'present_count': len(present_ids),
'absent_count': len(emp_ids) - len(present_ids),
'late_count': late_count,
'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_corrections': pending_corrections,
'pending_approvals': pending_approvals,
'clocked_in': clocked_in,
}
@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). 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')
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