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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user