first push message
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
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>
|
||||
Reference in New Issue
Block a user