first commit
This commit is contained in:
commit
916f428041
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
26
__manifest__.py
Normal file
26
__manifest__.py
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'Employee KPI',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Human Resources',
|
||||
'summary': 'Manage Employee Key Performance Indicators',
|
||||
'description': """
|
||||
Employee KPI Module
|
||||
===================
|
||||
Allows employees and managers to track KPIs.
|
||||
- Create Master KPIs
|
||||
- Employee "Realization" input
|
||||
- Automated calculation based on polarization (Max, Min, Min-Max)
|
||||
""",
|
||||
'author': 'Antigravity',
|
||||
'depends': ['base', 'hr', 'portal', 'website'],
|
||||
'data': [
|
||||
'security/res_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/kpi_views.xml',
|
||||
'views/kpi_menus.xml',
|
||||
'views/kpi_portal_templates.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
controllers/__init__.py
Normal file
1
controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import kpi_portal
|
||||
65
controllers/kpi_portal.py
Normal file
65
controllers/kpi_portal.py
Normal file
@ -0,0 +1,65 @@
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
||||
|
||||
class KPIPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
if 'kpi_count' in counters:
|
||||
values['kpi_count'] = request.env['kpi.kpi'].search_count([
|
||||
('employee_id.user_id', '=', request.env.user.id)
|
||||
])
|
||||
return values
|
||||
|
||||
@http.route(['/my/kpi', '/my/kpi/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_kpis(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
|
||||
values = self._prepare_portal_layout_values()
|
||||
|
||||
Kpi = request.env['kpi.kpi']
|
||||
domain = [('employee_id.user_id', '=', request.env.user.id)]
|
||||
|
||||
kpi_count = Kpi.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url="/my/kpi",
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
|
||||
total=kpi_count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
kpis = Kpi.search(domain, limit=self._items_per_page, offset=pager['offset'])
|
||||
values.update({
|
||||
'kpis': kpis,
|
||||
'page_name': 'kpi',
|
||||
'default_url': '/my/kpi',
|
||||
'pager': pager,
|
||||
})
|
||||
return request.render("employee_kpi.portal_my_kpis", values)
|
||||
|
||||
@http.route(['/my/kpi/<model("kpi.kpi"):kpi>'], type='http', auth="user", website=True)
|
||||
def portal_my_kpi_detail(self, kpi, **kw):
|
||||
# Explicit check in case record rules are bypassed or misconfigured
|
||||
if kpi.employee_id.user_id != request.env.user:
|
||||
return request.redirect('/my/kpi')
|
||||
|
||||
return request.render("employee_kpi.portal_my_kpi", {
|
||||
'kpi': kpi,
|
||||
'page_name': 'kpi',
|
||||
})
|
||||
|
||||
@http.route(['/my/kpi/save_line'], type='json', auth="user")
|
||||
def portal_kpi_save_line(self, line_id, realization):
|
||||
line = request.env['kpi.kpi.line'].browse(line_id)
|
||||
if line.kpi_id.employee_id.user_id != request.env.user:
|
||||
return {'error': 'Access Denied'}
|
||||
|
||||
# Only allow edit if KPI is running? Or always?
|
||||
# Assuming always allow for now, or based on state logic if added later.
|
||||
line.write({'realization': float(realization)})
|
||||
return {
|
||||
'success': True,
|
||||
'new_score': line.score,
|
||||
'new_final_score': line.final_score,
|
||||
'kpi_total_score': line.kpi_id.total_score
|
||||
}
|
||||
3
models/__init__.py
Normal file
3
models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import kpi_kpi
|
||||
from . import kpi_kpi_line
|
||||
from . import kpi_period_line
|
||||
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/kpi_kpi.cpython-312.pyc
Normal file
BIN
models/__pycache__/kpi_kpi.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/kpi_kpi_line.cpython-312.pyc
Normal file
BIN
models/__pycache__/kpi_kpi_line.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/kpi_period_line.cpython-312.pyc
Normal file
BIN
models/__pycache__/kpi_period_line.cpython-312.pyc
Normal file
Binary file not shown.
52
models/kpi_kpi.py
Normal file
52
models/kpi_kpi.py
Normal file
@ -0,0 +1,52 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
class KpiKpi(models.Model):
|
||||
_name = 'kpi.kpi'
|
||||
_description = 'Employee KPI'
|
||||
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default='New')
|
||||
employee_id = fields.Many2one('hr.employee', string='Employee', required=True, tracking=True)
|
||||
manager_id = fields.Many2one('hr.employee', string='Manager', tracking=True)
|
||||
job_id = fields.Many2one('hr.job', string='Job Position', related='employee_id.job_id', store=True, readonly=True)
|
||||
department_id = fields.Many2one('hr.department', string='Department', related='employee_id.department_id', store=True, readonly=True)
|
||||
|
||||
period = fields.Selection([
|
||||
('2024', '2024'),
|
||||
('2025', '2025'),
|
||||
('2026', '2026'),
|
||||
('2027', '2027'),
|
||||
], string='Period (Year)', required=True, default=lambda self: str(fields.Date.today().year), tracking=True)
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('done', 'Done'),
|
||||
('cancel', 'Cancelled'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
line_ids = fields.One2many('kpi.kpi.line', 'kpi_id', string='KPI Lines')
|
||||
total_score = fields.Float(string='Total Score', compute='_compute_total_score', store=True)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
employee = self.env['hr.employee'].browse(vals.get('employee_id'))
|
||||
year = vals.get('period')
|
||||
vals['name'] = f"KPI - {employee.name} - {year}"
|
||||
return super(KpiKpi, self).create(vals_list)
|
||||
|
||||
@api.depends('line_ids.final_score')
|
||||
def _compute_total_score(self):
|
||||
for record in self:
|
||||
record.total_score = sum(line.final_score for line in record.line_ids)
|
||||
|
||||
def action_confirm(self):
|
||||
self.write({'state': 'confirmed'})
|
||||
|
||||
def action_done(self):
|
||||
self.write({'state': 'done'})
|
||||
|
||||
def action_draft(self):
|
||||
self.write({'state': 'draft'})
|
||||
272
models/kpi_kpi_line.py
Normal file
272
models/kpi_kpi_line.py
Normal file
@ -0,0 +1,272 @@
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from markupsafe import Markup
|
||||
|
||||
class KpiKpiLine(models.Model):
|
||||
_name = 'kpi.kpi.line'
|
||||
_description = 'KPI Line'
|
||||
|
||||
kpi_id = fields.Many2one('kpi.kpi', string='KPI Reference', ondelete='cascade')
|
||||
perspective = fields.Char(string='Perspective', required=True)
|
||||
code = fields.Char(string='Code')
|
||||
name = fields.Char(string='KPI Name', required=True)
|
||||
|
||||
kpi_type = fields.Selection([
|
||||
('num', '#'),
|
||||
('pct', '%'),
|
||||
('idr', 'IDR')
|
||||
], string='Type', default='num')
|
||||
|
||||
polarization = fields.Selection([
|
||||
('max', 'Maksimal'),
|
||||
('min', 'Minimal'),
|
||||
('range', 'Min-Max'),
|
||||
], string='Polarization', required=True, default='max')
|
||||
|
||||
uom = fields.Selection([
|
||||
('#', '#'),
|
||||
('%', '%'),
|
||||
('IDR', 'IDR'),
|
||||
('USD', 'USD'),
|
||||
], string='UoM', default='#')
|
||||
|
||||
weight = fields.Float(string='Weight (%)', digits=(16, 2))
|
||||
|
||||
period_line_ids = fields.One2many('kpi.period.line', 'line_id', string='Periods')
|
||||
periodicity = fields.Selection([
|
||||
('monthly', 'Monthly'),
|
||||
('quarterly', 'Quarterly'),
|
||||
('semesterly', 'Semesterly'),
|
||||
('yearly', 'Yearly'),
|
||||
], string='Periodicity', default='monthly')
|
||||
|
||||
target = fields.Float(string='Target (Full Year)', required=True, default=0.0)
|
||||
target_ytd = fields.Float(string='Target (YTD)', required=True, default=0.0)
|
||||
|
||||
# Thresholds for Min-Max / Range
|
||||
threshold_min = fields.Float(string='Threshold Min')
|
||||
target_min = fields.Float(string='Target Min')
|
||||
target_max = fields.Float(string='Target Max')
|
||||
threshold_max = fields.Float(string='Threshold Max')
|
||||
|
||||
realization = fields.Float(string='Realization', compute='_compute_realization', store=True, readonly=False, digits=(16, 2))
|
||||
|
||||
score = fields.Float(string='Score (%)', compute='_compute_score', store=True, digits=(16, 2))
|
||||
final_score = fields.Float(string='Final Score', compute='_compute_final_score', store=True, digits=(16, 4))
|
||||
|
||||
@api.depends('period_line_ids.realization')
|
||||
def _compute_realization(self):
|
||||
for line in self:
|
||||
if line.period_line_ids:
|
||||
line.realization = sum(p.realization for p in line.period_line_ids)
|
||||
else:
|
||||
# Fallback to manual entry if no periods generated (or keep existing value)
|
||||
# If we want to strictly enforce periodic entry, we would remove this else or make it 0.
|
||||
# For flexibility, let's allow manual if no periods exist.
|
||||
pass
|
||||
|
||||
def action_open_worksheet(self):
|
||||
self.ensure_one()
|
||||
if not isinstance(self.id, int):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError("Please save the KPI header first.")
|
||||
|
||||
if not self.period_line_ids:
|
||||
return self.action_generate_periods()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'KPI Worksheet',
|
||||
'res_model': 'kpi.kpi.line',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'view_id': self.env.ref('employee_kpi.view_kpi_kpi_line_form').id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_generate_periods(self):
|
||||
self.ensure_one()
|
||||
if not isinstance(self.id, int):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError("Please save the KPI header first before generating the worksheet.")
|
||||
|
||||
self.period_line_ids.unlink()
|
||||
|
||||
vals_list = []
|
||||
try:
|
||||
year = int(self.kpi_id.period)
|
||||
except (ValueError, TypeError):
|
||||
year = fields.Date.today().year
|
||||
|
||||
if self.periodicity == 'monthly':
|
||||
months = [
|
||||
('January', 1), ('February', 2), ('March', 3), ('April', 4),
|
||||
('May', 5), ('June', 6), ('July', 7), ('August', 8),
|
||||
('September', 9), ('October', 10), ('November', 11), ('December', 12)
|
||||
]
|
||||
seq = 1
|
||||
for name, month_idx in months:
|
||||
vals_list.append({
|
||||
'line_id': self.id,
|
||||
'name': name,
|
||||
'sequence': seq,
|
||||
'date_start': fields.Date.from_string(f'{year}-{month_idx:02d}-01'),
|
||||
# Simplified end date logic, improving could use calendar.monthrange
|
||||
'date_end': fields.Date.from_string(f'{year}-{month_idx:02d}-28'),
|
||||
})
|
||||
seq += 1
|
||||
|
||||
elif self.periodicity == 'quarterly':
|
||||
quarters = ['Q1', 'Q2', 'Q3', 'Q4']
|
||||
seq = 1
|
||||
for name in quarters:
|
||||
vals_list.append({
|
||||
'line_id': self.id,
|
||||
'name': name,
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 1
|
||||
|
||||
elif self.periodicity == 'semesterly':
|
||||
semesters = ['Semester 1', 'Semester 2']
|
||||
seq = 1
|
||||
for name in semesters:
|
||||
vals_list.append({
|
||||
'line_id': self.id,
|
||||
'name': name,
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 1
|
||||
|
||||
elif self.periodicity == 'yearly':
|
||||
vals_list.append({
|
||||
'line_id': self.id,
|
||||
'name': str(year),
|
||||
'sequence': 1,
|
||||
})
|
||||
|
||||
if vals_list:
|
||||
self.env['kpi.period.line'].create(vals_list)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'KPI Worksheet',
|
||||
'res_model': 'kpi.kpi.line',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'view_id': self.env.ref('employee_kpi.view_kpi_kpi_line_form').id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_save_worksheet(self):
|
||||
""" dummy action to trigger save """
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
@api.depends('polarization', 'realization', 'target_ytd', 'target_min', 'target_max', 'threshold_min', 'threshold_max')
|
||||
def _compute_score(self):
|
||||
for line in self:
|
||||
score = 0.0
|
||||
if line.polarization == 'max':
|
||||
# Higher is better
|
||||
# Formula assumption based on "Max": (Realization / Target) * 100 ?
|
||||
# Or simplistic: If Realization >= Target then 100% else relative?
|
||||
# Usually: (Realization / Target) * 100
|
||||
if line.target_ytd:
|
||||
score = (line.realization / line.target_ytd) * 100
|
||||
# Cap at some point? Usually cap at 100-120% depending on policy, but letting it float for now.
|
||||
else:
|
||||
score = 100.0 if line.realization > 0 else 0.0
|
||||
|
||||
elif line.polarization == 'min':
|
||||
# Lower is better.
|
||||
# Formula: (Target / Realization) * 100 ? Or specialized inverse formula?
|
||||
# Common inverse: 2 - (Realization / Target) * 100 ?
|
||||
# Simple assumption: (Target / Realization) * 100
|
||||
if line.realization and line.realization != 0:
|
||||
score = (line.target_ytd / line.realization) * 100
|
||||
else:
|
||||
score = 100.0 if line.target_ytd == 0 else 0.0
|
||||
|
||||
elif line.polarization == 'range': # "Min-Max"
|
||||
# Logic based on bands:
|
||||
# Realization between Target Min and Target Max => 100% (or 110% as per some KPI structures)
|
||||
# Realization between Threshold Min and Target Min => Pro-rated?
|
||||
# Realization between Target Max and Threshold Max => Pro-rated?
|
||||
# Outside Threshold => 0
|
||||
|
||||
real = line.realization
|
||||
|
||||
# Perfect Range
|
||||
if line.target_min <= real <= line.target_max:
|
||||
score = 100.0
|
||||
else:
|
||||
# Check lower band
|
||||
if line.threshold_min <= real < line.target_min:
|
||||
# Linear interpolation from Threshold(0%) to TargetMin(100%)
|
||||
if (line.target_min - line.threshold_min) != 0:
|
||||
score = ((real - line.threshold_min) / (line.target_min - line.threshold_min)) * 100
|
||||
else:
|
||||
score = 0.0
|
||||
|
||||
# Check upper band
|
||||
elif line.target_max < real <= line.threshold_max:
|
||||
# Linear interpolation from TargetMax(100%) to ThresholdMax(0%)
|
||||
if (line.threshold_max - line.target_max) != 0:
|
||||
score = ((line.threshold_max - real) / (line.threshold_max - line.target_max)) * 100
|
||||
else:
|
||||
score = 0.0
|
||||
|
||||
else:
|
||||
score = 0.0
|
||||
|
||||
line.score = score
|
||||
|
||||
@api.depends('score', 'weight')
|
||||
def _compute_final_score(self):
|
||||
for line in self:
|
||||
# Weight is likely in percentage (e.g. 10 or 0.1). Excel showed 0.05 (5%).
|
||||
# If weight is 0.05, and Score is 100. Final Score should be 5.
|
||||
# So: Score * Weight if Weight is 0.xx, or Score * (Weight/100) if Weight is xx.
|
||||
# User excel showed: Weight 0.05. Score 100. Final Score = 0.05 * 100 = 5? Or 0.05 * 100 = 5.
|
||||
# Let's assume Weight is entered as decimal (0.05) as per Excel "0.05".
|
||||
line.final_score = line.score * line.weight
|
||||
|
||||
def write(self, vals):
|
||||
# Track changes for visualization in chatter
|
||||
tracked_fields = {'realization', 'target', 'target_ytd', 'threshold_min', 'threshold_max', 'target_min', 'target_max'}
|
||||
fields_to_check = tracked_fields.intersection(vals.keys())
|
||||
|
||||
if fields_to_check:
|
||||
# Store old values to compare
|
||||
old_values = {rec.id: {f: rec[f] for f in fields_to_check} for rec in self}
|
||||
|
||||
# Perform write
|
||||
result = super(KpiKpiLine, self).write(vals)
|
||||
|
||||
# Check and log
|
||||
for rec in self:
|
||||
for field in fields_to_check:
|
||||
old_val = old_values[rec.id].get(field)
|
||||
new_val = vals.get(field)
|
||||
|
||||
# Handle comparisons safely (simplified for float as most are float)
|
||||
changed = False
|
||||
if self._fields[field].type == 'float':
|
||||
if float(old_val or 0.0) != float(new_val or 0.0):
|
||||
changed = True
|
||||
else:
|
||||
if old_val != new_val:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
field_label = self._fields[field].string
|
||||
body = Markup("KPI Line <b>%s</b> updated %s: %s → %s") % (
|
||||
rec.name,
|
||||
field_label,
|
||||
old_val,
|
||||
new_val
|
||||
)
|
||||
rec.kpi_id.message_post(body=body)
|
||||
return result
|
||||
|
||||
return super(KpiKpiLine, self).write(vals)
|
||||
71
models/kpi_period_line.py
Normal file
71
models/kpi_period_line.py
Normal file
@ -0,0 +1,71 @@
|
||||
from odoo import models, fields, api
|
||||
import logging
|
||||
from markupsafe import Markup
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class KpiPeriodLine(models.Model):
|
||||
_name = 'kpi.period.line'
|
||||
_description = 'KPI Period Line (Worksheet)'
|
||||
_order = 'sequence, id'
|
||||
|
||||
line_id = fields.Many2one('kpi.kpi.line', string='KPI Line', required=True, ondelete='cascade')
|
||||
name = fields.Char(string='Period', required=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
date_start = fields.Date(string='Start Date')
|
||||
date_end = fields.Date(string='End Date')
|
||||
|
||||
realization = fields.Float(string='Realization', digits=(16, 2))
|
||||
target = fields.Float(string='Target (Period)', digits=(16, 2))
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Open'),
|
||||
('closed', 'Closed'),
|
||||
], string='Status', default='draft')
|
||||
|
||||
def write(self, vals):
|
||||
# Track changes for visualization in chatter of the parent KPI
|
||||
tracked_fields = {'realization', 'target', 'state'}
|
||||
fields_to_check = tracked_fields.intersection(vals.keys())
|
||||
|
||||
if fields_to_check:
|
||||
# Store old values to compare
|
||||
old_values = {rec.id: {f: rec[f] for f in fields_to_check} for rec in self}
|
||||
|
||||
# Perform write
|
||||
result = super(KpiPeriodLine, self).write(vals)
|
||||
|
||||
# Check and log
|
||||
for rec in self:
|
||||
for field in fields_to_check:
|
||||
old_val = old_values[rec.id].get(field)
|
||||
new_val = vals.get(field)
|
||||
|
||||
# Handle comparisons safely
|
||||
changed = False
|
||||
if self._fields[field].type == 'float':
|
||||
if float(old_val or 0.0) != float(new_val or 0.0):
|
||||
changed = True
|
||||
else:
|
||||
if old_val != new_val:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
parent_kpi = rec.line_id.kpi_id
|
||||
if parent_kpi:
|
||||
field_label = self._fields[field].string
|
||||
# Format values for display (mapped selection, etc) not fully implemented here but using raw strings for now is usually fine for floats/simple selects
|
||||
# For State showing label would be better but raw value is understandable for "draft/closed"
|
||||
|
||||
body = Markup("KPI Line <b>%s</b> - Period <b>%s</b> updated %s: %s → %s") % (
|
||||
rec.line_id.name,
|
||||
rec.name,
|
||||
field_label,
|
||||
old_val,
|
||||
new_val
|
||||
)
|
||||
parent_kpi.message_post(body=body)
|
||||
return result
|
||||
|
||||
return super(KpiPeriodLine, self).write(vals)
|
||||
6
security/ir.model.access.csv
Normal file
6
security/ir.model.access.csv
Normal file
@ -0,0 +1,6 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_kpi_kpi_line_user,kpi.kpi.line user,model_kpi_kpi_line,group_kpi_user,1,1,1,1
|
||||
access_kpi_period_line_user,kpi.period.line user,model_kpi_period_line,group_kpi_user,1,1,1,1
|
||||
access_kpi_kpi_manager,kpi.kpi manager,model_kpi_kpi,group_kpi_manager,1,1,1,1
|
||||
access_kpi_kpi_line_manager,kpi.kpi.line manager,model_kpi_kpi_line,group_kpi_manager,1,1,1,1
|
||||
access_kpi_period_line_manager,kpi.period.line manager,model_kpi_period_line,group_kpi_manager,1,1,1,1
|
||||
|
20
security/ir_rule.xml
Normal file
20
security/ir_rule.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- User can only see their own KPI -->
|
||||
<record id="rule_kpi_kpi_user_own" model="ir.rule">
|
||||
<field name="name">See Own KPI</field>
|
||||
<field name="model_id" ref="model_kpi_kpi"/>
|
||||
<field name="groups" eval="[(4, ref('group_kpi_user'))]"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager see all -->
|
||||
<record id="rule_kpi_kpi_manager_all" model="ir.rule">
|
||||
<field name="name">Manager See All KPI</field>
|
||||
<field name="model_id" ref="model_kpi_kpi"/>
|
||||
<field name="groups" eval="[(4, ref('group_kpi_manager'))]"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
22
security/res_groups.xml
Normal file
22
security/res_groups.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Define Privilege for KPI -->
|
||||
<record id="privilege_employee_kpi" model="res.groups.privilege">
|
||||
<field name="name">Employee KPI</field>
|
||||
<field name="category_id" ref="base.module_category_human_resources_employees"/>
|
||||
</record>
|
||||
|
||||
<record id="group_kpi_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="privilege_id" ref="privilege_employee_kpi"/>
|
||||
</record>
|
||||
|
||||
<record id="group_kpi_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="privilege_id" ref="privilege_employee_kpi"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_kpi_user'))]"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
34
views/kpi_menus.xml
Normal file
34
views/kpi_menus.xml
Normal file
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<menuitem id="menu_employee_kpi_root" name="KPI" web_icon="hr,static/description/icon.png" groups="group_kpi_user"/>
|
||||
|
||||
<record id="action_kpi_kpi_my" model="ir.actions.act_window">
|
||||
<field name="name">My KPIs</field>
|
||||
<field name="res_model">kpi.kpi</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('employee_id.user_id', '=', uid)]</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_kpi_kpi_all" model="ir.actions.act_window">
|
||||
<field name="name">All KPIs</field>
|
||||
<field name="res_model">kpi.kpi</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_kpi_kpi_my"
|
||||
name="My KPI"
|
||||
parent="menu_employee_kpi_root"
|
||||
action="action_kpi_kpi_my"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_kpi_kpi_all"
|
||||
name="All KPIs"
|
||||
parent="menu_employee_kpi_root"
|
||||
action="action_kpi_kpi_all"
|
||||
groups="group_kpi_manager"
|
||||
sequence="20"/>
|
||||
</data>
|
||||
</odoo>
|
||||
180
views/kpi_portal_templates.xml
Normal file
180
views/kpi_portal_templates.xml
Normal file
@ -0,0 +1,180 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- Portal Home Menu Item -->
|
||||
<template id="portal_my_home_menu_kpi" name="Portal layout : KPI menu entries" inherit_id="portal.portal_breadcrumbs" priority="30">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li t-if="page_name == 'kpi' or kpi" class="breadcrumb-item col-12 col-md-auto" t-att-class="{'active': not kpi}">
|
||||
<a t-if="kpi" t-attf-href="/my/kpi">KPIs</a>
|
||||
<span t-else="">KPIs</span>
|
||||
</li>
|
||||
<li t-if="kpi" class="breadcrumb-item active">
|
||||
<span t-field="kpi.name"/>
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="portal_my_home_kpi" name="Show KPI" inherit_id="portal.portal_my_home" priority="30">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">KPIs</t>
|
||||
<t t-set="url" t-value="'/my/kpi'"/>
|
||||
<t t-set="placeholder_count" t-value="'kpi_count'"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- KPI List View -->
|
||||
<template id="portal_my_kpis" name="My KPIs">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<t t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Reference</th>
|
||||
<th>Period</th>
|
||||
<th>Status</th>
|
||||
<th>Total Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="kpis" t-as="kpi">
|
||||
<tr>
|
||||
<td><a t-attf-href="/my/kpi/#{kpi.id}"><t t-esc="kpi.name"/></a></td>
|
||||
<td><span t-field="kpi.period"/></td>
|
||||
<td><span t-field="kpi.state"/></td>
|
||||
<td><span t-field="kpi.total_score"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- KPI Detail View -->
|
||||
<template id="portal_my_kpi" name="My KPI Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="o_portal_fullwidth_alert" groups="employee_kpi.group_kpi_manager">
|
||||
<t t-call="portal.portal_back_in_edit_mode">
|
||||
<t t-set="backend_url" t-value="'/nav/to/record/kpi.kpi/' + str(kpi.id)"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<h4>
|
||||
<span t-field="kpi.name"/>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-4 text-end">
|
||||
<span t-field="kpi.state" class="badge bg-secondary"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<strong>Employee:</strong> <span t-field="kpi.employee_id"/><br/>
|
||||
<strong>Department:</strong> <span t-field="kpi.department_id"/><br/>
|
||||
<strong>Job:</strong> <span t-field="kpi.job_id"/>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<strong>Period:</strong> <span t-field="kpi.period"/><br/>
|
||||
<strong>Total Score:</strong> <span t-field="kpi.total_score" id="kpi_total_score"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>KPI Details</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>KPI</th>
|
||||
<th>Weight</th>
|
||||
<th>Target (YTD)</th>
|
||||
<th>Realization</th>
|
||||
<th>Score</th>
|
||||
<th>Final Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="kpi.line_ids" t-as="line">
|
||||
<td><span t-field="line.code"/></td>
|
||||
<td>
|
||||
<span t-field="line.name"/>
|
||||
<br/>
|
||||
<small class="text-muted"><span t-field="line.perspective"/></small>
|
||||
</td>
|
||||
<td><span t-field="line.weight"/></td>
|
||||
<td><span t-field="line.target_ytd"/></td>
|
||||
<td>
|
||||
<input type="number" step="any" class="form-control form-control-sm kpi-realization-input"
|
||||
t-att-data-line-id="line.id"
|
||||
t-att-value="line.realization"/>
|
||||
</td>
|
||||
<td><span t-field="line.score" t-att-id="'score_' + str(line.id)"/></td>
|
||||
<td><span t-field="line.final_score" t-att-id="'final_score_' + str(line.id)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Inline script to handle updates -->
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var inputs = document.querySelectorAll('.kpi-realization-input');
|
||||
inputs.forEach(function(input) {
|
||||
input.addEventListener('change', function() {
|
||||
var lineId = this.getAttribute('data-line-id');
|
||||
var value = this.value;
|
||||
|
||||
odoo.define('employee_kpi.update', [], function(require){
|
||||
var ajax = require('web.ajax'); // Odoo 16/17 style, might differ in 19 but 'jsonRpc' is standard-ish
|
||||
// In Odoo 19, 'jsonRpc' might be direct fetch or via core `rpc` service if available.
|
||||
// Using fetch for generic web compatibility if 'odoo' object available.
|
||||
|
||||
// Let's use standard fetch to the controller route which is safer across versions if simple JSON
|
||||
fetch('/my/kpi/save_line', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
params: {
|
||||
line_id: parseInt(lineId),
|
||||
realization: value
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.result && data.result.success) {
|
||||
// Update DOM
|
||||
document.getElementById('score_' + lineId).innerText = data.result.new_score.toFixed(2);
|
||||
document.getElementById('final_score_' + lineId).innerText = data.result.new_final_score.toFixed(4);
|
||||
document.getElementById('kpi_total_score').innerText = data.result.kpi_total_score.toFixed(2);
|
||||
|
||||
// Optional: Green flash effect
|
||||
input.classList.add('is-valid');
|
||||
setTimeout(() => input.classList.remove('is-valid'), 1000);
|
||||
} else {
|
||||
alert('Error saving data');
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</t>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
168
views/kpi_views.xml
Normal file
168
views/kpi_views.xml
Normal file
@ -0,0 +1,168 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- KPI Line Form View (Detailed Worksheet) -->
|
||||
<record id="view_kpi_kpi_line_form" model="ir.ui.view">
|
||||
<field name="name">kpi.kpi.line.form</field>
|
||||
<field name="model">kpi.kpi.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="KPI Worksheet">
|
||||
<header>
|
||||
<button name="action_generate_periods" string="Regenerate Periods" type="object" class="oe_highlight" confirm="This will reset all data in the worksheet. Are you sure?"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
<h3><field name="code" readonly="1"/></h3>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Configuration">
|
||||
<field name="perspective"/>
|
||||
<field name="uom"/>
|
||||
<field name="weight"/>
|
||||
<field name="periodicity"/>
|
||||
<field name="polarization"/>
|
||||
</group>
|
||||
<group string="Targets & Limits">
|
||||
<field name="target" string="Target (Full Year)"/>
|
||||
<field name="target_ytd"/>
|
||||
|
||||
<field name="threshold_min"/>
|
||||
<field name="threshold_max" invisible="polarization != 'range'"/>
|
||||
<field name="target_min" invisible="polarization != 'range'"/>
|
||||
<field name="target_max" invisible="polarization != 'range'"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Realization Worksheet">
|
||||
<field name="period_line_ids">
|
||||
<list string="Periods" editable="bottom" create="false" delete="false">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="date_start" optional="hide"/>
|
||||
<field name="date_end" optional="hide"/>
|
||||
<field name="date_end" optional="hide"/>
|
||||
<field name="target" optional="hide" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="realization" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="state" invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
<group class="oe_right">
|
||||
<field name="realization" string="Total Realization"/>
|
||||
<field name="score"/>
|
||||
<field name="final_score"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string="Save" name="action_save_worksheet" type="object" class="oe_highlight"/>
|
||||
<button string="Discard" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- KPI Line Tree view for embedding -->
|
||||
<record id="view_kpi_kpi_line_tree" model="ir.ui.view">
|
||||
<field name="name">kpi.kpi.line.list</field>
|
||||
<field name="model">kpi.kpi.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="KPI Lines" editable="bottom">
|
||||
<field name="perspective"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="polarization" optional="show"/>
|
||||
<field name="uom" optional="hide"/>
|
||||
<field name="weight" sum="Total Weight" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="target_ytd" string="Target" widget="float" options="{'digits': [16, 2]}"/>
|
||||
|
||||
<!-- Optional detail fields for Min-Max -->
|
||||
<field name="threshold_min" optional="hide" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="threshold_max" optional="hide" invisible="polarization != 'range'" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="target_min" optional="hide" invisible="polarization != 'range'" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="target_max" optional="hide" invisible="polarization != 'range'" widget="float" options="{'digits': [16, 2]}"/>
|
||||
|
||||
<field name="realization" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="score" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="final_score" sum="Total Score" widget="float" options="{'digits': [16, 4]}"/>
|
||||
<button name="action_open_worksheet" type="object" icon="fa-cogs" title="Open Worksheet"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- KPI Header Form View -->
|
||||
<record id="view_kpi_kpi_form" model="ir.ui.view">
|
||||
<field name="name">kpi.kpi.form</field>
|
||||
<field name="model">kpi.kpi</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="KPI">
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm" type="object" invisible="state != 'draft'" class="oe_highlight" groups="employee_kpi.group_kpi_manager"/>
|
||||
<button name="action_done" string="Mark as Done" type="object" invisible="state != 'confirmed'" class="oe_highlight" groups="employee_kpi.group_kpi_manager"/>
|
||||
<button name="action_draft" string="Reset to Draft" type="object" invisible="state == 'draft'" groups="employee_kpi.group_kpi_manager"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id"/>
|
||||
<field name="job_id"/>
|
||||
<field name="department_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="period"/>
|
||||
<field name="manager_id"/>
|
||||
<field name="total_score" widget="progressbar"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="KPI Details">
|
||||
<field name="line_ids">
|
||||
<list string="KPI Lines" editable="bottom">
|
||||
<field name="perspective"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="polarization" optional="show"/>
|
||||
<field name="uom" optional="hide"/>
|
||||
<field name="weight" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="target_ytd" string="Target" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<!-- Threshold fields for Min-Max -->
|
||||
<field name="threshold_min" optional="hide" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="target_min" optional="hide" invisible="polarization != 'range'" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="target_max" optional="hide" invisible="polarization != 'range'" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="threshold_max" optional="hide" invisible="polarization != 'range'" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="realization" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="score" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<field name="final_score" widget="float" options="{'digits': [16, 2]}"/>
|
||||
<button name="action_open_worksheet" type="object" icon="fa-cogs" title="Open Worksheet"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- KPI Header Tree View -->
|
||||
<record id="view_kpi_kpi_tree" model="ir.ui.view">
|
||||
<field name="name">kpi.kpi.list</field>
|
||||
<field name="model">kpi.kpi</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Employee KPIs">
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="period"/>
|
||||
<field name="department_id"/>
|
||||
<field name="total_score" widget="progressbar"/>
|
||||
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'done'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
Loading…
Reference in New Issue
Block a user