first commit

This commit is contained in:
Suherdy Yacob 2026-02-05 18:45:49 +07:00
commit 916f428041
19 changed files with 921 additions and 0 deletions

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

26
__manifest__.py Normal file
View 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',
}

Binary file not shown.

1
controllers/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import kpi_portal

65
controllers/kpi_portal.py Normal file
View 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
View File

@ -0,0 +1,3 @@
from . import kpi_kpi
from . import kpi_kpi_line
from . import kpi_period_line

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

52
models/kpi_kpi.py Normal file
View 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
View 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 &rarr; %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
View 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 &rarr; %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)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_kpi_kpi_line_user kpi.kpi.line user model_kpi_kpi_line group_kpi_user 1 1 1 1
3 access_kpi_period_line_user kpi.period.line user model_kpi_period_line group_kpi_user 1 1 1 1
4 access_kpi_kpi_manager kpi.kpi manager model_kpi_kpi group_kpi_manager 1 1 1 1
5 access_kpi_kpi_line_manager kpi.kpi.line manager model_kpi_kpi_line group_kpi_manager 1 1 1 1
6 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
View 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
View 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
View 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>

View 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 &amp;&amp; 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
View 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 &amp; 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>