From 916f4280415ea4633d29b9a18683d0eb1bf08ff1 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 5 Feb 2026 18:45:49 +0700 Subject: [PATCH] first commit --- __init__.py | 1 + __manifest__.py | 26 ++ __pycache__/__init__.cpython-312.pyc | Bin 0 -> 200 bytes controllers/__init__.py | 1 + controllers/kpi_portal.py | 65 +++++ models/__init__.py | 3 + models/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 283 bytes models/__pycache__/kpi_kpi.cpython-312.pyc | Bin 0 -> 4053 bytes .../__pycache__/kpi_kpi_line.cpython-312.pyc | Bin 0 -> 10819 bytes .../kpi_period_line.cpython-312.pyc | Bin 0 -> 3297 bytes models/kpi_kpi.py | 52 ++++ models/kpi_kpi_line.py | 272 ++++++++++++++++++ models/kpi_period_line.py | 71 +++++ security/ir.model.access.csv | 6 + security/ir_rule.xml | 20 ++ security/res_groups.xml | 22 ++ views/kpi_menus.xml | 34 +++ views/kpi_portal_templates.xml | 180 ++++++++++++ views/kpi_views.xml | 168 +++++++++++ 19 files changed, 921 insertions(+) create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 controllers/__init__.py create mode 100644 controllers/kpi_portal.py create mode 100644 models/__init__.py create mode 100644 models/__pycache__/__init__.cpython-312.pyc create mode 100644 models/__pycache__/kpi_kpi.cpython-312.pyc create mode 100644 models/__pycache__/kpi_kpi_line.cpython-312.pyc create mode 100644 models/__pycache__/kpi_period_line.cpython-312.pyc create mode 100644 models/kpi_kpi.py create mode 100644 models/kpi_kpi_line.py create mode 100644 models/kpi_period_line.py create mode 100644 security/ir.model.access.csv create mode 100644 security/ir_rule.xml create mode 100644 security/res_groups.xml create mode 100644 views/kpi_menus.xml create mode 100644 views/kpi_portal_templates.xml create mode 100644 views/kpi_views.xml diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..0fd2a14 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5de2fcaf181dd260409cf176b704b9dc1026153 GIT binary patch literal 200 zcmX@j%ge<81m9v>GDU&(V-N=hn4pZ$0zk%eh7^Vr#vF!R#wbQchDs()=9i2>VNJ$c zY`OU7EiTE=O-xD2&nwnX%`M2uuS`vi&o0Q+kB`sH%PfhH*DI*}#bE=qpfo4du80F@ WD#+4e5aR'], 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/'], 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 + } diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..2bc83be --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import kpi_kpi +from . import kpi_kpi_line +from . import kpi_period_line diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c88038198458fd68c9b011e304e8827297066ac2 GIT binary patch literal 283 zcmXw!u}TCn5Qb+qdw7Va)ir{>+ZZeb@d-q&w+V6?*l4n|B)dg=-@#|_S$u(Bm3{uONH}@#_cQOA}9bkJY@D3I%dI;De7OsS=w%W52R;@zF zbu;@xX>V|S^g1{rNATo;#=D-xpA|n)bZqz0LsPFIDtQ;{A~TT>*owmLUUwWwr>Uc8 zvwq5B$Nh>eX|~MQH+nUtk;AjK+$T+8@SKUtU>&5(CvA-D-Du3!kazQ22`5i?vHZJi VnodQ26(Vk6xqKX%bOtK*Pj$l1yTI%$;$F zz1gn2O0|hZMX1^))v9W=PeGw7kLgocX{AN#i_-+f8dWM%rK)|)5?XC}>bc_|5)ydY ztN6_Q`R=*r-t*maZvNu&I1!Y84o|7W0z&_wjY8q_mCYYQWe#D4nFxw9Atq`I*`oH4 zok2F#g|KZ3VY|YN!u;3El0DZG;*zY${hKyKyU9m2tVDE^A5xVF)=j${SHoMUmgF0o zO*Vu+gS8bDVlWEXFcY$4TZkQo6~ACYoDC`b=$@@pb9KyO4)gFUc_=L9_Yo2-bonl5 z2X+FMd+1tXbE6^WTd;0GdWt)9y%D(wdvU3yx2$M``;F*wT!AZJLwhWA?M{nf|5aZw zxEk;^aQfWCy~P#0Z<(_X=IqBlz}FUcT591B?py;Oz;(F(p})A(atqnC3$g(p1f7S9 z$V#PZ^vF)!G#X-XU`klZ4~1$#W7X)fok-lc#N>K$(-bP=_u=L#6xxqlK>Dx#|NahfxqfZ%9h!P7c1D&S5N4X<%Ca)VrjSZUuQEn$xqF|E^Yw;v) z%UFv=l4c23hU7%Vc3?hfesJ@cRa!ejph-k|Vm%(s~ z2MwdzV9I?_8}#>Tx@xFeELC1mDyf(b!$)MTS!M}QBC-KSak`-qg?OkI4`{(kTqZ_T zi5aG|y;x7}+p6jn#?mO-Ot$S<+i_A2O7e{pq~=xQB-#6_@jbH7TB}zfs)qe7KT%{N zhVxE@5rpW~qTyf@gP21`am;{Ao-3pI>sM&g?`RWLAB;AF5jX^d#GFvO~$hBfWo z)4^~8jx{P{aI_vQ9vx6En2TsZs7jQ5@#IV4Ohk?jV)7;DAW+`17F!4nbc7N^WWgUCCi0MBa_#g3 zZAVy(4XGpw-glfYRH@&xKzqb_d|aX z71Z!h19qewaHIT}php)Oi+U;7fChKV-ya6vnUzvli^da%A{n$-(rIsAw^@=OBbBgV zxafRn@bI%J;sl5MnwdkBOgp+chS0~hN!w_VFo4Pz%wXG6UIVs`wZOqLclS-A(XCnw zSUY9tXd$Z=RKWKQy4gYuDtte-zX8b=5iI5>?W1%bua(acE}3LMN55kyA&Z@X(YsL= z8O5AwSb3OEnnb_wfn*9gA+`xUCL5EZ3URK zN#TgB>yo6?J^E=$lY>wtr$MAVd8+5M(kc4SL42D=&ngc*N37!q%yfI!TXAn?H*tSr zL0GL3XW48+^P=!a&jZiGCy&C*f#vE<{Rgw`?A2eoHym`47fw`K^{cD%11nx}QC#&N z&j`m!fLd5dX$%b3TZhzGftlOl6eWm7!U3oN1rbp25KxeL6|pcg1rFMcWbMUdRdC-V zBNfSbw~<9jh^0N-7No=P!Ef_8h;GDKi{31yOTr@1_j>saGsiIKZaG!<2m2j{0AG;E zn652bf~#(8J!Z0WyE3i769taxT@H_ttKg6{3|E1_qpKE#lt+EjmpG z|Dq24YoKpAurRRRc;ac}iIqU#|8yE;Kds)&zzff=C!(fMIBL_iH44~rRO5*PK>=^I zjRZkM%J&wl;DRIlFbE)2_5SCGar86OoquZxfB}!2z7BpFTnqHAHuOz*K6Tf9W2eKG z+qa!JwbODiRYV>9rpTfe?;u0nX78bC6>bY@hKZ0nxI09JmLFQV3}T(IxraU@v>2SK z6^~O@`3-IFK1yfNRFFnl!F|s;EEd6n&qVcKFWLYZK znSvzIKq^V5Q<5NQCnB`&mZaMWIg%gYEC_UBMGq4-PMrx8)fhYrR3rbEI6oqjTsRfr z<(6!NlOogr(;3Y@;GQ7n!SdgUz-nZlKr(gt&rq=TyAIwP8F zkWM;IBa&HeJu#$QWR;@uz*Mc6OW&JuC5CnD_K-_-LJt+abe1O{up#9~Bnfi$S?IOg z|A9qTD+^eH-U$5)3cCC3Yr9aG;*TE)^+&{7q&7AGeT$k3$Z>77R zal$(1Tj6}0Hl91NX-9nR2CZ$BBEBr$|Jn7QUC)#?uW~Kl*lb)sLjmH3Redfn+&j;C zhHLsSh-Qm<)(Q8Byvfp+B%x<*@hIn7?lS#<*up$OIXq3N@Py1U^c+#3NuNJDjr1FK chGG7W0xKx+f_;~1hI73!bK^eK-dd*e7_yZQPpVRJDyc0s;FY$^n^2onCC(SvPG#0#Ip^wu z&q>{?1^~Qt({=;D+bV_fXV7VI%n{5|!uQg32t#QY`JI zd~`4Ell97c@?N=*>1Aky%e)GovR6s!a<9s#?p4#2j5B7XW@CTa-rKNU&{R<%PAd)(6F@704ra)miXL*iyC(Rw239rdNz# z4&zs_wzTohJC45#=2Z#vO42RZ5wj`-vv5bu>a^V|+7YWJ4a>44mOTy2x+7L?8dmX+ zSaqxe=CB)9y#9XTO!k)Sh`op13)t2N4XHgZ-4VBu-3Pe)({RfGw|uZI%T6{WC7C^N zgSwwU=(VxUH>loS>_I3i+1H?~+L1yF+sYndUuO@qN7y&+tKuZswlr$hJN7-w9!p9a zdpxa2%@ox~vv1x|_u8kZF}k<5i*lThv}cDs@Hyx4b1(Hn!=48GdZ86kpS^V2hVYOo z0S_4vbeZepc+T(UB$eAGxLqvgkV#4*$b0<#k~-iAox}w>Nq33kg<;Me^t{J)J7l~8 zCLyVL?%fd&&#@A70+g47CgAu`7hFEhAqQG=|A_A;U4^w_ckm@$gEbG!OY+n0mnEiq ze3;`=NhXP^Sgy}C;tj?-`&?s^`n+pM@c3L_?CJCPCDnP4Khak3E`LAF2_SpV1Frs3 z!0X~YA)+n$Vx%i4T=A&Rcq;-J7|Mh#ETCS1bu6G>gmo-%tSepZybbD53!Ep|A#D%m z=^qH%>S`Pg*;AzX6t!WJc_r)V_XLHPMqFc2YkaLLC(oBFzrm3 zQ;NE^|5g)2je|5@*v1MX9Dwh0ocdXcChS}T;oFIs@6)q1P2I{zVn33P()Z{K;56vc zrV3&sYuG2z!vYew*ThMqy-t{kBUG_TR<>by{H5kdKj-JhhWWNo)7g|-YDjHr8aE9`yaBh% zE3`GF`n&;b31%q%mHKj$TCsO6=PkVZ>BJ8wM0?k-1l-CG3N5rlE*Ud6RFVrLJ|4T@ zlSz!gdHW;=H#;#={usRBaWz8~zlN(rfF!g+F-fhPEzc;GreLZ)W;8{ORV&7-CslS} zWtok(T^8Feui3j+tGcF{$GNr*EtOZWX$0)&(3je_lWG7Ep|XIep->H`iyWfEle953 zwIx!3iF3|)g32TyX(NOjmIkks@hK)i0%RbDtUM`dAJ9N0i~lF&0^FFSwDp_hqYJ!Q zR>3N>^T4VAj*2kj88l@8pvkI}ykwgVt6{YttH6%wF7POo_h?CTMc{bi^oDj_@^UVL zvk9*EI9qUlv!V4G0NugywmuIp1RGu!c=!evm?)4?{B@3c{A^(KC9!io0e(mr;J6^K z1JV-H7vOy_tJ3AT;XMyGN+dWb#ZE*51ICTsQ%Ot^ES1Fg!LCUfP?1g|_L35-pw~5| zH^L+psRB$HBx={8m9(5+7~wf*z|ToqkKpkOK^NLw8DPmh{-9*UQ`W$Zxw&D&KYs?e z0&MwB)SHMIlpr>xdI_un&4-} zXh%XJC?=_xT7TPk)3~O#J~3M7s_)d!*Z$#wHDm2B3{5{bl}5^rt(uNU49C}#$0BC7 zM9up|^S)JcQ^e4;p`;4-#LQJOYi+Esc(#A0KW43n6_w16&5X^4XTp){)2l^iqDAM$ zqVuuBl1-yJSGz%}HCp~848QH{pvytd&L?mZ{=b}^SDhQ0>Zc}T8Y-CZ_$WN@*-lEf z7%`z2Cgjk^1~boiSY`+;JO4h!Mv71935HcpD8dYwD1oBhfx|PQoKS^jgH;*bpqc#? zhm~PPFco_ZqT`hD4Ax`-pc$5bM8WxosPH3()%*dIJl+38O4=<-tnOn)k}6GwK?{XI z4ki@0Mi|Qf7dxzto~%t%JZ_v&leJO9+SqYjzV529IUlDq=r#`OfP=9eI55UJU~}2L ze%XYEaG?1=;h<#)4)O^Hbzf(;!P?{whf_0Vl^OGw@#QcgdX*UuYeMIGus#C}&9H21 z2U#;)z!n09EY^%I%1(K3?^kKEmL$!rl&owqT9PU%h%N+qf`!RipRAhV_keYMwY--l z@wa}H7ICnA3zn=+$gfh<^sU$^H3qd&+EEg>V*F{q2p#`**2{LC18c+C5?UUjFJk0U zO1pu0@(w*XZmKgbKLiHjPfHEkb$pojczN_@pd~-= z8h?tuE!3GaBYy5F`VjGkcl;@;DrhK=kMx7T_Z0mVXllB^EOEZ;9RC!vOaM_`bO!_2 z#w;4N)fWQq#SzfFBA}gf#{uYZ@NOt1daoqk*Tjb8d-v^!qNx>P0yvWte;MDZ1&Ag| zB&vmE7v}^M$p>SZtYR<%l2L%OOdLUIQBa zxOtYekhL4(i0>k4Fv=xW9_3zQ+F^o{A{by@;}V16o1}|(BSD*_bPa>WW+h!8AMn9K zkYHR6zdU9nC8ZmDXNUxy&~RwCY(F?5;6#H14wzE}k;IqaDZtXk7g<9fz$b*&i9l13 zG|A9X5O6zfsVD9%UjtaE@~b{WrbE&LcmhT@u z)d?QA$vkx;R^@m`X$;mG<1{nviRD_Oxi&G^7OSYbGde#SE49UJRZlAGKCQo7AFVtj zRvudJUhaIj>*47~?uSPsm#;)#e`~d}=bvr%Pjz>7QCo{>Ygukywm+mFIv$;PSP{9@ z6>05WwO!dXs4DYcP^vuBMlO|So_=>mJtN;V!N?nhR8D@>P$nA67D`qOd!(YqsZ+6h z6YyrSJ)@Kc>ok*{uT2$$G#Q`?;X{(90eZj~DVn7#hP^Rs%|bY0X`VWrAiV2;fv{!8 z;E3r9qWV%%Um7c^xYIJ<^2Aay*Zz?vR$2zkmFEC+2IHol(ig<^xwlWODo;e&fRg0$TRg*nxsuxZ5ztz4W zhnFt}^q0OUtT<(&9+_HB=g9xoKts9BS;Y7O_1|+8Xh`8hP~@Q@w2+6&ZS088H*SkN zzP4b%2FI(vAEF2nS@7S04xy5)k%ZIj4G7#l3KCuDZwsh@ZRCukJZ+@$YEOGR#$BY7 z0|msGxrGtS!HDs-i260+{Yv`b^kf!>w30R+QaHzhY^v^qlvomu`7%Q=nZj}6tMsO8 zY}+cKG9s(=mr%xY9Mkl*pJ8Hwr6*+Ba~Q0QxE4p@Ev|CL1MUHx%_L{X4uS4vdZNM3c*r}YUq(}YIjfkE z`TqeZ;jzegGALykYY0|{zep&Z+ku;`ltVCDVvYBPaUNMlN1U%qGkp*I0!plZ7-(c! zgT&mBe_Y=zNf5G1TXV~Tk)86kxn-psmM7-+wPI?jS*iVx2e#&AECAmMo#aEB3K0&=s7j4SFn zz7vZ(2xl;9N3w6|BgEYUI673wn&uv)GJJ%{58<`&XP_!c7VSCJqN#u^nz?AIgQU`I zqS3a{v0`+9-7{NekA8SGR<&!~9e>HFKMh;~yi(2YLOWm{?Eu3}s;>^W`;F`H@ zG5F*6@4f%n+!iaVoVfz;5OKF?*}cfFS=yJnKX-oSd~9i7FWYs;IqzHyMjMZd@F_d~ z(6v%}GFDr^$VMBEiSVgC_OSbJoL@L2J#R;QJR*ELJfeLt;(u4Hd3PRoteZ#rV3!-` z8yDNxiaVFuqs^USbLZpYPM{wxYZS{Gm#RN+{0u57`qA1$V(p>jc0xbe(Ia;BM0&p! z?e&Q8$vQ%1&0N8{wS21mcg}1EXxJ)Zh~j&%93@vD+YAUA4`M*jb5115ZD>B=umB|U z0caKli|T({8oUxknm+=)!MjJf;s@kp zW=rP6lo31Qxu0k!gILh+a?t%Hc*}5`xvg-^J(OE^Z5J#h6K1m7Q#oKN+%kd(8V}Mx zkN^rRtU&p9Ww*2p^q){bt%|{95Hq3JN)`uG8Zu1%b`dUqiVsNUwo}(u$+$4WwZDg& z8j?d@3fikh`rZ>HPrc4VNF;|+F+%D6P-Zc3cr${R7zosuL*q+)-nFD%H(Ro zs#%iIt>3U7N9oA=Lp*aH%}M+o5QUfowfqOzMGv>5f~z0W;{hS)FcJ@4 zVlW_0Lk-2CZv1=j(H1NY?vc=wD^I$}7zuIrqa|0Co@{~JIB4_M1DC#hdre`6{qR$Lb?J}4F+Tw*`>eCGL+q1ECyr;NYU6|Y}@D{Ad| zbYQOPj(y&~6!=+X=ThFn{ss5quDgAA53N*oip8A~YtPgf(c1GuNfnlS{Q5#rENEJ4 zUM*;apjk;-N}o}MhQrhHSlOOuR6*Y1>5f==-JB?&P;pvlT zKnLar7Dhe_OrM0l71aQPJU-;`7dphE-O-{(v8ZvWWu@r#CndIpqEE^fPpp>g`_qo) z+MhH$>{@L;y;5>IX5T%3a=oN-p?{@hFEr*({<5eXen0iz^)4M0?T4cFW1{`oobE|Q zO$2V#D!OB(6?aa}pNf^%#!9Q;v!PLZlP=z*6t?0y1{Alo^p1938?`iwmd2&-h^6td z<@L>an97D6X138lnTw~+|FhBjd%X*0(YR-^d)3$!Q8)eS*-@(MB>k&ZYma~j_ebWA z66F`Pp`$S8iyQ^i|GE%MN1dd0I`PWK>6Eliyz&|GVqNcaLiWU)c%vXF0AxAA&3niV zPCU^Ae_im8H^Hq>Tt=A_1Bp~m^1l=kgLkZn+)Nkz*CKu_)R;`d3n${NN%E0^Pf~P} z!$(Y^`l6p}@&RpfMa18PDZ~#jIkv=)Bd3_0XL5jvt4D0<4-gqlE+xTVEila_kn2$X z+X%mg#rLsj!2*pckCG}G5+d#9yj~~#D~C{`doj09xR~O%ZWL-0KO?Jle%ckwK^)vMg*Ot52^52|18`Bj|y>|QX z&BJqjYr2}r_O15dQuCUwb+UckSTXfp)L0=JE1przE+wRdj0JPt3k{Q7GkFRm!dyN5 z8aRNWsS4iLkoRQ^!KEvJVLmc>4lr~jQ}(E~Skx9jqZFk|a2bu}xvm#Fg|2e)1o(S7 z#lY!1H{V$(UFu&mv`u!d7uHPo%?4%y5qrzB_0gfV!Yh*(WBHZS_Go^km|qEGstK9W zg{wfuv~Tik8kxwhgAbWU?lr?@m~md&bm462OzFb6mf1%q*77b*o`J(uuxq+=_T0?5 zMdO;eb@Kcc);nu?O_OK7(l*<+;jibH!#eJn*|Xq_ywMeT+Zh=QM8@8KK~bjTvdOdS zddpPj?Q=KJE$AW#J05jMdR=S!>##3h={68p%O8dgirmSr@4o$;Z%6VPRuzq#GPR=M zxtvn&+rZj0bDl!`%zlc7vRfuow7?c9^Wq(*%NNR)^vm8y=OVqH$WUli@tsXstD*vX zhv^NhZUpHq^rE@CRYldNOr>Z=lnWLa{f;r332jcto?-bSvk^cloFRSwa literal 0 HcmV?d00001 diff --git a/models/__pycache__/kpi_period_line.cpython-312.pyc b/models/__pycache__/kpi_period_line.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74fd40a08fdaee4099701c5468e508180f161901 GIT binary patch literal 3297 zcma)9OKcm*8J;C~mn%LkSr#pkVrW~EMO7B6PA|;Z` z>@MSoSq3a%AT;1WGTJ~gS|D_Lhz+ZViUz3BQ*tN}pcm0nD`MNChTDs8itQqCPW@-e zwUj^(x)5jnnfd<5%>0khUpyWsg734RUrrh>g#Jwj*2mR}M|mI?5k{Cvp)?a?(zci_ zZI9U*I%i9#Z3mop+4uCIyw61K+J^F(sPGQ58gbL|?x#8%pNVV!4z9_>b?qQ;4{n*Z zVL#prJF%{)xrC+|+LJ5t~ANT>&>ohA>&z@PN+Mmkb9cQ&KLa<(-q7q-fz` zNh1-)WXlU98Ej4%uGfi_O6DakDQ6>jTTX3U=MTz$hnmG>qoic`j~Gg4PTB5Gt@bJ5%@k?Gki5 zAi22B#0h}obWUe=t`$JPuw8Ysz+d2iI>4d=UtkNIX2l`W0AKrb^r0Uioq1}9gIaLm zYf~?1xUdP7pz}N1Ky}S{ELmH<{xrQ58esoFjBsLG!KI^}VqX_b4fIyO_hI{NH~7zf zZMU_cH@wTJHb{f4wU=#c&hM|y_5-&Qa|Kt%p*QGGb8Mqy-K8>Db=dC?sc>e8TvBlB zZs7Rn2Y0a7vU000c(i~u!n|ekGxP>6*Jon}3BH&a|5?z-UB-80W^aafe@U3OUd z7^Llk?xp8|Br`Ef?qMFZ(XWU_5xR6FQJI=+7NYggHs`JAkrafA>s^|vK67D z1~-+GC2iU=birH`+K#Q@KPG>l{KM>8+mB1W`>wWoufMU;dj89!O9wvg zy56-S|84*1N@Mv*Iev5Bjj0~U;6oJjoLsP1+j}3PK;y}Uk!q;>=I&2hZnacGhc`kmR6;MT{B$iexG+`??f*D& zJyFj6N?sTP-sd`C&L3FRuj%EHioa*WAFcSKEB$x<1OEtw%l_YlZk}2T9R6(N&cQ$R zeLlJN!s)w#)77q?>tpu<`^(dJ1BYO+GHDo#xfx(Z2?VzUqg4ZrWknn**r1t#sx;zUE(EC=XYB z(G_;hcWhNS_T=F>gAR-_Pu5z`tB~Z;@0v$W@?S8Xkpb@)UJmF#570K^Hw00n-YtrT zQxxI4b16!@Me*&Nl&Y_ACIluiQR7N7YepF_$_j?yqV>rT&PbWL=j9AhAT>k4oJv5D z_E6Gs8bWiLD28A?CJdI=bHhoaT7-n5Xog_YBm)sUN|S`r4y+2zaYn-=ydKQBR;Xv0 zerI^<9v>&ElqkZ}U;vg>T7h3y1}Tb0yxBPD!spO@a~W0L?ZuJm?!(22swYq!z2|K& zP0hIv{r@SCT9=v;Fvyt+40z6_e6>#Upsrb zw k9o%pRD$c;tD{IcqqT@09GQ%8S7+svWHt}CXX;bX~02u`=_y7O^ literal 0 HcmV?d00001 diff --git a/models/kpi_kpi.py b/models/kpi_kpi.py new file mode 100644 index 0000000..eb3e6cf --- /dev/null +++ b/models/kpi_kpi.py @@ -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'}) diff --git a/models/kpi_kpi_line.py b/models/kpi_kpi_line.py new file mode 100644 index 0000000..5a1183a --- /dev/null +++ b/models/kpi_kpi_line.py @@ -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 %s 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) diff --git a/models/kpi_period_line.py b/models/kpi_period_line.py new file mode 100644 index 0000000..57dcc5e --- /dev/null +++ b/models/kpi_period_line.py @@ -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 %s - Period %s 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) diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..746c766 --- /dev/null +++ b/security/ir.model.access.csv @@ -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 diff --git a/security/ir_rule.xml b/security/ir_rule.xml new file mode 100644 index 0000000..4c810b9 --- /dev/null +++ b/security/ir_rule.xml @@ -0,0 +1,20 @@ + + + + + + See Own KPI + + + [('employee_id.user_id', '=', user.id)] + + + + + Manager See All KPI + + + [(1, '=', 1)] + + + diff --git a/security/res_groups.xml b/security/res_groups.xml new file mode 100644 index 0000000..a1f0349 --- /dev/null +++ b/security/res_groups.xml @@ -0,0 +1,22 @@ + + + + + + Employee KPI + + + + + User + + + + + Manager + + + + + + diff --git a/views/kpi_menus.xml b/views/kpi_menus.xml new file mode 100644 index 0000000..d28750a --- /dev/null +++ b/views/kpi_menus.xml @@ -0,0 +1,34 @@ + + + + + + + My KPIs + kpi.kpi + list,form + [('employee_id.user_id', '=', uid)] + {} + + + + All KPIs + kpi.kpi + list,form + {} + + + + + + + diff --git a/views/kpi_portal_templates.xml b/views/kpi_portal_templates.xml new file mode 100644 index 0000000..9b478cf --- /dev/null +++ b/views/kpi_portal_templates.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + diff --git a/views/kpi_views.xml b/views/kpi_views.xml new file mode 100644 index 0000000..a55e509 --- /dev/null +++ b/views/kpi_views.xml @@ -0,0 +1,168 @@ + + + + + + kpi.kpi.line.form + kpi.kpi.line + +
+
+
+ +
+

+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + + kpi.kpi.line.list + kpi.kpi.line + + + + + + + + + + + + + + + + + + + +