Roster management and grace period changes

This commit is contained in:
Bhagya-K 2026-06-08 17:01:09 +05:30
parent 5c6341d8b7
commit adfe801d8e
21 changed files with 1317 additions and 245 deletions

View File

@ -12,6 +12,7 @@
'hr_holidays',
'resource',
'hr_attendance_extended',
'roster_management',
],
'data': [
@ -23,7 +24,8 @@
'views/late_coming_request.xml',
'views/hr_employee_inherit.xml',
'views/late_coming_mail_template.xml',
# 'security/record_rules.xml',
'views/ot_request.xml',
'views/ot_mail_template.xml',
],
'installable': True,

View File

@ -1,4 +1,4 @@
from odoo import api, fields, models, tools
from odoo import api, fields, models, tools, _
class AttendanceAnalytics(models.Model):
@ -12,86 +12,73 @@ class AttendanceAnalytics(models.Model):
'hr.employee',
string='Employee'
)
department_id = fields.Many2one(
'hr.department',
string='Department'
)
date = fields.Date()
date_end = fields.Date(
string="End Date"
)
min_check_in = fields.Datetime()
max_check_out = fields.Datetime()
worked_hours = fields.Float()
out_time = fields.Float()
expected_check_in = fields.Float()
expected_check_out = fields.Float()
required_checkout_time = fields.Float()
compensated_time = fields.Float()
department_grace_period = fields.Integer()
late_minutes = fields.Float()
early_out_minutes = fields.Float()
is_late = fields.Boolean(
string="Late"
)
is_early_out = fields.Boolean(
string="Early Out"
)
is_compensation_pending = fields.Boolean(
string="Compensation Pending"
)
late_time = fields.Char(
string="Late Time",
compute="_compute_late_time"
)
early_out_time = fields.Char(
string="Early Out Time",
compute="_compute_early_out_time"
)
holiday_name = fields.Char()
is_holiday = fields.Boolean()
is_week_off = fields.Boolean()
status_message = fields.Char(
string="Status"
)
display_label = fields.Char(
compute="_compute_display_label",
store=False
)
color = fields.Integer(
compute="_compute_color"
)
status = fields.Selection([
('present', 'Present'),
('leave', 'Leave'),
('no_info', 'No Information')
])
('absent', 'Absent'),
('half_day', 'Half Day'),
('late_in', 'Late In'),
('early_out', 'Early Out'),
('on_duty', 'On Duty'),
('work_from_home', 'Work From Home'),
('holiday', 'Holiday'),
('week_off', 'Week Off'),
], string="Attendance Status")
late_request_id = fields.Many2one(
'late.coming.request',
@ -102,11 +89,26 @@ class AttendanceAnalytics(models.Model):
string="Late Approved"
)
hours_per_day = fields.Float()
allowed_ot_limit = fields.Float()
ot_eligible = fields.Boolean()
overtime_hours = fields.Float()
shift_id = fields.Many2one(
'resource.calendar',
string='Shift'
)
shift_name = fields.Char(
string='Shift'
)
@api.depends('late_minutes')
def _compute_late_time(self):
for rec in self:
total_minutes = int(rec.late_minutes or 0)
hours = total_minutes // 60
@ -121,7 +123,6 @@ class AttendanceAnalytics(models.Model):
def _compute_early_out_time(self):
for rec in self:
total_minutes = int(
rec.early_out_minutes or 0
)
@ -135,12 +136,16 @@ class AttendanceAnalytics(models.Model):
)
@api.depends(
'status_message',
'status',
'late_time',
'early_out_time'
)
def _compute_display_label(self):
status_dict = dict(
self._fields['status'].selection
)
for rec in self:
lines = []
@ -148,8 +153,13 @@ class AttendanceAnalytics(models.Model):
if rec.employee_id:
lines.append(rec.employee_id.name)
if rec.status_message:
lines.append(rec.status_message)
if rec.status:
lines.append(
status_dict.get(
rec.status,
rec.status
)
)
if rec.late_minutes:
lines.append(
@ -163,36 +173,30 @@ class AttendanceAnalytics(models.Model):
rec.display_label = "\n".join(lines)
@api.depends(
'is_holiday',
'is_week_off',
'status',
'is_compensation_pending',
'is_late'
)
@api.depends('status')
def _compute_color(self):
for rec in self:
if rec.is_holiday:
rec.color = 4
elif rec.is_week_off:
rec.color = 7
elif rec.status == 'leave':
rec.color = 3
elif rec.is_compensation_pending:
rec.color = 2
elif rec.is_late:
rec.color = 1
elif rec.status == 'present':
if rec.status == 'present':
rec.color = 10
else:
elif rec.status == 'absent':
rec.color = 1
elif rec.status == 'half_day':
rec.color = 2
elif rec.status == 'late_in':
rec.color = 3
elif rec.status == 'early_out':
rec.color = 4
elif rec.status == 'holiday':
rec.color = 7
elif rec.status == 'week_off':
rec.color = 8
elif rec.status == 'work_from_home':
rec.color = 5
elif rec.status == 'on_duty':
rec.color = 6
else:
rec.color = 0
def action_create_late_request(self):
@ -216,8 +220,6 @@ class AttendanceAnalytics(models.Model):
self.is_compensation_pending,
'compensation_minutes':
self.early_out_minutes,
'status_message':
self.status_message,
})
return {
'type': 'ir.actions.act_window',
@ -228,6 +230,71 @@ class AttendanceAnalytics(models.Model):
'target': 'current',
}
def action_create_ot_request(self):
self.ensure_one()
request = self.env['overtime.request'].search([
('employee_id', '=', self.employee_id.id),
('attendance_date', '=', self.date)
], limit=1)
if not request:
request = self.env['overtime.request'].create({
'employee_id':
self.employee_id.id,
'attendance_date':
self.date,
'check_in':
self.min_check_in,
'check_out':
self.max_check_out,
'worked_hours':
self.worked_hours,
'hours_per_day':
self.hours_per_day,
'allowed_ot_limit':
self.allowed_ot_limit,
'overtime_hours':
self.overtime_hours,
})
return {
'type': 'ir.actions.act_window',
'name': 'OT Request',
'res_model': 'overtime.request',
'res_id': request.id,
'view_mode': 'form',
'target': 'current',
}
def action_create_shiftswap_request(self):
self.ensure_one()
request = self.env['shift.swap.request'].search([
('employee_id', '=', self.employee_id.id),
('roster_date', '=', self.date),
('state', '!=', 'approved')
], limit=1)
self.ensure_one()
roster_form = self.env.ref('roster_management.view_shift_swap_form')
if not request:
request = self.env['shift.swap.request'].create({
'employee_id': self.employee_id.id,
'roster_date': self.date,
})
return {
'type': 'ir.actions.act_window',
'name': 'Shift Swap Request',
'res_model': 'shift.swap.request',
'res_id': request.id,
'view_mode': 'form',
'view_id': roster_form.id,
'target': 'current',
'context': {
'edit': True,
},
}
def init(self):
tools.drop_view_if_exists(
@ -318,6 +385,23 @@ class AttendanceAnalytics(models.Model):
WHERE state = 'approved'
),
ot_requests AS (
SELECT
id,
employee_id,
attendance_date,
state
FROM overtime_request
WHERE state = 'approved'
),
holiday_data AS (
@ -343,45 +427,73 @@ class AttendanceAnalytics(models.Model):
)
SELECT
row_number() OVER() AS id,
ed.employee_id,
ed.department_id,
rc.id AS shift_id,
rc.name AS shift_name,
ed.date,
ed.date AS date_end,
ats.min_check_in,
ats.max_check_out,
COALESCE(
ats.worked_hours,
0
) AS worked_hours,
CASE
WHEN ats.min_check_in IS NOT NULL
AND ats.max_check_out IS NOT NULL
THEN (
EXTRACT(
EPOCH FROM (
ats.max_check_out -
ats.min_check_in
)
) / 3600
) - ats.worked_hours
ELSE 0
END AS out_time,
CASE
WHEN ats.worked_hours > 9
THEN ats.worked_hours - 9
ELSE 0
END AS compensated_time,
rc.hours_per_day AS hours_per_day,
(
rc.hours_per_day
+
COALESCE(rc.over_time_hrs, 0)
) AS allowed_ot_limit,
CASE
WHEN ats.worked_hours > rc.hours_per_day
THEN
ats.worked_hours - rc.hours_per_day
ELSE 0
END AS overtime_hours,
CASE
WHEN ats.worked_hours >
(
rc.hours_per_day
+
COALESCE(rc.over_time_hrs, 0)
)
THEN TRUE
ELSE FALSE
END AS ot_eligible,
CASE
WHEN ats.min_check_in IS NOT NULL
AND ats.max_check_out IS NOT NULL
THEN (
EXTRACT(
EPOCH FROM (
ats.max_check_out -
ats.min_check_in
)
) / 3600
) - ats.worked_hours
ELSE 0
END AS out_time,
CASE
WHEN ats.worked_hours > 9
THEN ats.worked_hours - 9
ELSE 0
END AS compensated_time,
lr.id AS late_request_id,
hd.holiday_name,
@ -796,104 +908,28 @@ END AS compensated_time,
THEN TRUE
ELSE FALSE
END AS late_approved,
CASE
WHEN hd.id IS NOT NULL
THEN 'Holiday'
THEN 'holiday'
WHEN EXTRACT(
DOW FROM ed.date
) IN (0,6)
THEN 'Week Off'
WHEN leave.id IS NOT NULL
THEN 'Leave'
THEN 'week_off'
WHEN ats.min_check_in IS NULL
THEN 'Absent'
WHEN
(
(
(
rc.shift_end_time * 60
)
+
CASE
WHEN ats.min_check_in
IS NOT NULL
THEN GREATEST(
(
(
EXTRACT(
HOUR
FROM ats.min_check_in
) * 60
)
+
EXTRACT(
MINUTE
FROM ats.min_check_in
)
)
-
(
(
rc.shift_start_time
* 60
)
+
COALESCE(
dg.grace_period,
rc.late_grace_period,
0
)
),
0
)
ELSE 0
END
)
>
(
(
EXTRACT(
HOUR
FROM ats.max_check_out
) * 60
)
+
EXTRACT(
MINUTE
FROM ats.max_check_out
)
)
)
THEN 'Compensation Pending'
THEN 'absent'
WHEN ats.worked_hours < (rc.hours_per_day / 2.0)
THEN 'half_day'
WHEN
(
(
EXTRACT(
HOUR
FROM ats.min_check_in
HOUR FROM ats.min_check_in
) * 60
)
+
@ -902,9 +938,9 @@ END AS compensated_time,
FROM ats.min_check_in
)
)
>
(
(
rc.shift_start_time * 60
@ -916,23 +952,36 @@ END AS compensated_time,
0
)
)
THEN 'Late'
ELSE 'Present'
END AS status_message,
CASE
WHEN leave.id IS NOT NULL
THEN 'leave'
WHEN ats.min_check_in IS NOT NULL
THEN 'present'
ELSE 'no_info'
THEN 'late_in'
WHEN
(
(
EXTRACT(
HOUR
FROM ats.max_check_out
) * 60
)
+
EXTRACT(
MINUTE
FROM ats.max_check_out
)
)
<
(
rc.shift_end_time * 60
)
THEN 'early_out'
ELSE 'present'
END AS status
FROM employee_dates ed
LEFT JOIN attendance_summary ats
@ -949,6 +998,10 @@ END AS compensated_time,
LEFT JOIN late_requests lr
ON lr.employee_id = ed.employee_id
AND lr.attendance_date = ed.date
LEFT JOIN ot_requests otr
ON otr.employee_id = ed.employee_id
AND otr.attendance_date = ed.date
LEFT JOIN holiday_data hd
ON ed.date BETWEEN
@ -981,7 +1034,7 @@ END AS compensated_time,
('date', '=', today),
('status', '=', 'no_info'),
('status', '=', 'absent'),
('is_holiday', '=', False),

View File

@ -8,6 +8,17 @@ class HREmployee(models.Model):
string="Attendance Count",
compute="_compute_attendance_analytics_count"
)
attendance_mode = fields.Selection(
[
('office', 'Office Based'),
('remote', 'Remote'),
('hybrid', 'Hybrid'),
('shift', 'Shift Based'),
],
string='Attendance Mode',
default='office',
tracking=True,
)
def _compute_attendance_analytics_count(self):

View File

@ -0,0 +1,158 @@
from odoo import fields, models, _
from odoo.exceptions import UserError
class OvertimeRequest(models.Model):
_name = 'overtime.request'
_description = 'Overtime Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_rec_name = 'employee_id'
employee_id = fields.Many2one(
'hr.employee',
required=True,
tracking=True
)
department_id = fields.Many2one(
'hr.department',
related='employee_id.department_id',
store=True
)
manager_id = fields.Many2one(
'hr.employee',
related='employee_id.parent_id',
store=True
)
attendance_date = fields.Date(
tracking=True
)
check_in = fields.Datetime()
check_out = fields.Datetime()
worked_hours = fields.Float()
hours_per_day = fields.Float()
allowed_ot_limit = fields.Float()
overtime_hours = fields.Float(
tracking=True
)
reason = fields.Text(
tracking=True
)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('approved', 'Approved'),
('rejected', 'Rejected')
], default='draft', tracking=True)
def action_submit(self):
for rec in self:
if not rec.reason:
raise UserError(
_("Please enter reason.")
)
if not rec.manager_id:
raise UserError(
_("Manager not configured.")
)
if not rec.manager_id.work_email:
raise UserError(
_("Manager email not configured.")
)
rec.state = 'submitted'
template = self.env.ref(
'grace_period.email_template_ot_request'
)
template.send_mail(
rec.id,
force_send=True
)
rec.message_post(
body=_(
"OT Request Submitted"
)
)
def action_approve(self):
for rec in self:
if (
rec.manager_id.user_id != self.env.user
and not self.env.user.has_group(
'hr.group_hr_manager'
)
):
raise UserError(
_("Only Manager or HR can approve.")
)
rec.state = 'approved'
rec.message_post(
body=_("Overtime Request Approved.")
)
def action_reject(self):
for rec in self:
if (
rec.manager_id.user_id != self.env.user
and not self.env.user.has_group(
'hr.group_hr_manager'
)
):
raise UserError(
_("Only Manager or HR can reject.")
)
rec.state = 'rejected'
template = self.env.ref(
'grace_period.email_template_ot_rejected'
)
template.send_mail(
rec.id,
force_send=True
)
rec.message_post(
body=_("Overtime Request Rejected.")
)
def action_reset_to_draft(self):
self.state = 'draft'
self.message_post(
body=_(
"Reset to Draft"
)
)

View File

@ -24,6 +24,7 @@ class ResourceCalendar(models.Model):
shift_end_time = fields.Float(string="End Time")
late_grace_period = fields.Integer(default=15)
early_out_grace_period = fields.Integer(default=0)
over_time_hrs = fields.Float(default=2)
department_grace_ids = fields.One2many(
'resource.calendar.department.grace',
'calendar_id',

View File

@ -5,3 +5,4 @@ access_attendance_analytics_manager,attendance.analytics.manager,model_attendanc
access_attendance_analytics_admin,attendance.analytics.admin,model_attendance_analytics,base.group_system,1,1,1,1
access_late_coming_request,attendance_late_coming_request,model_late_coming_request,base.group_user,1,1,1,1
access_resource_calendar_department_grace,resource_calendar_department_grace,model_resource_calendar_department_grace,base.group_user,1,1,1,1
access_overtime_request,overtime_request,model_overtime_request,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_attendance_analytics_admin attendance.analytics.admin model_attendance_analytics base.group_system 1 1 1 1
6 access_late_coming_request attendance_late_coming_request model_late_coming_request base.group_user 1 1 1 1
7 access_resource_calendar_department_grace resource_calendar_department_grace model_resource_calendar_department_grace base.group_user 1 1 1 1
8 access_overtime_request overtime_request model_overtime_request base.group_user 1 1 1 1

View File

@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- =============================================== -->
<!-- TREE VIEW -->
<!-- =============================================== -->
<record id="view_attendance_analytics_list" model="ir.ui.view">
<field name="name">attendance.analytics.list</field>
<field name="model">attendance.analytics</field>
@ -25,7 +22,15 @@
<field name="is_early_out"/>
<field name="is_compensation_pending"/>
<field name="late_approved"/>
<field name="status_message"/>
<field name="shift_id"/>
<!-- <field name="shift_name"/>-->
<field name="status"
widget="badge"
decoration-success="status == 'present'"
decoration-danger="status == 'absent'"
decoration-warning="status == 'late_in'"
decoration-info="status == 'half_day'"
decoration-primary="status == 'holiday'"/>
<button name="action_create_late_request"
type="object"
string="Request Approval"
@ -39,6 +44,15 @@
and early_out_minutes == 0
)
"/>
<button name="action_create_ot_request"
string="Apply OT"
type="object"
class="btn-warning"
invisible="ot_eligible == False"/>
<button name="action_create_shiftswap_request"
string="Swift Swap Request"
type="object"
class="btn-primary"/>
</list>
</field>
</record>
@ -60,7 +74,6 @@
event_open_popup="true">
<field name="display_label"/>
<field name="employee_id"/>
<field name="status_message"/>
</calendar>
</field>
</record>
@ -104,6 +117,9 @@
<filter string="Date"
name="group_date"
context="{'group_by':'date'}"/>
<filter string="Status"
name="group_status"
context="{'group_by':'status'}"/>
</group>
</search>
</field>
@ -114,12 +130,13 @@
<field name="name">Attendance Analytics</field>
<field name="res_model">attendance.analytics</field>
<field name="view_mode">list,calendar</field>
<field name="domain"> [ '|', ('employee_id.user_id', '=', uid), ('employee_id.parent_id.user_id', '=', uid) ] </field>
<field name="domain">[ '|', ('employee_id.user_id', '=', uid), ('employee_id.parent_id.user_id', '=', uid) ]
</field>
<field name="search_view_id" ref="view_attendance_analytics_search"/>
</record>
<menuitem id="menu_attendance_analytics"
name="Attendance Report"
name="Attendance Data"
parent="hr_attendance.menu_hr_attendance_root"
action="action_attendance_analytics"
sequence="50"/>

View File

@ -31,6 +31,54 @@
</xpath>
</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='department_id']"
position="after">
<field name="attendance_mode"/>
</xpath>
</field>
</record>
<record id="view_employee_filter_inherit_attendance_mode"
model="ir.ui.view">
<field name="name">
hr.employee.search.attendance.mode
</field>
<field name="model">hr.employee</field>
<field name="inherit_id"
ref="hr.view_employee_filter"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<filter string="Office"
name="office_employees"
domain="[('attendance_mode','=','office')]"/>
<filter string="Remote"
name="remote_employees"
domain="[('attendance_mode','=','remote')]"/>
<filter string="Hybrid"
name="hybrid_employees"
domain="[('attendance_mode','=','hybrid')]"/>
<filter string="Shift"
name="shift_employees"
domain="[('attendance_mode','=','shift')]"/>
</xpath>
</field>
</record>

View File

@ -54,57 +54,29 @@
type="object"
invisible="state != 'submitted'"
class="btn-danger"/>
<field name="state"
widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="employee_id"
readonly="1"/>
<field name="manager_id"
readonly="1"/>
<field name="attendance_date"
readonly="1"/>
<field name="worked_hours"
readonly="1"/>
<field name="employee_id"/>
<field name="manager_id"/>
<field name="attendance_date"/>
<field name="worked_hours"/>
</group>
<group>
<field name="late_minutes"
readonly="1"/>
<field name="compensation_minutes"
readonly="1"/>
<field name="department_grace_period"
readonly="1"/>
<field name="required_checkout_time"
readonly="1"/>
<field name="late_minutes"/>
<field name="compensation_minutes"/>
<field name="department_grace_period"/>
<field name="required_checkout_time"/>
</group>
</group>
<group>
<field name="status_message"
readonly="1"/>
<field name="reason"
placeholder="Enter reason here..."
placeholder="Enter reason here...Forgot punch/Client meeting/System issue/Travel/on-duty"
readonly="state in ('approved','rejected')"/>
</group>
</sheet>

View File

@ -0,0 +1,124 @@
<odoo>
<record id="email_template_ot_request" model="mail.template">
<field name="name">Overtime Request Submitted</field>
<field name="model_id" ref="model_overtime_request"/>
<field name="subject">Overtime Request - {{ object.employee_id.name }}</field>
<field name="email_from">{{ user.email }}</field>
<field name="email_to">{{ object.manager_id.work_email }}</field>
<field name="body_html" type="html">
<div>
<p>
Hello,
</p>
<p>
Employee
<strong>
<t t-out="object.employee_id.name"/>
</strong>
submitted an Overtime Request.
</p>
<table border="1"
cellpadding="5"
cellspacing="0">
<tr>
<td>
<strong>Date</strong>
</td>
<td>
<t t-out="object.attendance_date"/>
</td>
</tr>
<tr>
<td>
<strong>Worked Hours</strong>
</td>
<td>
<t t-out="object.worked_hours"/>
</td>
</tr>
<tr>
<td>
<strong>OT Hours</strong>
</td>
<td>
<t t-out="object.overtime_hours"/>
</td>
</tr>
</table>
<br/>
<p>
<strong>Reason:</strong>
</p>
<p>
<t t-out="object.reason"/>
</p>
<br/>
<p>
Please review the request.
</p>
</div>
</field>
</record>
<record id="email_template_ot_rejected" model="mail.template">
<field name="name">Overtime Request Rejected</field>
<field name="model_id" ref="model_overtime_request"/>
<field name="subject">Overtime Request Rejected</field>
<field name="email_from">{{ user.email }}</field>
<field name="email_to">{{ object.employee_id.work_email }}</field>
<field name="body_html" type="html">
<div>
<p>
Hello
<strong>
<t t-out="object.employee_id.name"/>
</strong>
,
</p>
<p>
Your Overtime Request for
<strong>
<t t-out="object.attendance_date"/>
</strong>
has been
<strong style="color:red;">
Rejected
</strong>.
</p>
<p>
<strong>Manager:</strong>
<t t-out="object.manager_id.name"/>
</p>
<p>
<strong>OT Hours:</strong>
<t t-out="object.overtime_hours"/>
</p>
<p>
<strong>Reason Submitted:</strong>
</p>
<p>
<t t-out="object.reason"/>
</p>
<br/>
<p>
Please contact your manager.
</p>
<br/>
<p>
Thank You
</p>
</div>
</field>
</record>
</odoo>

View File

@ -0,0 +1,99 @@
<odoo>
<record id="view_overtime_request_form" model="ir.ui.view">
<field name="name">overtime.request.form</field>
<field name="model">overtime.request</field>
<field name="arch" type="xml">
<form string="OT Request">
<header>
<button name="action_submit"
string="Submit"
type="object"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_approve"
string="Approve"
type="object"
class="btn-success"
invisible="state != 'submitted'"/>
<button name="action_reject"
string="Reject"
type="object"
class="btn-danger"
invisible="state != 'submitted'"/>
<button name="action_reset_to_draft"
string="Reset to Draft"
type="object"
invisible="state == 'draft'"/>
<field name="state"
widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="department_id"/>
<field name="attendance_date"/>
<field name="worked_hours"/>
</group>
<group>
<field name="hours_per_day"/>
<field name="allowed_ot_limit"/>
<field name="overtime_hours"/>
<field name="check_in"/>
<field name="check_out"/>
</group>
</group>
<group>
<field name="reason"
placeholder="Enter OT Reason..."
readonly="state in ('approved','rejected')"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_overtime_request_list" model="ir.ui.view">
<field name="name">overtime.request.list</field>
<field name="model">overtime.request</field>
<field name="arch" type="xml">
<list string="OT Request">
<field name="employee_id"/>
<field name="department_id"/>
<field name="attendance_date"/>
<field name="worked_hours"/>
<field name="hours_per_day"/>
<field name="overtime_hours"/>
</list>
</field>
</record>
<record id="action_overtime_request" model="ir.actions.act_window">
<field name="name">Overtime Requests</field>
<field name="res_model">overtime.request</field>
<field name="view_mode">list,form</field>
<field name="domain">
[
'|',
('employee_id.user_id', '=', uid),
('employee_id.parent_id.user_id', '=', uid)
]
</field>
</record>
<menuitem id="menu_overtime_request"
name="Overtime Requests"
parent="hr_attendance.menu_hr_attendance_root"
action="action_overtime_request"
sequence="35"/>
</odoo>

View File

@ -46,6 +46,7 @@
<field name="late_grace_period"/>
<field name="early_out_grace_period"/>
<field name="over_time_hrs"/>
</group>

View File

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

View File

@ -0,0 +1,21 @@
{
'name': 'Roster Management',
'version': '18.0.1.0.0',
'category': 'Human Resources',
'summary': 'Employee Shift and Roster Management',
'author': 'Srivyn Platforms',
'license': 'LGPL-3',
'depends': [
'hr',
'mail',
'resource',
],
'data': [
'security/ir.model.access.csv',
'views/shift_swap.xml',
'views/team_roster.xml',
'views/shift_swap_mail_template.xml',
],
'installable': True,
'application': True,
}

View File

@ -0,0 +1,2 @@
from . import shift_swap
from . import team_roster

View File

@ -0,0 +1,152 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
class ShiftSwapRequest(models.Model):
_name = 'shift.swap.request'
_inherit = [
'mail.thread',
'mail.activity.mixin'
]
employee_id = fields.Many2one('hr.employee')
shift_id = fields.Many2one(
'resource.calendar',
related='employee_id.resource_calendar_id',
string='Assigned Shift',
store=True,
readonly=True,
)
swap_employee_id = fields.Many2one(
'hr.employee'
)
roster_date = fields.Date()
reason = fields.Text()
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('approved', 'Approved'),
('rejected', 'Rejected')
], default='draft')
manager_id = fields.Many2one(
'hr.employee',
related='employee_id.parent_id',
store=True
)
def action_submit(self):
for rec in self:
if not rec.reason:
raise UserError(
_("Please enter reason.")
)
if not rec.manager_id:
raise UserError(
_("Manager not configured.")
)
if not rec.manager_id.work_email:
raise UserError(
_("Manager email not configured.")
)
rec.state = 'submitted'
template = self.env.ref(
'roster_management.email_template_shift_swap_request'
)
template.send_mail(
rec.id,
force_send=True
)
rec.message_post(
body=_(
"Shift swapping Request Submitted"
)
)
def action_reject(self):
for rec in self:
if (
rec.manager_id.user_id != self.env.user
and not self.env.user.has_group(
'hr.group_hr_manager'
)
):
raise UserError(
_("Only Manager or HR can reject.")
)
rec.state = 'rejected'
template = self.env.ref(
'roster_management.email_template_shiftswap_rejected'
)
template.send_mail(
rec.id,
force_send=True
)
rec.message_post(
body=_("ShiftSwap Request Rejected.")
)
def action_approve(self):
for rec in self:
if (
rec.manager_id.user_id != self.env.user
and not self.env.user.has_group(
'hr.group_hr_manager'
)
):
raise UserError(
_("Only Manager or HR can approve.")
)
roster1 = self.env[
'team.roster.line'
].search([
('employee_id', '=', rec.employee_id.id),
('roster_date', '=', rec.roster_date)
], limit=1)
roster2 = self.env[
'team.roster.line'
].search([
('employee_id', '=', rec.swap_employee_id.id),
('roster_date', '=', rec.roster_date)
], limit=1)
if not roster1 or not roster2:
raise UserError(
_("Roster records not found for the selected date.")
)
shift = roster1.shift_id
roster1.shift_id = roster2.shift_id
roster2.shift_id = shift
rec.state = 'approved'
rec.message_post(
body=_("Shift Swap Request Approved.")
)
def action_reset_to_draft(self):
self.state = 'draft'
self.message_post(
body=_(
"Reset to Draft"
)
)

View File

@ -0,0 +1,137 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
from datetime import timedelta
class TeamRoster(models.Model):
_name = 'team.roster'
_description = 'Team Roster'
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(
required=True
)
start_date = fields.Date(
required=True
)
end_date = fields.Date(
required=True
)
state = fields.Selection([
('draft', 'Draft'),
('generated', 'Generated'),
('approved', 'Approved')
], default='draft')
line_ids = fields.One2many(
'team.roster.line',
'roster_id'
)
def action_generate_roster(self):
shifts = self.env[
'resource.calendar'
].search([])
employees = self.env[
'hr.employee'
].search([
('active', '=', True)
])
if not shifts:
return
self.line_ids.unlink()
shift_count = len(shifts)
current_date = self.start_date
counter = 0
while current_date <= self.end_date:
for emp in employees:
self.env[
'team.roster.line'
].create({
'roster_id': self.id,
'employee_id': emp.id,
'shift_id':
shifts[
counter %
shift_count
].id,
'roster_date':
current_date
})
counter += 1
current_date += timedelta(days=1)
self.state = 'generated'
class TeamRosterLine(models.Model):
_name = 'team.roster.line'
_description = 'Team Roster Line'
roster_id = fields.Many2one(
'team.roster'
)
employee_id = fields.Many2one(
'hr.employee',
required=True
)
shift_id = fields.Many2one(
'resource.calendar',
required=True,
string="Shift"
)
roster_date = fields.Date(
required=True
)
department_id = fields.Many2one(
related='employee_id.department_id',
store=True
)
@api.constrains(
'employee_id',
'roster_date'
)
def _check_duplicate(self):
for rec in self:
duplicate = self.search([
('employee_id', '=', rec.employee_id.id),
('roster_date', '=', rec.roster_date),
('id', '!=', rec.id)
])
if duplicate:
raise ValidationError(
"Employee already has a shift assigned."
)

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_team_roster,team.roster,model_team_roster,,1,1,1,1
access_team_roster_line,team.roster.line,model_team_roster_line,,1,1,1,1
access_shift_swap_request,shift.swap.request,model_shift_swap_request,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_team_roster team.roster model_team_roster 1 1 1 1
3 access_team_roster_line team.roster.line model_team_roster_line 1 1 1 1
4 access_shift_swap_request shift.swap.request model_shift_swap_request 1 1 1 1

View File

@ -0,0 +1,69 @@
<odoo>
<record id="view_shift_swap_tree" model="ir.ui.view">
<field name="name">shift.swap.request.tree</field>
<field name="model">shift.swap.request</field>
<field name="arch" type="xml">
<list>
<field name="employee_id"/>
<field name="swap_employee_id"/>
<field name="roster_date"/>
<field name="state"/>
</list>
</field>
</record>
<record id="view_shift_swap_form" model="ir.ui.view">
<field name="name">shift.swap.request.form</field>
<field name="model">shift.swap.request</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_submit"
string="Submit"
type="object"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_approve"
string="Approve"
type="object"
class="btn-success"
invisible="state != 'submitted'"/>
<button name="action_reject"
string="Reject"
type="object"
class="btn-danger"
invisible="state != 'submitted'"/>
<field name="state"
widget="statusbar" force_save="1"/>
</header>
<sheet>
<group>
<field name="employee_id"/>
<field name="shift_id" readonly="1"/>
<field name="swap_employee_id"
required="1" force_save="1"/>
<field name="roster_date"/>
</group>
<group>
<field name="reason"
readonly="state in ('approved','rejected')"
required="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_shift_swap" model="ir.actions.act_window">
<field name="name">Shift Swap Request</field>
<field name="res_model">shift.swap.request</field>
<field name="view_mode">list,form</field>
<field name="domain">
[
'|',
('employee_id.user_id', '=', uid),
('employee_id.parent_id.user_id', '=', uid)
]
</field>
</record>
</odoo>

View File

@ -0,0 +1,110 @@
<odoo>
<record id="email_template_shift_swap_request" model="mail.template">
<field name="name">ShiftSwap Request Submitted</field>
<field name="model_id" ref="model_shift_swap_request"/>
<field name="subject">ShiftSwap Request - {{ object.employee_id.name }}</field>
<field name="email_from">{{ user.email }}</field>
<field name="email_to">{{ object.manager_id.work_email }}</field>
<field name="body_html" type="html">
<div>
<p>
Hello,
</p>
<p>
Employee
<strong>
<t t-out="object.employee_id.name"/>
</strong>
submitted an ShiftSwap Request.
</p>
<p>
The Swap Employee is
<strong>
<t t-out="object.swap_employee_id.name"/>
</strong>
</p>
<table border="1"
cellpadding="5"
cellspacing="0">
<tr>
<td>
<strong>Date</strong>
</td>
<td>
<t t-out="object.roster_date"/>
</td>
</tr>
</table>
<br/>
<p>
<strong>Reason:</strong>
</p>
<p>
<t t-out="object.reason"/>
</p>
<br/>
<p>
Please review the request.
</p>
</div>
</field>
</record>
<record id="email_template_shiftswap_rejected" model="mail.template">
<field name="name">ShiftSwap Request Rejected</field>
<field name="model_id" ref="model_shift_swap_request"/>
<field name="subject">ShiftSwap Request Rejected</field>
<field name="email_from">{{ user.email }}</field>
<field name="email_to">{{ object.employee_id.work_email }}</field>
<field name="body_html" type="html">
<div>
<p>
Hello
<strong>
<t t-out="object.employee_id.name"/>
</strong>
,
</p>
<p>
Your ShiftSwap Request for
<strong>
<t t-out="object.roster_date"/>
</strong>
has been
<strong style="color:red;">
Rejected
</strong>.
</p>
<p>
<strong>Manager:</strong>
<t t-out="object.manager_id.name"/>
</p>
<p>
<strong>Reason Submitted:</strong>
</p>
<p>
<t t-out="object.reason"/>
</p>
<br/>
<p>
Please contact your manager.
</p>
<br/>
<p>
Thank You
</p>
</div>
</field>
</record>
</odoo>

View File

@ -0,0 +1,89 @@
<odoo>
<record id="view_team_roster_tree" model="ir.ui.view">
<field name="name">team.roster.tree</field>
<field name="model">team.roster</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state"/>
</list>
</field>
</record>
<record id="view_team_roster_form" model="ir.ui.view">
<field name="name">team.roster.form</field>
<field name="model">team.roster</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_generate_roster"
string="Generate Roster"
type="object"
class="btn-primary"
invisible="state != 'draft'"/>
<!-- <button name="action_approve"-->
<!-- string="Approve"-->
<!-- type="object"-->
<!-- class="btn-success"-->
<!-- invisible="state != 'generated'"/>-->
<field name="state"
widget="statusbar"/>
</header>
<sheet>
<group>
<field name="name"/>
<field name="start_date"/>
<field name="end_date"/>
</group>
<notebook>
<page string="Roster Lines">
<field name="line_ids">
<list editable="bottom">
<field name="roster_date"/>
<field name="employee_id"/>
<field name="shift_id"/>
<field name="department_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_team_roster_calendar" model="ir.ui.view">
<field name="name">team.roster.line.calendar</field>
<field name="model">team.roster.line</field>
<field name="arch" type="xml">
<calendar
string="Roster Calendar"
date_start="roster_date"
color="employee_id">
<field name="employee_id"/>
<field name="shift_id"/>
</calendar>
</field>
</record>
<record id="action_team_roster" model="ir.actions.act_window">
<field name="name">Team Rosters</field>
<field name="res_model">team.roster</field>
<field name="view_mode">list,form</field>
</record>
<menuitem
id="menu_roster_root"
name="Roster Management"
sequence="50"/>
<menuitem
id="menu_team_roster"
name="Team Rosters"
parent="menu_roster_root"
action="action_team_roster"/>
<menuitem
id="menu_shift_swap"
name="Shift Swap Requests"
parent="menu_roster_root"
action="action_shift_swap"/>
</odoo>