first push message

This commit is contained in:
2026-07-01 14:41:49 +07:00
parent 6667dec2bf
commit 58b5f46cc4
2951 changed files with 316619 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
from . import controllers
from . import models
@@ -0,0 +1,52 @@
{
'name': "project_dashboard_advanced",
'summary': "Short (1 phrase/line) summary of the module's purpose",
'description': """
Project Dashboard Advanced Odoo app provides a comprehensive and visually rich dashboard
for analyzing projects and tasks efficiently. It includes KPI counter cards displaying
key metrics such as total projects, active tasks, overdue tasks, today's tasks, and
personal task assignments.
""",
'author': "My Company",
'website': "https://www.yourcompany.com",
# Categories can be used to filter modules in modules listing
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
# for the full list
'category': 'Uncategorized',
'version': '0.1',
'license': 'LGPL-3',
# any module necessary for this one to work correctly
'depends': ['project',
'hr_timesheet',
'mail',],
# always loaded
'data': [
'security/ir.model.access.csv',
'security/ir.model.access.csv',
'security/dashboard_security.xml',
'views/report_templates.xml',
'views/menu_view.xml',
],
# only loaded in demonstration mode
'demo': [
'demo/demo.xml',
],
'assets': {
'web.assets_backend': [
'project_dashboard_advanced/static/lib/chartjs/Chart.js',
'project_dashboard_advanced/static/src/css/dashboard.css',
'project_dashboard_advanced/static/src/js/dashboard.js',
'project_dashboard_advanced/static/src/xml/dashboard.xml',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': True,
'auto_install': False,
}
@@ -0,0 +1 @@
from . import main
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
from odoo import http, fields
from odoo.http import request
import logging
_logger = logging.getLogger(__name__)
class ProjectDashboardController(http.Controller):
def _verify_api_key(self, api_key):
if not api_key: return None
key_rec = request.env['dashboard.api.key'].sudo().search([('api_key', '=', api_key), ('active', '=', True)],
limit=1)
if key_rec:
key_rec.write({'last_used': fields.Datetime.now()})
return key_rec.company_id
return None
@http.route('/project_dashboard_advanced/get_kpis', type='json', auth='user')
def get_kpis(self, filters=None, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_dashboard_kpis(filters, company_id)
@http.route('/project_dashboard_advanced/tasks_by_project', type='json', auth='user')
def tasks_by_project(self, filters=None, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_tasks_by_project(filters, company_id)
@http.route('/project_dashboard_advanced/tasks_by_stage', type='json', auth='user')
def tasks_by_stage(self, filters=None, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_tasks_by_stage(filters, company_id)
@http.route('/project_dashboard_advanced/get_all_tasks', type='json', auth='user')
def get_all_tasks(self, filters=None, limit=10, offset=0, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_all_tasks(filters, limit, offset, company_id)
@http.route('/project_dashboard_advanced/get_companies', type='json', auth='user')
def get_companies(self):
return request.env['project.dashboard'].sudo().get_companies()
@http.route('/project_dashboard_advanced/get_managers', type='json', auth='user')
def get_managers(self):
user = request.env.user
users = request.env['res.users'].search([('share', '=', False), ('active', '=', True)],
order='name') if user.has_group(
'project.group_project_manager') else user
return [{'id': u.id, 'name': u.name} for u in users]
@http.route('/project_dashboard_advanced/get_customers', type='json', auth='user')
def get_customers(self):
customers = request.env['res.partner'].search([('customer_rank', '>', 0), ('active', '=', True)], order='name')
return [{'id': c.id, 'name': c.name} for c in customers]
@http.route('/project_dashboard_advanced/get_projects', type='json', auth='user')
def get_projects(self, manager_id=None, customer_id=None, company_id=None):
domain = []
if company_id: domain.append(('company_id', '=', company_id))
if manager_id: domain.append(('user_id', '=', int(manager_id)))
if customer_id: domain.append(('partner_id', '=', int(customer_id)))
if not request.env.user.has_group('project.group_project_manager'):
domain.append(('user_id', '=', request.env.user.id))
projects = request.env['project.project'].search(domain, order='name')
return [{'id': p.id, 'name': p.name} for p in projects]
@http.route('/project_dashboard_advanced/get_activities', type='json', auth='user')
def get_activities(self, filters=None, limit=10, offset=0):
if filters is None: filters = {}
domain = [('res_model', '=', 'project.task')]
if filters.get('project_id'):
tids = request.env['project.task'].search([('project_id', '=', filters['project_id'])]).ids
if tids: domain.append(('res_id', 'in', tids))
if not request.env.user.has_group('project.group_project_manager'):
domain.append(('user_id', '=', request.env.user.id))
activities = request.env['mail.activity'].search(domain, limit=limit, offset=offset, order='date_deadline desc')
total = request.env['mail.activity'].search_count(domain)
return {
'activities': [{
'id': a.id, 'task': a.res_name or 'Unknown',
'activity': a.activity_type_id.name if a.activity_type_id else 'Unknown',
'summary': a.summary or '',
'date': a.date_deadline.strftime('%Y-%m-%d') if a.date_deadline else ''
} for a in activities],
'total': total
}
# ✅ MISSING ROUTE RESTORED
@http.route('/project_dashboard_advanced/timesheet_hours', type='json', auth='user')
def timesheet_hours(self, filters=None, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_timesheet_hours(filters, company_id)
@http.route('/project_dashboard_advanced/task_deadline', type='json', auth='user')
def task_deadline(self, filters=None, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_task_deadline_chart(filters, company_id)
@http.route('/project_dashboard_advanced/priority_wise', type='json', auth='user')
def priority_wise(self, filters=None, company_id=None, api_key=None):
if api_key:
company = self._verify_api_key(api_key)
if company:
company_id = company.id
else:
return {'error': 'Invalid API Key'}
return request.env['project.dashboard'].sudo().get_priority_wise_tasks(filters, company_id)
+30
View File
@@ -0,0 +1,30 @@
<odoo>
<data>
<!--
<record id="object0" model="project_dashboard_advanced.project_dashboard_advanced">
<field name="name">Object 0</field>
<field name="value">0</field>
</record>
<record id="object1" model="project_dashboard_advanced.project_dashboard_advanced">
<field name="name">Object 1</field>
<field name="value">10</field>
</record>
<record id="object2" model="project_dashboard_advanced.project_dashboard_advanced">
<field name="name">Object 2</field>
<field name="value">20</field>
</record>
<record id="object3" model="project_dashboard_advanced.project_dashboard_advanced">
<field name="name">Object 3</field>
<field name="value">30</field>
</record>
<record id="object4" model="project_dashboard_advanced.project_dashboard_advanced">
<field name="name">Object 4</field>
<field name="value">40</field>
</record>
-->
</data>
</odoo>
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import dashboard_api_key
from . import project_dashboard
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
import secrets
class DashboardAPIKey(models.Model):
_name = 'dashboard.api.key'
_description = 'Dashboard API Key for External Access'
_order = 'create_date desc'
name = fields.Char(string='Name', required=True)
api_key = fields.Char(string='API Key', readonly=True, copy=False, index=True)
company_id = fields.Many2one('res.company', string='Company', required=True,
default=lambda self: self.env.company)
user_id = fields.Many2one('res.users', string='User', required=True,
default=lambda self: self.env.user)
active = fields.Boolean(default=True)
description = fields.Text(string='Description')
last_used = fields.Datetime(string='Last Used', readonly=True)
expiry_date = fields.Datetime(string='Expiry Date')
@api.model
def create(self, vals):
# Generate API key if not provided
if not vals.get('api_key'):
vals['api_key'] = f"pda_{secrets.token_urlsafe(32)}"
return super().create(vals)
def action_regenerate_key(self):
"""Regenerate API key"""
self.ensure_one()
self.write({'api_key': f"pda_{secrets.token_urlsafe(32)}"})
return True
def action_check_validity(self):
"""Check if API key is still valid"""
self.ensure_one()
if not self.active:
return False
if self.expiry_date and fields.Datetime.now() > self.expiry_date:
return False
return True
@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from datetime import datetime, timedelta
from collections import defaultdict
class ProjectDashboard(models.Model):
_name = 'project.dashboard'
_description = 'Project Dashboard Data'
@api.model
def get_dashboard_kpis(self, filters=None, company_id=None):
if filters is None: filters = {}
user = self.env.user
today = fields.Date.today()
company = self.env['res.company'].browse(company_id) if company_id else user.company_id
task_domain = [('company_id', '=', company.id)]
project_domain = [('company_id', '=', company.id)]
if filters.get('manager_id'):
project_domain.append(('user_id', '=', filters['manager_id']))
task_domain.append(('project_id.user_id', '=', filters['manager_id']))
if filters.get('customer_id'):
project_domain.append(('partner_id', '=', filters['customer_id']))
task_domain.append(('project_id.partner_id', '=', filters['customer_id']))
if filters.get('project_id'):
task_domain.append(('project_id', '=', filters['project_id']))
# User filtering
if not user.has_group('project.group_project_manager'):
task_domain.append(('user_ids', 'in', [user.id]))
# ✅ FIXED: Use 'fold' field to find closed stages
closed_stages = self.env['project.task.type'].search([('fold', '=', True)])
return {
'total_projects': self.env['project.project'].search_count(project_domain),
'active_tasks': self.env['project.task'].search_count(
task_domain + [('stage_id', 'not in', closed_stages.ids)]),
'overdue_tasks': self.env['project.task'].search_count(
task_domain + [('date_deadline', '<', today), ('stage_id', 'not in', closed_stages.ids)]),
'today_tasks': self.env['project.task'].search_count(task_domain + [('date_deadline', '=', today)]),
'my_tasks': self.env['project.task'].search_count(task_domain + [('user_ids', 'in', [user.id])]),
'my_overdue_tasks': self.env['project.task'].search_count(
task_domain + [('user_ids', 'in', [user.id]), ('date_deadline', '<', today),
('stage_id', 'not in', closed_stages.ids)]),
}
@api.model
def get_task_deadline_chart(self, filters=None, company_id=None):
if filters is None: filters = {}
today = fields.Date.today()
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
task_domain = [('company_id', '=', company.id)]
if filters.get('project_id'): task_domain.append(('project_id', '=', filters['project_id']))
if not self.env.user.has_group('project.group_project_manager'): task_domain.append(
('user_ids', 'in', [self.env.user.id]))
# ✅ FIXED: Use 'fold' field
closed_stages = self.env['project.task.type'].search([('fold', '=', True)])
tasks = self.env['project.task'].search(task_domain + [('stage_id', 'not in', closed_stages.ids)])
overdue = today_count = upcoming = 0
for task in tasks:
if task.date_deadline:
d = fields.Date.to_date(task.date_deadline)
if d and d < today:
overdue += 1
elif d and d == today:
today_count += 1
elif d:
upcoming += 1
return [{'name': 'Overdue', 'value': overdue}, {'name': 'Today', 'value': today_count},
{'name': 'Upcoming', 'value': upcoming}]
@api.model
def get_tasks_by_project(self, filters=None, company_id=None):
if filters is None: filters = {}
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
task_domain = [('company_id', '=', company.id)]
if filters.get('manager_id'): task_domain.append(('project_id.user_id', '=', filters['manager_id']))
if filters.get('customer_id'): task_domain.append(('project_id.partner_id', '=', filters['customer_id']))
if not self.env.user.has_group('project.group_project_manager'): task_domain.append(
('user_ids', 'in', [self.env.user.id]))
tasks = self.env['project.task'].search(task_domain)
project_data = defaultdict(int)
for task in tasks:
project_data[task.project_id.name or 'Unassigned'] += 1
return [{'name': k, 'value': v} for k, v in project_data.items()]
@api.model
def get_tasks_by_stage(self, filters=None, company_id=None):
if filters is None: filters = {}
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
task_domain = [('company_id', '=', company.id)]
if filters.get('project_id'): task_domain.append(('project_id', '=', filters['project_id']))
if not self.env.user.has_group('project.group_project_manager'): task_domain.append(
('user_ids', 'in', [self.env.user.id]))
tasks = self.env['project.task'].search(task_domain)
stage_data = defaultdict(int)
for task in tasks:
stage_data[task.stage_id.name or 'No Stage'] += 1
return [{'name': k, 'value': v} for k, v in stage_data.items()]
@api.model
def get_all_tasks(self, filters=None, limit=10, offset=0, company_id=None):
if filters is None: filters = {}
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
task_domain = [('company_id', '=', company.id)]
if filters.get('project_id'): task_domain.append(('project_id', '=', filters['project_id']))
if filters.get('manager_id'): task_domain.append(('project_id.user_id', '=', filters['manager_id']))
if filters.get('customer_id'): task_domain.append(('project_id.partner_id', '=', filters['customer_id']))
if not self.env.user.has_group('project.group_project_manager'): task_domain.append(
('user_ids', 'in', [self.env.user.id]))
tasks = self.env['project.task'].search(task_domain, limit=limit, offset=offset, order='date_deadline asc')
total = self.env['project.task'].search_count(task_domain)
today = fields.Date.today()
priority_map = {'0': 'Low', '1': 'Medium', '2': 'High', '3': 'Urgent'}
result = []
for task in tasks:
days_diff = 999
status = 'upcoming'
if task.date_deadline:
# ✅ SAFE CONVERSION: Handles both datetime and date types
deadline_date = fields.Date.to_date(task.date_deadline)
if deadline_date:
delta = deadline_date - today
days_diff = delta.days
if days_diff < 0:
status = 'overdue'
elif days_diff == 0:
status = 'today'
result.append({
'id': task.id, 'name': task.name,
'project': task.project_id.name if task.project_id else '',
'deadline': task.date_deadline.strftime('%Y-%m-%d') if task.date_deadline else '',
'days_diff': days_diff, 'status': status,
'priority': str(task.priority), 'priority_label': priority_map.get(str(task.priority), 'Normal'),
})
return {'tasks': result, 'total': total}
@api.model
def get_timesheet_hours(self, filters=None, company_id=None):
if filters is None: filters = {}
today = fields.Date.today()
first_day = today.replace(day=1)
last_day = (first_day + timedelta(days=32)).replace(day=1) - timedelta(days=1)
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
domain = [('date', '>=', first_day), ('date', '<=', last_day), ('company_id', '=', company.id)]
if filters.get('project_id'): domain.append(('project_id', '=', filters['project_id']))
timesheets = self.env['account.analytic.line'].search(domain)
daily_hours = defaultdict(float)
for ts in timesheets:
daily_hours[ts.date.strftime('%Y-%m-%d')] += ts.unit_amount
result = []
current_day = first_day
while current_day <= last_day:
date_str = current_day.strftime('%Y-%m-%d')
result.append({'date': date_str, 'hours': daily_hours.get(date_str, 0.0)})
current_day += timedelta(days=1)
return result
@api.model
def get_task_deadline_chart(self, filters=None, company_id=None):
if filters is None: filters = {}
today = fields.Date.today()
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
task_domain = [('company_id', '=', company.id)]
if filters.get('project_id'): task_domain.append(('project_id', '=', filters['project_id']))
if not self.env.user.has_group('project.group_project_manager'): task_domain.append(
('user_ids', 'in', [self.env.user.id]))
tasks = self.env['project.task'].search(task_domain)
overdue = today_count = upcoming = 0
for task in tasks:
if task.date_deadline:
# ✅ SAFE CONVERSION
d = fields.Date.to_date(task.date_deadline)
if d and d < today:
overdue += 1
elif d and d == today:
today_count += 1
elif d:
upcoming += 1
return [{'name': 'Overdue', 'value': overdue}, {'name': 'Today', 'value': today_count},
{'name': 'Upcoming', 'value': upcoming}]
@api.model
def get_priority_wise_tasks(self, filters=None, company_id=None):
if filters is None: filters = {}
company = self.env['res.company'].browse(company_id) if company_id else self.env.user.company_id
task_domain = [('company_id', '=', company.id)]
if filters.get('project_id'): task_domain.append(('project_id', '=', filters['project_id']))
if not self.env.user.has_group('project.group_project_manager'): task_domain.append(
('user_ids', 'in', [self.env.user.id]))
tasks = self.env['project.task'].search(task_domain)
priority_data = defaultdict(int)
priority_map = {'0': 'Low', '1': 'Medium', '2': 'High', '3': 'Urgent'}
for task in tasks:
priority_data[priority_map.get(str(task.priority), 'Normal')] += 1
return [{'name': k, 'value': v} for k, v in priority_data.items()]
@api.model
def get_companies(self):
user = self.env.user
companies = user.company_ids if user.has_group('base.group_multi_company') else user.company_id
return [{'id': c.id, 'name': c.name} for c in companies]
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- API Key Model -->
<record id="model_dashboard_api_key" model="ir.model">
<field name="name">Dashboard API Key</field>
<field name="model">dashboard.api.key</field>
<!-- <field name="state">manual</field>-->
</record>
</odoo>
@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_dashboard_advanced_project_dashboard_advanced,project_dashboard_advanced.project_dashboard_advanced,model_project_dashboard,base.group_user,1,1,1,1
access_dashboard_api_key_user,dashboard.api.key.user,model_dashboard_api_key,base.group_user,1,0,0,0
access_dashboard_api_key_manager,dashboard.api.key.manager,model_dashboard_api_key,project.group_project_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_dashboard_advanced_project_dashboard_advanced project_dashboard_advanced.project_dashboard_advanced model_project_dashboard base.group_user 1 1 1 1
3 access_dashboard_api_key_user dashboard.api.key.user model_dashboard_api_key base.group_user 1 0 0 0
4 access_dashboard_api_key_manager dashboard.api.key.manager model_dashboard_api_key project.group_project_manager 1 1 1 1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,400 @@
/* Main Dashboard Container */
.o_project_dashboard {
padding: 20px;
background: #f8f9fa;
overflow:scroll;
height:100vh;
overflow-y: auto;
overflow-x: auto;
font-size: 14px;
}
/* Header Styles */
.dashboard-header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.greeting-section {
display: flex;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e9ecef;
}
.user-avatar img {
width: 64px;
height: 64px;
border-radius: 50%;
margin-right: 15px;
border: 3px solid #007bff;
}
.greeting-text h3 {
margin: 0;
color: #333;
font-size: 24px;
}
.greeting-text p {
margin: 5px 0 0;
color: #666;
}
.filters-section {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-group {
flex: 1;
min-width: 150px;
}
.filter-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
font-size: 12px;
}
.filter-group select,
.filter-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
/* KPI Cards */
.kpi-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.kpi-card {
background: white;
padding: 25px;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-left: 4px solid #007bff;
transition: transform 0.3s;
}
.kpi-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.kpi-card.my-tasks { border-left-color: #007bff; }
.kpi-card.my-overdue { border-left-color: #dc3545; }
.kpi-card.total-projects { border-left-color: #28a745; }
.kpi-card.active-tasks { border-left-color: #ffc107; }
.kpi-card.overdue-tasks { border-left-color: #fd7e14; }
.kpi-card.today-tasks { border-left-color: #6f42c1; }
.kpi-value {
font-size: 36px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.kpi-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Charts */
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
/* Ensure charts have proper dimensions */
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
min-height: 300px; /* ✅ Critical: Give charts minimum height */
position: relative; /* ✅ Required for Chart.js positioning */
}
.chart-container canvas {
max-height: 300px;
width: 100% !important; /* ✅ Force full width */
height: 250px !important; /* ✅ Force height */
}
.chart-container h4 {
margin: 0 0 20px;
color: #333;
font-size: 18px;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
/* Responsive charts */
@media (max-width: 768px) {
.chart-container {
min-height: 250px;
}
.chart-container canvas {
height: 200px !important;
}
}
/* Tables */
.data-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.table-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
max-height: 400px;
overflow-y: auto;
}
.table-container h4 {
margin: 0 0 20px;
color: #333;
font-size: 18px;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
.table {
margin-bottom: 0;
width: 100%;
}
.table thead th {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
color: #555;
padding: 12px;
position: sticky;
top: 0;
z-index: 10;
}
.table tbody td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.table tbody tr:hover {
background: #f8f9fa;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
.pagination button {
padding: 5px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pagination button:hover {
background: #0056b3;
}
.pagination button:disabled {
background: #6c757d;
cursor: not-allowed;
}
/* Print Button */
#btn_print_dashboard {
background: #28a745;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
height: fit-content;
}
#btn_print_dashboard:hover {
background: #1e7e34;
}
/* Responsive */
@media (max-width: 768px) {
.charts-row,
.data-row {
grid-template-columns: 1fr;
}
.filters-section {
flex-direction: column;
}
.filter-group {
width: 100%;
}
.kpi-cards {
grid-template-columns: repeat(2, 1fr);
}
}
/* Loading Spinner */
.loading {
text-align: center;
padding: 40px;
}
.loading::after {
content: '';
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Add these new styles */
/* Live indicator */
.live-indicator {
margin-top: 15px;
padding: 8px 12px;
background: #e8f5e9;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.live-dot {
width: 8px;
height: 8px;
background: #4caf50;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Clickable KPI cards */
.kpi-card.clickable {
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.kpi-card.clickable:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
}
.kpi-card.clickable .kpi-hint {
font-size: 10px;
color: #999;
margin-top: 5px;
opacity: 0;
transition: opacity 0.3s;
}
.kpi-card.clickable:hover .kpi-hint {
opacity: 1;
}
/* Filter actions */
.filter-actions {
display: flex;
gap: 10px;
align-items: flex-end;
}
/* Print styles */
@media print {
.no-print,
.filter-actions,
.live-indicator,
.pagination,
.btn,
.kpi-hint {
display: none !important;
}
.kpi-card.clickable {
cursor: default;
break-inside: avoid;
}
.chart-container {
break-inside: avoid;
page-break-inside: avoid;
}
.o_project_dashboard {
padding: 0;
background: white;
}
.dashboard-header {
box-shadow: none;
border-bottom: 2px solid #007bff;
}
}
/* Responsive improvements */
@media (max-width: 768px) {
.filter-actions {
flex-direction: column;
width: 100%;
}
.filter-actions button {
width: 100%;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,573 @@
/** @odoo-module **/
import { Component, onMounted, onWillUnmount, useState, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class ProjectDashboard extends Component {
static template = "project_dashboard_advanced.ProjectDashboard";
setup() {
console.log("🚀 Dashboard Component Loaded with Live Updates");
this.state = useState({
userName: 'User',
userImage: '/web/static/img/user_avatar.png',
greeting: 'Morning',
filters: {
manager_id: null,
customer_id: null,
project_id: null,
date_range: 'lifetime',
company_id: null
},
kpis: {},
tasks: [],
activities: [],
tasksPage: 1,
activitiesPage: 1,
tasksLimit: 10,
activitiesLimit: 10,
tasksTotal: 0,
activitiesTotal: 0,
loaded: false,
showCompanyFilter: false,
lastUpdate: new Date().toLocaleTimeString(),
refreshInterval: 15,
autoRefresh: true,
});
// Define Refs
this.refsConfig = {
filter_company: useRef("filter_company"),
filter_manager: useRef("filter_manager"),
filter_customer: useRef("filter_customer"),
filter_project: useRef("filter_project"),
filter_date: useRef("filter_date"),
btn_refresh: useRef("btn_refresh"),
btn_print_dashboard: useRef("btn_print_dashboard"),
kpi_my_tasks: useRef("kpi_my_tasks"),
kpi_my_overdue: useRef("kpi_my_overdue"),
kpi_total_projects: useRef("kpi_total_projects"),
kpi_active_tasks: useRef("kpi_active_tasks"),
kpi_overdue_tasks: useRef("kpi_overdue_tasks"),
kpi_today_tasks: useRef("kpi_today_tasks"),
chart_tasks_by_stage: useRef("chart_tasks_by_stage"),
chart_tasks_by_project: useRef("chart_tasks_by_project"),
chart_timesheet_hours: useRef("chart_timesheet_hours"),
chart_task_deadline: useRef("chart_task_deadline"),
chart_priority_wise: useRef("chart_priority_wise"),
tasks_table_body: useRef("tasks_table_body"),
btn_prev_tasks: useRef("btn_prev_tasks"),
tasks_pagination: useRef("tasks_pagination"),
btn_next_tasks: useRef("btn_next_tasks"),
activities_table_body: useRef("activities_table_body"),
btn_prev_activities: useRef("btn_prev_activities"),
activities_pagination: useRef("activities_pagination"),
btn_next_activities: useRef("btn_next_activities"),
};
this.charts = {};
this.refreshInterval = null;
var self = this;
onMounted(function () {
console.log("📥 Fetching Dashboard Data...");
self._getSession().then(function () {
return self._loadCompanies();
}).then(function () {
return self._loadFilterOptions();
}).then(function () {
return self._loadAll();
}).then(function () {
self.state.loaded = true;
self._startAutoRefresh();
self._bindEvents();
console.log("✅ Dashboard Load Complete");
}).catch(function (err) {
console.error("❌ Error loading dashboard:", err);
});
});
onWillUnmount(function () {
self._stopAutoRefresh();
});
}
_getGreeting() {
var h = new Date().getHours();
return h < 12 ? 'Morning' : h < 18 ? 'Afternoon' : 'Evening';
}
_getSession() {
var self = this;
return fetch('/web/session/get_session_info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: {} })
}).then(function (resp) { return resp.json(); })
.then(function (result) {
if (result.result) {
self.state.userName = result.result.name || 'User';
self.state.userImage = result.result.uid
? '/web/image?model=res.users&id=' + result.result.uid + '&field=image_128'
: '/web/static/img/user_avatar.png';
self.csrfToken = result.result.csrf_token;
self.state.greeting = self._getGreeting();
// Check if user is admin/manager
self.state.isAdmin = result.result.is_admin || result.result.uid === 2;
}
});
}
_call(route, params) {
var self = this;
return fetch(route, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': self.csrfToken || ''
},
body: JSON.stringify({
jsonrpc: '2.0',
id: Math.random(),
method: 'call',
params: params || {}
})
}).then(function (resp) { return resp.json(); })
.then(function (result) {
if (result.error) throw new Error(result.error.data.message);
return result.result;
});
}
_loadCompanies() {
var self = this;
return this._call('/project_dashboard_advanced/get_companies', {}).then(function (companies) {
if (companies && companies.length > 1) {
self.state.showCompanyFilter = true;
self._populateSelect(self.refsConfig.filter_company.el, companies, 'All Companies');
}
});
}
_loadFilterOptions() {
var self = this;
return Promise.all([
self._call('/project_dashboard_advanced/get_managers', {}),
self._call('/project_dashboard_advanced/get_customers', {})
]).then(function (results) {
self._populateSelect(self.refsConfig.filter_manager.el, results[0], 'All Managers');
self._populateSelect(self.refsConfig.filter_customer.el, results[1], 'All Customer');
return self._loadProjectOptions();
});
}
_loadProjectOptions() {
var self = this;
var params = {};
if (self.state.filters.company_id) params.company_id = self.state.filters.company_id;
if (self.state.filters.manager_id) params.manager_id = self.state.filters.manager_id;
if (self.state.filters.customer_id) params.customer_id = self.state.filters.customer_id;
return self._call('/project_dashboard_advanced/get_projects', params).then(function (projects) {
self._populateSelect(self.refsConfig.filter_project.el, projects, 'All Projects');
});
}
_populateSelect(selectEl, items, defaultText) {
if (!selectEl) return;
selectEl.innerHTML = '<option value="">' + defaultText + '</option>';
items.forEach(function (item) {
var opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
selectEl.appendChild(opt);
});
}
_startAutoRefresh() {
var self = this;
this.refreshInterval = setInterval(function () {
console.log("🔄 Auto-refreshing dashboard data...");
self._loadAll().then(function () {
self.state.lastUpdate = new Date().toLocaleTimeString();
});
}, 15000); // 15 seconds
}
_stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
_loadAll() {
var self = this;
return Promise.all([
self._loadKPIs(),
self._loadCharts(),
self._loadTasks(),
self._loadActivities()
]);
}
_loadKPIs() {
var self = this;
var params = { filters: self.state.filters };
if (self.state.filters.company_id) {
params.company_id = self.state.filters.company_id;
}
return self._call('/project_dashboard_advanced/get_kpis', params).then(function (kpis) {
self.state.kpis = kpis;
var kpiMap = {
'my_tasks': kpis.my_tasks,
'my_overdue': kpis.my_overdue_tasks,
'total_projects': kpis.total_projects,
'active_tasks': kpis.active_tasks,
'overdue_tasks': kpis.overdue_tasks,
'today_tasks': kpis.today_tasks
};
Object.keys(kpiMap).forEach(function (key) {
var refName = 'kpi_' + key;
var el = self.refsConfig[refName] ? self.refsConfig[refName].el : null;
if (el) {
var valueEl = el.querySelector('.kpi-value');
if (valueEl) valueEl.textContent = kpiMap[key] || 0;
}
});
});
}
_loadCharts() {
var self = this;
var params = { filters: self.state.filters };
if (self.state.filters.company_id) {
params.company_id = self.state.filters.company_id;
}
if (typeof Chart === 'undefined') {
console.error("❌ Chart.js is NOT loaded!");
return;
}
return self._call('/project_dashboard_advanced/tasks_by_stage', params)
.then(function (data) { self._renderChart('chart_tasks_by_stage', 'doughnut', data); })
.then(function () { return self._call('/project_dashboard_advanced/tasks_by_project', params); })
.then(function (data) { self._renderChart('chart_tasks_by_project', 'bar', data); })
.then(function () { return self._call('/project_dashboard_advanced/timesheet_hours', params); })
.then(function (data) { self._renderChart('chart_timesheet_hours', 'line', data); })
.then(function () { return self._call('/project_dashboard_advanced/task_deadline', params); })
.then(function (data) { self._renderChart('chart_task_deadline', 'pie', data); })
.then(function () { return self._call('/project_dashboard_advanced/priority_wise', params); })
.then(function (data) { self._renderChart('chart_priority_wise', 'bar', data); });
}
_renderChart(refName, type, data) {
var canvasEl = this.refsConfig[refName] ? this.refsConfig[refName].el : null;
if (!canvasEl) return;
if (this.charts[refName]) this.charts[refName].destroy();
var labels = [];
var values = [];
var colors = ['#007bff','#28a745','#ffc107','#dc3545','#6f42c1','#17a2b8','#6610f2','#fd7e14'];
if (data && Array.isArray(data) && data.length > 0) {
data.forEach(function (item, index) {
labels.push(item.name || 'Category ' + (index + 1));
values.push(item.value || 0);
});
} else {
labels.push('No Data');
values.push(0);
}
var chartDataObj = {
labels: labels,
datasets: [{ data: values, backgroundColor: colors, borderWidth: 1 }]
};
var chartConfig = {
type: type,
data: chartDataObj,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } }
}
};
try {
this.charts[refName] = new Chart(canvasEl, chartConfig);
} catch (e) {
console.error("Chart Error:", e);
}
}
_loadTasks() {
var self = this;
var offset = (self.state.tasksPage - 1) * self.state.tasksLimit;
var params = {
filters: self.state.filters,
limit: self.state.tasksLimit,
offset: offset
};
if (self.state.filters.company_id) {
params.company_id = self.state.filters.company_id;
}
return self._call('/project_dashboard_advanced/get_all_tasks', params).then(function (result) {
self.state.tasks = result.tasks || [];
self.state.tasksTotal = result.total || 0;
self._renderTasksTable();
self._updateTasksPagination();
});
}
_renderTasksTable() {
var tbodyEl = this.refsConfig.tasks_table_body ? this.refsConfig.tasks_table_body.el : null;
if (!tbodyEl) return;
if (this.state.tasks.length === 0) {
tbodyEl.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No tasks found</td></tr>';
return;
}
var self = this;
var html = '';
this.state.tasks.forEach(function (t) {
var badgeClass = 'bg-success';
if (t.status === 'overdue') badgeClass = 'bg-danger';
else if (t.status === 'today') badgeClass = 'bg-warning';
html += '<tr>' +
'<td>' + (t.name || '') + '</td>' +
'<td>' + (t.project || '-') + '</td>' +
'<td><span class="badge ' + badgeClass + '">' + (t.date_deadline || '-') + '</span></td>' +
'<td><span class="badge bg-secondary">' + (t.priority_label || '-') + '</span></td>' +
'<td><button class="btn btn-sm btn-primary btn-view-task" data-task-id="' + t.id + '">View</button></td>' +
'</tr>';
});
tbodyEl.innerHTML = html;
tbodyEl.querySelectorAll('.btn-view-task').forEach(function(btn) {
btn.addEventListener('click', function() {
var taskId = this.getAttribute('data-task-id');
self._openTask(taskId);
});
});
}
_updateTasksPagination() {
var maxPage = Math.ceil(this.state.tasksTotal / this.state.tasksLimit) || 1;
var pagEl = this.refsConfig.tasks_pagination ? this.refsConfig.tasks_pagination.el : null;
if (pagEl) pagEl.textContent = 'Page ' + this.state.tasksPage + ' of ' + maxPage;
var prevBtn = this.refsConfig.btn_prev_tasks ? this.refsConfig.btn_prev_tasks.el : null;
var nextBtn = this.refsConfig.btn_next_tasks ? this.refsConfig.btn_next_tasks.el : null;
if (prevBtn) prevBtn.disabled = this.state.tasksPage <= 1;
if (nextBtn) nextBtn.disabled = this.state.tasksPage >= maxPage;
}
_loadActivities() {
var self = this;
var offset = (self.state.activitiesPage - 1) * self.state.activitiesLimit;
return self._call('/project_dashboard_advanced/get_activities', {
filters: self.state.filters,
limit: self.state.activitiesLimit,
offset: offset
}).then(function (result) {
self.state.activities = result.activities || [];
self.state.activitiesTotal = result.total || 0;
self._renderActivitiesTable();
self._updateActivitiesPagination();
});
}
_renderActivitiesTable() {
var tbodyEl = this.refsConfig.activities_table_body ? this.refsConfig.activities_table_body.el : null;
if (!tbodyEl) return;
if (this.state.activities.length === 0) {
tbodyEl.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No activities found</td></tr>';
return;
}
var self = this;
var html = '';
this.state.activities.forEach(function (a) {
html += '<tr>' +
'<td>' + (a.task || '-') + '</td>' +
'<td>' + (a.activity || '-') + '</td>' +
'<td>' + (a.summary || '-') + '</td>' +
'<td>' + (a.date || '-') + '</td>' +
'<td><button class="btn btn-sm btn-primary btn-view-activity" data-activity-id="' + a.id + '">View</button></td>' +
'</tr>';
});
tbodyEl.innerHTML = html;
tbodyEl.querySelectorAll('.btn-view-activity').forEach(function(btn) {
btn.addEventListener('click', function() {
var activityId = this.getAttribute('data-activity-id');
self._openActivity(activityId);
});
});
}
_updateActivitiesPagination() {
var maxPage = Math.ceil(this.state.activitiesTotal / this.state.activitiesLimit) || 1;
var pagEl = this.refsConfig.activities_pagination ? this.refsConfig.activities_pagination.el : null;
if (pagEl) pagEl.textContent = 'Page ' + this.state.activitiesPage + ' of ' + maxPage;
var prevBtn = this.refsConfig.btn_prev_activities ? this.refsConfig.btn_prev_activities.el : null;
var nextBtn = this.refsConfig.btn_next_activities ? this.refsConfig.btn_next_activities.el : null;
if (prevBtn) prevBtn.disabled = this.state.activitiesPage <= 1;
if (nextBtn) nextBtn.disabled = this.state.activitiesPage >= maxPage;
}
_openTask(taskId) {
var id = parseInt(taskId);
if (isNaN(id)) return;
window.location.href = '/web#model=project.task&id=' + id + '&view_type=form';
}
_openActivity(activityId) {
var id = parseInt(activityId);
if (isNaN(id)) return;
window.location.href = '/web#model=mail.activity&id=' + id + '&view_type=form';
}
_handleKPIClick(filterType) {
console.log("📊 KPI clicked:", filterType);
// Apply filter based on KPI type
var filters = {};
switch(filterType) {
case 'my_tasks':
filters.user_id = this.state.userName; // This would need backend support
break;
case 'my_overdue':
filters.status = 'overdue';
break;
case 'total_projects':
// Open projects view
window.location.href = '/web#model=project.project&view_type=list';
return;
case 'active_tasks':
filters.stage = 'active';
break;
case 'overdue_tasks':
filters.status = 'overdue';
break;
case 'today_tasks':
filters.date_deadline = 'today';
break;
}
// Reload tasks with new filter
this.state.filters = Object.assign({}, this.state.filters, filters);
this._loadTasks();
}
_printReport() {
console.log("🖨️ Printing dashboard report...");
window.print();
}
_bindEvents() {
var self = this;
// Company filter
if (this.refsConfig.filter_company.el) {
this.refsConfig.filter_company.el.addEventListener('change', function (e) {
var value = e.target.value;
self.state.filters.company_id = value ? parseInt(value) : null;
self._loadProjectOptions().then(function () { return self._loadAll(); });
});
}
// Other filters
var filterEls = [
{ ref: this.refsConfig.filter_manager, name: 'manager' },
{ ref: this.refsConfig.filter_customer, name: 'customer' },
{ ref: this.refsConfig.filter_project, name: 'project' },
{ ref: this.refsConfig.filter_date, name: 'date' }
];
filterEls.forEach(function (item) {
if (item.ref && item.ref.el) {
item.ref.el.addEventListener('change', function (e) {
var value = e.target.value;
self.state.filters[item.name + '_id'] =
item.name === 'date' ? value : (value ? parseInt(value) : null);
if (item.name === 'manager' || item.name === 'customer') {
self._loadProjectOptions().then(function () { return self._loadAll(); });
} else {
self._loadAll();
}
});
}
});
// KPI Cards Click Handlers
var kpiCards = [
{ ref: this.refsConfig.kpi_my_tasks, filter: 'my_tasks' },
{ ref: this.refsConfig.kpi_my_overdue, filter: 'my_overdue' },
{ ref: this.refsConfig.kpi_total_projects, filter: 'total_projects' },
{ ref: this.refsConfig.kpi_active_tasks, filter: 'active_tasks' },
{ ref: this.refsConfig.kpi_overdue_tasks, filter: 'overdue_tasks' },
{ ref: this.refsConfig.kpi_today_tasks, filter: 'today_tasks' },
];
kpiCards.forEach(function(kpi) {
if (kpi.ref && kpi.ref.el) {
kpi.ref.el.style.cursor = 'pointer';
kpi.ref.el.addEventListener('click', function() {
self._handleKPIClick(kpi.filter);
});
}
});
// Refresh button
if (this.refsConfig.btn_refresh.el) {
this.refsConfig.btn_refresh.el.addEventListener('click', function () {
console.log("🔄 Manual refresh triggered");
self._loadAll().then(function () {
self.state.lastUpdate = new Date().toLocaleTimeString();
});
});
}
// Print button
if (this.refsConfig.btn_print_dashboard.el) {
this.refsConfig.btn_print_dashboard.el.addEventListener('click', function () {
self._printReport();
});
}
// Pagination buttons
var btns = [
{ el: this.refsConfig.btn_prev_tasks.el, action: () => { if (self.state.tasksPage > 1) { self.state.tasksPage--; self._loadTasks(); } } },
{ el: this.refsConfig.btn_next_tasks.el, action: () => { var max = Math.ceil(self.state.tasksTotal / self.state.tasksLimit); if (self.state.tasksPage < max) { self.state.tasksPage++; self._loadTasks(); } } },
{ el: this.refsConfig.btn_prev_activities.el, action: () => { if (self.state.activitiesPage > 1) { self.state.activitiesPage--; self._loadActivities(); } } },
{ el: this.refsConfig.btn_next_activities.el, action: () => { var max = Math.ceil(self.state.activitiesTotal / self.state.activitiesLimit); if (self.state.activitiesPage < max) { self.state.activitiesPage++; self._loadActivities(); } } },
];
btns.forEach(function(btn) {
if (btn.el) btn.el.addEventListener('click', btn.action);
});
}
}
registry.category('actions').add('project_dashboard_tag', ProjectDashboard);
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project_dashboard_advanced.ProjectDashboard" owl="1">
<div class="o_project_dashboard">
<!-- HEADER -->
<div class="dashboard-header">
<div class="greeting-section">
<div class="user-avatar">
<img t-att-src="state.userImage" alt="User" class="img-circle"/>
</div>
<div class="greeting-text">
<!-- <h3>Good <t t-esc="state.greeting"/>, <t t-esc="state.userName"/></h3>-->
<h3><t t-esc="state.userName"/></h3>
<p>My Dashboard</p>
</div>
</div>
<div class="filters-section">
<!-- Company Filter -->
<div class="filter-group" t-if="state.showCompanyFilter">
<label>Company</label>
<select t-ref="filter_company" class="form-control">
<option value="">All Companies</option>
</select>
</div>
<div class="filter-group">
<label>Managers</label>
<select t-ref="filter_manager" class="form-control">
<option value="">All Managers</option>
</select>
</div>
<div class="filter-group">
<label>Customers</label>
<select t-ref="filter_customer" class="form-control">
<option value="">All Customer</option>
</select>
</div>
<div class="filter-group">
<label>Projects</label>
<select t-ref="filter_project" class="form-control">
<option value="">All Projects</option>
</select>
</div>
<div class="filter-group">
<label>Date</label>
<select t-ref="filter_date" class="form-control">
<option value="lifetime">Lifetime</option>
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
</select>
</div>
<div class="filter-actions">
<button t-ref="btn_refresh" class="btn btn-secondary" title="Refresh (15s)">
<i class="fa fa-refresh"/> Refresh
</button>
<button t-ref="btn_print_dashboard" class="btn btn-primary">
<i class="fa fa-print"/> Print Report
</button>
</div>
</div>
<!-- Live indicator -->
<div class="live-indicator">
<span class="live-dot"/>
<span>Live Update: <t t-esc="state.lastUpdate"/> (<t t-esc="state.refreshInterval"/>s)</span>
</div>
</div>
<!-- KPI CARDS - Clickable -->
<div class="kpi-cards">
<div class="kpi-card my-tasks clickable" t-ref="kpi_my_tasks" data-filter="my_tasks">
<div class="kpi-value">0</div>
<div class="kpi-label">My Tasks</div>
<div class="kpi-hint">Click to view</div>
</div>
<div class="kpi-card my-overdue clickable" t-ref="kpi_my_overdue" data-filter="my_overdue">
<div class="kpi-value">0</div>
<div class="kpi-label">My Overdue</div>
<div class="kpi-hint">Click to view</div>
</div>
<div class="kpi-card total-projects clickable" t-ref="kpi_total_projects" data-filter="total_projects">
<div class="kpi-value">0</div>
<div class="kpi-label">Total Projects</div>
<div class="kpi-hint">Click to view</div>
</div>
<div class="kpi-card active-tasks clickable" t-ref="kpi_active_tasks" data-filter="active_tasks">
<div class="kpi-value">0</div>
<div class="kpi-label">Active Tasks</div>
<div class="kpi-hint">Click to view</div>
</div>
<div class="kpi-card overdue-tasks clickable" t-ref="kpi_overdue_tasks" data-filter="overdue_tasks">
<div class="kpi-value">0</div>
<div class="kpi-label">Overdue Tasks</div>
<div class="kpi-hint">Click to view</div>
</div>
<div class="kpi-card today-tasks clickable" t-ref="kpi_today_tasks" data-filter="today_tasks">
<div class="kpi-value">0</div>
<div class="kpi-label">Today Tasks</div>
<div class="kpi-hint">Click to view</div>
</div>
</div>
<!-- CHARTS -->
<div class="charts-row">
<div class="chart-container">
<h4>Task By Stages</h4>
<canvas t-ref="chart_tasks_by_stage"/>
</div>
<div class="chart-container">
<h4>Task By Project</h4>
<canvas t-ref="chart_tasks_by_project"/>
</div>
</div>
<!-- TASKS TABLE & DEADLINE CHART -->
<div class="data-row">
<div class="table-container">
<h4>All Tasks</h4>
<table class="table table-striped">
<thead><tr><th>Name</th><th>Project</th><th>Deadline</th><th>Priority</th><th>Action</th></tr></thead>
<tbody t-ref="tasks_table_body"/>
</table>
<div class="pagination">
<button t-ref="btn_prev_tasks" class="btn btn-sm">Previous</button>
<span t-ref="tasks_pagination">Page 1 of 1</span>
<button t-ref="btn_next_tasks" class="btn btn-sm">Next</button>
</div>
</div>
<div class="chart-container">
<h4>Task Deadline</h4>
<canvas t-ref="chart_task_deadline"/>
</div>
</div>
<!-- ACTIVITIES & PRIORITY CHART -->
<div class="data-row">
<div class="table-container">
<h4>Activities</h4>
<table class="table table-striped">
<thead><tr><th>Task</th><th>Activity</th><th>Summary</th><th>Date</th><th>Action</th></tr></thead>
<tbody t-ref="activities_table_body"/>
</table>
<div class="pagination">
<button t-ref="btn_prev_activities" class="btn btn-sm">Previous</button>
<span t-ref="activities_pagination">Page 1 of 1</span>
<button t-ref="btn_next_activities" class="btn btn-sm">Next</button>
</div>
</div>
<div class="chart-container">
<h4>Priority Wise</h4>
<canvas t-ref="chart_priority_wise"/>
</div>
</div>
<div class="chart-container full-width">
<h4>Timesheet Hours</h4>
<canvas t-ref="chart_timesheet_hours"/>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- CLIENT ACTION (Replaces ir.ui.view) -->
<record id="action_project_dashboard" model="ir.actions.client">
<field name="name">Project Dashboard</field>
<field name="tag">project_dashboard_tag</field>
</record>
<!-- MENUS -->
<menuitem id="menu_project_dashboard_root"
name="Project Dashboard"
sequence="10"/>
<menuitem id="menu_project_dashboard_main"
name="Dashboard"
parent="menu_project_dashboard_root"
sequence="10"/>
<menuitem id="menu_project_dashboard_open"
name="Project Dashboard"
parent="menu_project_dashboard_main"
action="action_project_dashboard"
sequence="10"/>
</odoo>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="dashboard_report_template" name="Project Dashboard Report">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>Project Dashboard Report</h2>
<p>Generated: <t t-esc="datetime.datetime.now()"/></p>
<!-- KPIs -->
<div class="row mt-4">
<div class="col-4">
<h4>Total Projects</h4>
<p class="text-primary display-4" t-esc="kpis.get('total_projects', 0)"/>
</div>
<div class="col-4">
<h4>Active Tasks</h4>
<p class="text-success display-4" t-esc="kpis.get('active_tasks', 0)"/>
</div>
<div class="col-4">
<h4>Overdue Tasks</h4>
<p class="text-danger display-4" t-esc="kpis.get('overdue_tasks', 0)"/>
</div>
</div>
<!-- Tasks Table -->
<h3 class="mt-5">Tasks</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Task Name</th>
<th>Project</th>
<th>Deadline</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr>
<td t-esc="task.get('name')"/>
<td t-esc="task.get('project')"/>
<td t-esc="task.get('deadline')"/>
<td t-esc="task.get('status')"/>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</template>
</odoo>