From fef99809e5adce38fafade82e295aaa539cd7c90 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 31 May 2026 02:28:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(fusion=5Fclock):=20redesign=20dashboard=20?= =?UTF-8?q?=E2=80=94=20layered,=20role-aware,=20gradient=20cards=20(dark+l?= =?UTF-8?q?ight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- fusion_clock/__manifest__.py | 2 +- fusion_clock/controllers/clock_api.py | 232 +++++++++++--- .../static/src/js/fusion_clock_dashboard.js | 72 +++-- .../static/src/scss/fusion_clock.scss | 232 ++++++++------ .../static/src/xml/fusion_clock_dashboard.xml | 290 ++++++++++-------- fusion_clock/tests/__init__.py | 1 + fusion_clock/tests/test_dashboard.py | 142 +++++++++ fusion_clock/views/clock_menus.xml | 6 +- 8 files changed, 681 insertions(+), 296 deletions(-) create mode 100644 fusion_clock/tests/test_dashboard.py diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 37f2a7bb..90e9a5c0 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.13.2', + 'version': '19.0.3.14.0', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ diff --git a/fusion_clock/controllers/clock_api.py b/fusion_clock/controllers/clock_api.py index 1641bd47..cbf9887e 100644 --- a/fusion_clock/controllers/clock_api.py +++ b/fusion_clock/controllers/clock_api.py @@ -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 diff --git a/fusion_clock/static/src/js/fusion_clock_dashboard.js b/fusion_clock/static/src/js/fusion_clock_dashboard.js index 46dd29b8..5c70bf76 100644 --- a/fusion_clock/static/src/js/fusion_clock_dashboard.js +++ b/fusion_clock/static/src/js/fusion_clock_dashboard.js @@ -13,16 +13,11 @@ export class FusionClockDashboard extends Component { this.action = useService("action"); this.state = useState({ loading: true, - clocked_in: [], - total_employees: 0, - present_count: 0, - absent_count: 0, - late_count: 0, - pending_reasons: 0, - pending_corrections: 0, error: "", + role: "employee", + personal: {}, + team: null, }); - onWillStart(async () => { await this._fetchData(); }); @@ -30,12 +25,15 @@ export class FusionClockDashboard extends Component { 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 { - Object.assign(this.state, data); + 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."; @@ -43,25 +41,47 @@ export class FusionClockDashboard extends Component { this.state.loading = false; } - async onRefresh() { - await this._fetchData(); + // ---- 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" }); } - 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"); - } + // ---- 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); diff --git a/fusion_clock/static/src/scss/fusion_clock.scss b/fusion_clock/static/src/scss/fusion_clock.scss index ed873a5b..ac0402be 100644 --- a/fusion_clock/static/src/scss/fusion_clock.scss +++ b/fusion_clock/static/src/scss/fusion_clock.scss @@ -553,116 +553,148 @@ html.o_dark { } // =========================================================== -// Dashboard Summary Cards +// 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. // =========================================================== -.fclk-dash-card { - position: relative; - border-radius: 12px; - padding: 20px; - text-align: center; - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; +$o-webclient-color-scheme: bright !default; - &:hover { - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +$_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-card-icon { - width: 44px; - height: 44px; - border-radius: 12px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 20px; - margin-bottom: 12px; -} - -.fclk-dash-card-value { - font-size: 32px; - font-weight: 700; - line-height: 1; - margin-bottom: 4px; -} - -.fclk-dash-card-label { - font-size: 13px; - font-weight: 500; - letter-spacing: 0.2px; -} - -.fclk-dash-card--total { - background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%); - border: 1px solid #bfdbfe; - - .fclk-dash-card-icon { background: rgba(59, 130, 246, 0.15); color: #2563eb; } - .fclk-dash-card-value { color: #1e3a5f; } - .fclk-dash-card-label { color: #3b82f6; } -} - -.fclk-dash-card--present { - background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); - border: 1px solid #a7f3d0; - - .fclk-dash-card-icon { background: rgba(16, 185, 129, 0.15); color: #059669; } - .fclk-dash-card-value { color: #064e3b; } - .fclk-dash-card-label { color: #10b981; } -} - -.fclk-dash-card--absent { - background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); - border: 1px solid #fecaca; - - .fclk-dash-card-icon { background: rgba(239, 68, 68, 0.12); color: #dc2626; } - .fclk-dash-card-value { color: #7f1d1d; } - .fclk-dash-card-label { color: #ef4444; } -} - -.fclk-dash-card--late { - background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); - border: 1px solid #fde68a; - - .fclk-dash-card-icon { background: rgba(245, 158, 11, 0.15); color: #d97706; } - .fclk-dash-card-value { color: #78350f; } - .fclk-dash-card-label { color: #f59e0b; } -} - -html.o_dark { - .fclk-dash-card--total { - background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%); - border-color: rgba(59, 130, 246, 0.25); - .fclk-dash-card-value { color: #93c5fd; } - .fclk-dash-card-label { color: #60a5fa; } - .fclk-dash-card-icon { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } + .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-dash-card--present { - background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(52, 211, 153, 0.08) 100%); - border-color: rgba(16, 185, 129, 0.25); - .fclk-dash-card-value { color: #6ee7b7; } - .fclk-dash-card-label { color: #34d399; } - .fclk-dash-card-icon { background: rgba(16, 185, 129, 0.2); color: #34d399; } + .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-card--absent { - background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(248, 113, 113, 0.08) 100%); - border-color: rgba(239, 68, 68, 0.25); - .fclk-dash-card-value { color: #fca5a5; } - .fclk-dash-card-label { color: #f87171; } - .fclk-dash-card-icon { background: rgba(239, 68, 68, 0.18); color: #f87171; } + .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-card--late { - background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(251, 191, 36, 0.08) 100%); - border-color: rgba(245, 158, 11, 0.25); - .fclk-dash-card-value { color: #fcd34d; } - .fclk-dash-card-label { color: #fbbf24; } - .fclk-dash-card-icon { background: rgba(245, 158, 11, 0.2); color: #fbbf24; } + .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; } } - .fclk-dash-card:hover { - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + @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; } } } diff --git a/fusion_clock/static/src/xml/fusion_clock_dashboard.xml b/fusion_clock/static/src/xml/fusion_clock_dashboard.xml index 34cb7a92..a5086b89 100644 --- a/fusion_clock/static/src/xml/fusion_clock_dashboard.xml +++ b/fusion_clock/static/src/xml/fusion_clock_dashboard.xml @@ -2,148 +2,198 @@ -
-
- - -
-

Fusion Clock Dashboard

- -
+
+
-
+
-

Loading dashboard...

+

Loading dashboard…

-
- -
+
- -
-
-
-
- -
-
-
Total Employees
-
+ +
+
+
, 👋
+
-
-
-
- -
-
-
Present Today
-
-
-
-
-
- -
-
-
Absent Today
-
-
-
-
-
- -
-
-
Late Today
-
+
+ + ● Clocked in + ○ Not clocked in + + +
-
- -
-
-
-
Currently Clocked In
- active -
-
- -
- No employees currently clocked in -
-
- - - - - - - - - - - - - - - - - - -
EmployeeClock-InLocation
-
+ +
+
+
+
h
+
Today
+
+
+
📅
+
h
+
This Week
+
+
+
+
h
+
OT This Week
+
+
+
🔥
+
+
On-time Streak
+
+
+ + +
+
+

Today's Shift

+
+ Scheduled + + (h) + Not scheduled today + +
+
+ Status + +
+
+ Source + +
+
+
+

My Recent Activity

+ +
No recent activity
+
+ +
+ + + h · + OT +
+
+
+
+ +
+
+

Upcoming Leave

+ +
No upcoming leave
+
+ +
+
+
+
+

Recent Penalties

+ +
None this month 🎉
+
+ +
· min
+
+
+
+ + + +
Team / Org
+ +
+
+
+
+
Present now
+
+
+
🚫
+
+
Absent today
+
+
+
+
+
Late today
+
+
+
📨
+
+
Pending approvals
- -
-
-
-
Alerts
+
+
+

Currently Clocked In of

+ +
No one is clocked in right now
+
+ +
+ + late + + · +
+
+
+
+

Needs Attention

+
+ absent (no leave) + review →
-
-
- Pending Reasons - -
-
- Pending Corrections - -
-
- Late Today - -
+
+ on approved leave + today +
+
+ auto clock-out — reason pending + view → +
+
+ correction requests + open →
+
+ - -
-
-
Quick Actions
-
-
- - -
-
+ +
+

Quick Actions

+
+ 🕒 Open My Clock + 📄 My Timesheets + + 📋 All Attendances + 📨 Approvals + ⚠ Penalties + 🗒 Activity Logs + + + 📅 Shift Planner + 📊 Reports +
diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index 3862e75c..ec47f256 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_clock_nfc_kiosk from . import test_shift_planner from . import test_photo_retention from . import test_schedule_driven +from . import test_dashboard diff --git a/fusion_clock/tests/test_dashboard.py b/fusion_clock/tests/test_dashboard.py new file mode 100644 index 00000000..9bb807cf --- /dev/null +++ b/fusion_clock/tests/test_dashboard.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +import json +from odoo import fields +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, + }) + + # Clock in both of the lead's reports so present_count / roster are + # exercised (and so the leak test proves a clocked-in *sibling* never + # appears in another employee's payload). + Att = cls.env['hr.attendance'] + now = fields.Datetime.now() + Att.create({'employee_id': cls.rep1_emp.id, 'check_in': now}) + Att.create({'employee_id': cls.rep2_emp.id, 'check_in': now}) + + 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 + + # ---- Task 1: personal block ---- + 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') + for key in ('today_hours', 'week_hours', 'overtime_week', 'ontime_streak', 'shift'): + self.assertIn(key, data['personal']) + + # ---- Task 2: team/org scoping ---- + 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') + 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']) + # both reports are clocked in → present and on the roster + self.assertEqual(data['team']['present_count'], 2) + self.assertEqual(data['team']['absent_count'], 0) + roster_names = {row['employee'] for row in data['team']['clocked_in']} + self.assertEqual(roster_names, {'Report Rita', 'Report Raj'}) + + 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') + self.assertGreaterEqual(data['team']['total_employees'], 5) + # the two clocked-in reports are counted org-wide too + self.assertGreaterEqual(data['team']['present_count'], 2) + + # ---- Task 3: no cross-employee leak + no-employee path ---- + 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']) diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index 76fafc57..8f7daddd 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -24,13 +24,15 @@ sequence="46" groups="group_fusion_clock_kiosk_app"/> - + + groups="group_fusion_clock_user"/>