feature/odoo18 #2

Merged
administrator merged 43 commits from feature/odoo18 into develop 2025-05-22 16:16:43 +05:30
46 changed files with 2082 additions and 668 deletions
Showing only changes of commit be91a849f4 - Show all commits

View File

@ -4,11 +4,12 @@
'category': 'Human Resources',
'summary': 'Manage and update weekly timesheets for CWF department',
'author': 'Your Name or Company',
'depends': ['hr_attendance_extended','web', 'mail', 'base','hr_emp_dashboard'],
'depends': ['hr_attendance_extended','web', 'mail', 'base','hr_emp_dashboard','hr_employee_extended'],
'data': [
# 'views/timesheet_form.xml',
'security/ir.model.access.csv',
'views/timesheet_view.xml',
'views/timesheet_weekly_view.xml',
'data/email_template.xml',
],
'assets': {

View File

@ -1,5 +1,5 @@
<odoo>
<data noupdate="1">
<data noupdate="0">
<record id="email_template_timesheet_update" model="mail.template">
<field name="name">Timesheet Update Reminder</field>
<field name="email_from">${(user.email or '')}</field>

View File

@ -1,25 +1,72 @@
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
from datetime import timedelta
from datetime import datetime, timedelta
import datetime as dt
from odoo import _
class CwfTimesheetYearly(models.Model):
_name = 'cwf.timesheet.calendar'
_description = "CWF Timesheet Calendar"
_rec_name = 'name'
name = fields.Char(string='Year Name', required=True)
week_period = fields.One2many('cwf.timesheet','cwf_calendar_id')
_sql_constraints = [
('unique_year', 'unique(name)', 'The year must be unique!')
]
@api.constrains('name')
def _check_year_format(self):
for record in self:
if not record.name.isdigit() or len(record.name) != 4:
raise ValidationError("Year Name must be a 4-digit number.")
def generate_week_period(self):
for record in self:
record.week_period.unlink()
year = int(record.name)
# Find the first Monday of the year
start_date = datetime(year, 1, 1)
while start_date.weekday() != 0: # Monday is 0 in weekday()
start_date += timedelta(days=1)
# Generate weeks from Monday to Sunday
while start_date.year == year or (start_date - timedelta(days=1)).year == year:
end_date = start_date + timedelta(days=6)
self.env['cwf.timesheet'].create({
'name': f'Week {start_date.strftime("%W")}, {year}',
'week_start_date': start_date.date(),
'week_end_date': end_date.date(),
'cwf_calendar_id': record.id,
})
start_date += timedelta(days=7)
def action_generate_weeks(self):
self.generate_week_period()
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
class CwfTimesheet(models.Model):
_name = 'cwf.timesheet'
_description = 'CWF Weekly Timesheet'
_rec_name = 'name'
name = fields.Char(string='Week Name', required=True)
department_id = fields.Many2one('hr.department', string='Department')
week_start_date = fields.Date(string='Week Start Date', required=True)
week_end_date = fields.Date(string='Week End Date', required=True)
total_hours = fields.Float(string='Total Hours', required=True)
status = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted')
], default='draft', string='Status')
lines = fields.One2many('cwf.timesheet.line','week_id')
cwf_calendar_id = fields.Many2one('cwf.timesheet.calendar')
def send_timesheet_update_email(self):
template = self.env.ref('cwf_timesheet.email_template_timesheet_update')
@ -31,40 +78,219 @@ class CwfTimesheet(models.Model):
raise UserError('The start date cannot be after the end date.')
# Get all employees in the department
employees = self.env['hr.employee'].search([('department_id', '=', self.department_id.id)])
external_group_id = self.env.ref("hr_employee_extended.group_external_user")
users = self.env["res.users"].search([("groups_id", "=", external_group_id.id)])
employees = self.env['hr.employee'].search([('user_id', 'in', users.ids),'|',('doj','=',False),('doj','>=', self.week_start_date)])
print(employees)
# Loop through each day of the week and create timesheet lines for each employee
while current_date <= end_date:
for employee in employees:
self.env['cwf.timesheet.line'].create({
'week_id': self.id,
'employee_id': employee.id,
'week_day':current_date,
})
existing_record = self.env['cwf.weekly.timesheet'].search([
('week_id', '=', self.id),
('employee_id', '=', employee.id)
], limit=1)
if not existing_record:
self.env['cwf.timesheet.line'].create({
'week_id': self.id,
'employee_id': employee.id,
'week_day':current_date,
})
current_date += timedelta(days=1)
self.status = 'submitted'
for employee in employees:
weekly_timesheet_exists = self.env['cwf.weekly.timesheet'].sudo().search([('week_id','=',self.id),('employee_id','=',employee.id)])
if not weekly_timesheet_exists:
weekly_timesheet = self.env['cwf.weekly.timesheet'].sudo().create({
'week_id': self.id,
'employee_id': employee.id,
'status': 'draft'
})
# Generate the URL for the newly created weekly_timesheet
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
record_url = f"{base_url}/web#id={weekly_timesheet.id}&view_type=form&model=cwf.weekly.timesheet"
weekly_timesheet.update_attendance()
if employee.work_email:
email_values = {
'email_to': employee.work_email,
'body_html': template.body_html, # Email body from template
'body_html': template.body_html.replace(
'https://ftprotech.in/odoo/action-261',
record_url
), # Email body from template
'subject': 'Timesheet Update Notification',
}
template.send_mail(self.id, email_values=email_values, force_send=True)
class CwfWeeklyTimesheet(models.Model):
_name = "cwf.weekly.timesheet"
_description = "CWF Weekly Timesheet"
_rec_name = 'employee_id'
week_id = fields.Many2one('cwf.timesheet', 'Week')
employee_id = fields.Many2one('hr.employee', default=lambda self: self.env.user.employee_id.id)
cwf_timesheet_lines = fields.One2many('cwf.timesheet.line' ,'weekly_timesheet')
status = fields.Selection([('draft','Draft'),('submitted','Submitted')], default='draft')
week_start_date = fields.Date(related='week_id.week_start_date')
week_end_date = fields.Date(related='week_id.week_end_date')
@api.constrains('week_id', 'employee_id')
def _check_unique_week_employee(self):
for record in self:
# Search for existing records with the same week_id and employee_id
existing_record = self.env['cwf.weekly.timesheet'].search([
('week_id', '=', record.week_id.id),
('employee_id', '=', record.employee_id.id)
], limit=1)
# If an existing record is found and it's not the current record (in case of update), raise an error
if existing_record and existing_record.id != record.id:
raise ValidationError("A timesheet for this employee already exists for the selected week.")
def update_attendance(self):
for rec in self:
# Get the week start and end date
week_start_date = rec.week_id.week_start_date
week_end_date = rec.week_id.week_end_date
# Convert start and end dates to datetime objects for proper filtering
week_start_datetime = datetime.combine(week_start_date, datetime.min.time())
week_end_datetime = datetime.combine(week_end_date, datetime.max.time())
# Delete timesheet lines that are outside the week range
rec.cwf_timesheet_lines.filtered(lambda line:
line.week_day < week_start_date or line.week_day > week_end_date
).unlink()
# Search for attendance records that fall within the week period and match the employee
hr_attendance_records = self.env['hr.attendance'].sudo().search([
('check_in', '>=', week_start_datetime),
('check_out', '<=', week_end_datetime),
('employee_id', '=', rec.employee_id.id)
])
# Group the attendance records by date
attendance_by_date = {}
for attendance in hr_attendance_records:
attendance_date = attendance.check_in.date()
if attendance_date not in attendance_by_date:
attendance_by_date[attendance_date] = []
attendance_by_date[attendance_date].append(attendance)
# Get all the dates within the week period
all_week_dates = [week_start_date + timedelta(days=i) for i in
range((week_end_date - week_start_date).days + 1)]
# Create or update timesheet lines for each day in the week
for date in all_week_dates:
# Check if there is attendance for this date
if date in attendance_by_date:
# If there are multiple attendance records, take the earliest check_in and latest check_out
earliest_check_in = min(attendance.check_in for attendance in attendance_by_date[date])
latest_check_out = max(attendance.check_out for attendance in attendance_by_date[date])
if (earliest_check_in + timedelta(hours=5, minutes=30)).date() > date:
earliest_check_in = (datetime.combine(date, datetime.max.time()) - timedelta(hours=5, minutes=30))
if (latest_check_out + timedelta(hours=5, minutes=30)).date() > date:
latest_check_out = (datetime.combine(date, datetime.max.time()) - timedelta(hours=5, minutes=30))
# Check if a timesheet line for this employee, week, and date already exists
existing_timesheet_line = self.env['cwf.timesheet.line'].sudo().search([
('week_day', '=', date),
('employee_id', '=', rec.employee_id.id),
('week_id', '=', rec.week_id.id),
('weekly_timesheet', '=', rec.id)
], limit=1)
if existing_timesheet_line:
# If it exists, update the existing record
existing_timesheet_line.write({
'check_in_date': earliest_check_in,
'check_out_date': latest_check_out,
'state_type': 'present',
})
else:
# If it doesn't exist, create a new timesheet line with present state_type
self.env['cwf.timesheet.line'].create({
'weekly_timesheet': rec.id,
'employee_id': rec.employee_id.id,
'week_id': rec.week_id.id,
'week_day': date,
'check_in_date': earliest_check_in,
'check_out_date': latest_check_out,
'state_type': 'present',
})
else:
# If no attendance exists for this date, create a new timesheet line with time_off state_type
existing_timesheet_line = self.env['cwf.timesheet.line'].sudo().search([
('week_day', '=', date),
('employee_id', '=', rec.employee_id.id),
('week_id', '=', rec.week_id.id),
('weekly_timesheet', '=', rec.id)
], limit=1)
if not existing_timesheet_line:
if date.weekday() != 5 and date.weekday() != 6:
# If no record exists for this date, create a new timesheet line with time_off state_type
self.env['cwf.timesheet.line'].create({
'weekly_timesheet': rec.id,
'employee_id': rec.employee_id.id,
'week_id': rec.week_id.id,
'week_day': date,
'state_type': 'time_off',
})
def action_submit(self):
for rec in self:
for timesheet in rec.cwf_timesheet_lines:
timesheet.action_submit()
rec.status = 'submitted'
class CwfTimesheetLine(models.Model):
_name = 'cwf.timesheet.line'
_description = 'CWF Weekly Timesheet Lines'
_rec_name = 'employee_id'
employee_id = fields.Many2one('hr.employee', string='Employee')
week_id = fields.Many2one('cwf.timesheet', 'Week')
weekly_timesheet = fields.Many2one('cwf.weekly.timesheet')
employee_id = fields.Many2one('hr.employee', string='Employee', related='weekly_timesheet.employee_id')
week_id = fields.Many2one('cwf.timesheet', 'Week', related='weekly_timesheet.week_id')
week_day = fields.Date(string='Date')
check_in_date = fields.Datetime(string='Checkin')
check_out_date = fields.Datetime(string='Checkout ')
is_updated = fields.Boolean('Attendance Updated')
state_type = fields.Selection([('draft','Draft'),('holiday', 'Holiday'),('time_off','Time Off'),('present','Present')], default='draft')
state_type = fields.Selection([('draft','Draft'),('holiday', 'Holiday'),('time_off','Time Off'),('half_day','Half Day'),('present','Present')], default='draft', required=True)
@api.constrains('week_day', 'check_in_date', 'check_out_date')
def _check_week_day_and_times(self):
for record in self:
# Ensure week_day is within the week range
if record.week_id:
if record.week_day < record.week_id.week_start_date or record.week_day > record.week_id.week_end_date:
raise ValidationError(
"The selected 'week_day' must be within the range of the week from %s to %s." %
(record.week_id.week_start_date, record.week_id.week_end_date)
)
# Ensure check_in_date and check_out_date are on the selected week_day
if record.check_in_date:
if record.check_in_date.date() != record.week_day:
raise ValidationError(
"The 'check_in_date' must be on the selected Date."
)
if record.check_out_date:
if record.check_out_date.date() != record.week_day:
raise ValidationError(
"The 'check_out_date' must be on the selected Date."
)
def action_submit(self):
if self.state_type == 'draft' or not self.state_type:
@ -77,9 +303,23 @@ class CwfTimesheetLine(models.Model):
def _update_attendance(self):
attendance_obj = self.env['hr.attendance']
for record in self:
attendance_obj.sudo().create({
'employee_id': record.employee_id.id,
'check_in': record.check_in_date,
'check_out': record.check_out_date,
})
if record.check_in_date != False and record.check_out_date != False and record.employee_id:
first_check_in = attendance_obj.sudo().search([('check_in', '>=', record.check_in_date.date()),
('check_out', '<=', record.check_out_date.date()),('employee_id','=',record.employee_id.id)],
limit=1, order="check_in")
last_check_out = attendance_obj.sudo().search([('check_in', '>=', record.check_in_date.date()),
('check_out', '<=', record.check_out_date.date()),('employee_id','=',record.employee_id.id)],
limit=1, order="check_out desc")
if first_check_in or last_check_out:
if first_check_in.sudo().check_in != record.check_in_date:
first_check_in.sudo().check_in = record.check_in_date
if last_check_out.sudo().check_out != record.check_out_date:
last_check_out.sudo().check_out = record.check_out_date
else:
attendance_obj.sudo().create({
'employee_id': record.employee_id.id,
'check_in': record.check_in_date - timedelta(hours=5, minutes=30),
'check_out': record.check_out_date - timedelta(hours=5, minutes=30),
})
record.is_updated = True

View File

@ -1,4 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_cwf_timesheet_user,access.cwf.timesheet,model_cwf_timesheet,,1,1,1,1
access_cwf_timesheet_user,access.cwf.timesheet,model_cwf_timesheet,base.group_user,1,0,0,0
access_cwf_timesheet_manager,access.cwf.timesheet,model_cwf_timesheet,hr_attendance.group_hr_attendance_manager,1,1,1,1
access_cwf_timesheet_calendar,cwf_timesheet_calendar,model_cwf_timesheet_calendar,hr_attendance.group_hr_attendance_manager,1,1,1,1
access_cwf_timesheet_calendar_user,cwf_timesheet_calendar_user,model_cwf_timesheet_calendar,base.group_user,1,0,0,0
access_cwf_timesheet_line_user,access.cwf.timesheet.line,model_cwf_timesheet_line,,1,1,1,1
access_cwf_weekly_timesheet_user,cwf.weekly.timesheet access,model_cwf_weekly_timesheet,,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cwf_timesheet_user access.cwf.timesheet model_cwf_timesheet base.group_user 1 0 0 0
3 access_cwf_timesheet_manager access.cwf.timesheet model_cwf_timesheet hr_attendance.group_hr_attendance_manager 1 1 1 1
4 access_cwf_timesheet_calendar cwf_timesheet_calendar model_cwf_timesheet_calendar hr_attendance.group_hr_attendance_manager 1 1 1 1
5 access_cwf_timesheet_calendar_user cwf_timesheet_calendar_user model_cwf_timesheet_calendar base.group_user 1 0 0 0
6 access_cwf_timesheet_line_user access.cwf.timesheet.line model_cwf_timesheet_line 1 1 1 1
7 access_cwf_weekly_timesheet_user cwf.weekly.timesheet access model_cwf_weekly_timesheet 1 1 1 1

View File

@ -2,6 +2,7 @@
import { patch } from "@web/core/utils/patch";
import { NetflixProfileContainer } from "@hr_emp_dashboard/js/profile_component";
import { user } from "@web/core/user";
// Apply patch to NetflixProfileContainer prototype
patch(NetflixProfileContainer.prototype, {
@ -24,9 +25,14 @@ patch(NetflixProfileContainer.prototype, {
/**
* Override the hr_timesheets method
*/
hr_timesheets() {
async hr_timesheets() {
const isExternalUser = await user.hasGroup("hr_employee_extended.group_external_user");
// Check the department of the logged-in employee
if (this.state.login_employee.department_id == 'CWF') {
console.log(isExternalUser);
console.log("is external user");
debugger;
if (isExternalUser && this.state.login_employee.department_id) {
console.log("hello external");
// If the department is 'CWF', perform the action to open the timesheets
this.action.doAction({
name: "Timesheets",

View File

@ -1,10 +1,52 @@
<odoo>
<record id="view_cwf_timesheet_calendar_form" model="ir.ui.view">
<field name="name">cwf.timesheet.calendar.form</field>
<field name="model">cwf.timesheet.calendar</field>
<field name="arch" type="xml">
<form string="CWF Timesheet Calendar">
<sheet>
<group>
<field name="name"/>
<button name="action_generate_weeks" string="Generate Week Periods" type="object" class="oe_highlight"/>
</group>
<notebook>
<page string="Weeks">
<field name="week_period" context="{'order': 'week_start_date asc'}">
<list editable="bottom" decoration-success="status == 'submitted'">
<field name="name"/>
<field name="week_start_date"/>
<field name="week_end_date"/>
<field name="status"/>
<button name="send_timesheet_update_email" string="Send Update Email" invisible="status == 'submitted'" type="object" confirm="You can't revert this action. Please check twice before Submitting?" class="oe_highlight"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_cwf_timesheet" model="ir.actions.act_window">
<field name="name">CWF Timesheet</field>
<field name="res_model">cwf.timesheet</field>
<record id="view_cwf_timesheet_calendar_list" model="ir.ui.view">
<field name="name">cwf.timesheet.calendar.list</field>
<field name="model">cwf.timesheet.calendar</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
</list>
</field>
</record>
<record id="action_cwf_timesheet_calendar" model="ir.actions.act_window">
<field name="name">CWF Timesheet Calendar</field>
<field name="res_model">cwf.timesheet.calendar</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_timesheet_calendar_form" name="CWF Timesheet Calendar" parent="hr_attendance_extended.menu_attendance_attendance" action="cwf_timesheet.action_cwf_timesheet_calendar"/>
<record id="view_timesheet_form" model="ir.ui.view">
<field name="name">cwf.timesheet.form</field>
<field name="model">cwf.timesheet</field>
@ -21,63 +63,21 @@
<group>
<!-- Section for Employee and Date Range -->
<group>
<field name="department_id" readonly="status != 'draft'"/>
<field name="week_start_date" readonly="status != 'draft'"/>
<field name="week_end_date" readonly="status != 'draft'"/>
</group>
<!-- Section for Hours and Status -->
<group>
<field name="total_hours" readonly="status != 'draft'"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<menuitem id="menu_timesheet_form" name="Timesheet Form" parent="hr_attendance_extended.menu_attendance_attendance" action="action_cwf_timesheet"/>
<record id="action_cwf_timesheet_line" model="ir.actions.act_window">
<field name="name">CWF Timesheet Lines</field>
<field name="res_model">cwf.timesheet.line</field>
<field name="view_mode">list</field>
<field name="context">{'search_default_group_by_employee_id':1}</field>
</record>
<record id="view_cwf_timesheet_line_list" model="ir.ui.view">
<field name="name">cwf.timesheet.line.list</field>
<field name="model">cwf.timesheet.line</field>
<field name="arch" type="xml">
<list editable="bottom" create="0" delete="0" decoration-success="is_updated == True">
<field name="employee_id" readonly="1" force_save="1"/>
<field name="week_day" readonly="1" force_save="1"/>
<field name="check_in_date" readonly="is_updated == True"/>
<field name="check_out_date" readonly="is_updated == True"/>
<field name="state_type" readonly="is_updated == True"/>
<button name="action_submit" type="object" string="Submit" class="btn btn-outline-primary" invisible="is_updated == True"/>
</list>
</field>
<record id="action_cwf_timesheet" model="ir.actions.act_window">
<field name="name">CWF Timesheet</field>
<field name="res_model">cwf.timesheet</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_timesheet_form_line" name="Week Timesheet " parent="hr_attendance_extended.menu_attendance_attendance" action="action_cwf_timesheet_line"/>
<record id="view_cwf_timesheet_line_search" model="ir.ui.view">
<field name="name">cwf.timesheet.line.search</field>
<field name="model">cwf.timesheet.line</field>
<field name="arch" type="xml">
<search string="Timesheets">
<field name="employee_id"/>
<field name="week_id"/>
<field name="week_day"/>
<field name="check_in_date"/>
<field name="check_out_date"/>
<field name="state_type"/>
<group expand="0" string="Group By">
<filter string="Employee" name="employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
<filter string="Week" name="week_id" domain="[]" context="{'group_by':'week_id'}"/>
<filter string="Status" name ="state_type" domain="[]" context="{'group_by': 'state_type'}"/>
</group>
</search>
</field>
</record>
</odoo>

View File

@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_cwf_weekly_timesheet_form" model="ir.ui.view">
<field name="name">cwf.weekly.timesheet.form</field>
<field name="model">cwf.weekly.timesheet</field>
<field name="arch" type="xml">
<form string="CWF Weekly Timesheet" create="0">
<header>
<button name="update_attendance" string="Update"
type="object" class="oe_highlight" invisible="status != 'draft'"/>
<button name="action_submit" string="Submit" type="object" confirm="Are you sure you want to submit?"
class="oe_highlight" invisible="status != 'draft'"/>
<field name="status" readonly="1" widget="statusbar"/>
</header>
<sheet>
<group>
<field name="week_id" readonly="1"/>
<field name="employee_id" readonly="1"/>
<label for="week_start_date" string="Dates"/>
<div class="o_row">
<field name="week_start_date" widget="daterange" options="{'end_date_field': 'week_end_date'}"/>
<field name="week_end_date" invisible="1"/>
</div>
</group>
<notebook>
<page string="Timesheet Lines">
<field name="cwf_timesheet_lines">
<list editable="bottom">
<field name="employee_id"/>
<field name="week_day"/>
<field name="check_in_date"/>
<field name="check_out_date"/>
<field name="state_type"/>
<!-- <button name="action_submit" string="Submit" type="object"-->
<!-- confirm="Are you sure you want to submit?" class="oe_highlight"/>-->
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_cwf_weekly_timesheet_list" model="ir.ui.view">
<field name="name">cwf.weekly.timesheet.list</field>
<field name="model">cwf.weekly.timesheet</field>
<field name="arch" type="xml">
<list create="0">
<field name="week_id"/>
<field name="employee_id"/>
<field name="status"/>
<!-- <button name="action_submit" string="Submit" type="object" confirm="Are you sure you want to submit?"-->
<!-- class="oe_highlight"/>-->
</list>
</field>
</record>
<record id="view_cwf_weekly_timesheet_search" model="ir.ui.view">
<field name="name">cwf.weekly.timesheet.search</field>
<field name="model">cwf.weekly.timesheet</field>
<field name="arch" type="xml">
<search>
<!-- Search by Week ID -->
<field name="week_id"/>
<!-- Search by Employee -->
<field name="employee_id"/>
<!-- Search by Status -->
<field name="status"/>
<!-- Optional: Add custom filters if needed -->
<filter string="Draft" name="filter_draft" domain="[('status','=','draft')]"/>
<filter string="Submitted" name="filter_submitted" domain="[('status','=','submit')]"/>
<group expand="0" string="Group By">
<filter string="Week" name="week_id" domain="[]" context="{'group_by':'week_id'}"/>
<filter string="Employee" name="employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
<filter string="Status" name="state_type" domain="[]" context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<record id="action_cwf_weekly_timesheet" model="ir.actions.act_window">
<field name="name">CWF Weekly Timesheet</field>
<field name="res_model">cwf.weekly.timesheet</field>
<field name="view_mode">list,form</field>
<field name="context">
{
"search_default_week_id": 1,
"search_default_employee_id": 2
}
</field>
<field name="search_view_id" ref="cwf_timesheet.view_cwf_weekly_timesheet_search"/>
</record>
<record id="view_cwf_timesheet_line_list" model="ir.ui.view">
<field name="name">cwf.timesheet.line.list</field>
<field name="model">cwf.timesheet.line</field>
<field name="arch" type="xml">
<list editable="bottom" create="0" delete="0" decoration-success="is_updated == True">
<field name="employee_id" readonly="1" force_save="1"/>
<field name="week_day" readonly="1" force_save="1"/>
<field name="check_in_date" readonly="is_updated == True"/>
<field name="check_out_date" readonly="is_updated == True"/>
<field name="state_type" readonly="is_updated == True"/>
<!-- <button name="action_submit" type="object" string="Submit" class="btn btn-outline-primary"-->
<!-- invisible="is_updated == True"/>-->
</list>
</field>
</record>
<record id="view_cwf_timesheet_line_search" model="ir.ui.view">
<field name="name">cwf.timesheet.line.search</field>
<field name="model">cwf.timesheet.line</field>
<field name="arch" type="xml">
<search string="Timesheets">
<field name="employee_id"/>
<field name="week_id"/>
<field name="week_day"/>
<field name="check_in_date"/>
<field name="check_out_date"/>
<field name="state_type"/>
<group expand="0" string="Group By">
<filter string="Employee" name="employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
<filter string="Week" name="week_id" domain="[]" context="{'group_by':'week_id'}"/>
<filter string="Status" name="state_type" domain="[]" context="{'group_by': 'state_type'}"/>
</group>
</search>
</field>
</record>
<record id="action_cwf_timesheet_line" model="ir.actions.act_window">
<field name="name">CWF Timesheet Lines</field>
<field name="res_model">cwf.timesheet.line</field>
<field name="view_mode">list</field>
<field name="context">{"search_default_week_id": 1, "search_default_employee_id": 1}</field>
<field name="search_view_id" ref="cwf_timesheet.view_cwf_timesheet_line_search"/>
</record>
<menuitem id="menu_timesheet_form" name="CWF Weekly Timesheet "
parent="hr_attendance_extended.menu_attendance_attendance" action="action_cwf_weekly_timesheet"/>
<menuitem id="menu_timesheet_form_line" name="CWF Timesheet Lines"
parent="hr_attendance_extended.menu_attendance_attendance" action="action_cwf_timesheet_line"/>
</odoo>

View File

@ -12,10 +12,10 @@
'website': "https://www.ftprotech.com",
# Categories can be used to filter modules in modules listing
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
# for the full list
'category': 'Human Resources/Attendances',
'version': '0.1',
'license': 'LGPL-3',
# any module necessary for this one to work correctly
'depends': ['base','hr','hr_attendance','hr_holidays','hr_employee_extended'],
@ -23,21 +23,11 @@
# always loaded
'data': [
'security/ir.model.access.csv',
'security/security.xml',
'data/cron.xml',
'data/sequence.xml',
'views/hr_attendance.xml',
'views/day_attendance_report.xml',
'views/on_duty_form.xml',
],
'assets': {
'web.assets_backend': [
'hr_attendance_extended/static/src/xml/attendance_report.xml',
'hr_attendance_extended/static/src/js/attendance_report.js',
],
'web.assets_frontend': [
'web/static/lib/jquery/jquery.js',
'hr_attendance_extended/static/src/js/jquery-ui.min.js',
'hr_attendance_extended/static/src/js/jquery-ui.min.css',
]
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sequences for lead mining requests -->
<record id="ir_sequence_on_duty_form" model="ir.sequence">
<field name="name">On Duty Request</field>
<field name="code">on.duty.form</field>
<field name="prefix">OD</field>
<field name="padding">4</field>
</record>
</data>
</odoo>

View File

@ -1,2 +1,2 @@
from . import hr_attendance
from . import hr_attendance_report
from . import on_duty_form

View File

@ -1,147 +0,0 @@
from odoo import models, fields, api
from datetime import datetime, timedelta
import xlwt
from io import BytesIO
import base64
from odoo.exceptions import UserError
def convert_to_date(date_string):
# Use strptime to parse the date string in 'dd/mm/yyyy' format
return datetime.strptime(date_string, '%Y/%m/%d')
class AttendanceReport(models.Model):
_name = 'attendance.report'
_description = 'Attendance Report'
@api.model
def get_attendance_report(self, employee_id, start_date, end_date):
# Ensure start_date and end_date are in the correct format (datetime)
if employee_id == '-':
employee_id = False
else:
employee_id = int(employee_id)
if isinstance(start_date, str):
start_date = datetime.strptime(start_date, '%d/%m/%Y')
if isinstance(end_date, str):
end_date = datetime.strptime(end_date, '%d/%m/%Y')
# Convert the dates to 'YYYY-MM-DD' format for PostgreSQL
start_date_str = start_date.strftime('%Y-%m-%d')
end_date_str = end_date.strftime('%Y-%m-%d')
# Define the base where condition
if employee_id:
case = """WHERE emp.id = %s AND at.check_in >= %s AND at.check_out <= %s"""
params = (employee_id, start_date_str, end_date_str)
else:
case = """WHERE at.check_in >= %s AND at.check_out <= %s"""
params = (start_date_str, end_date_str)
# Define the query with improved date handling
query = """
WITH daily_checkins AS (
SELECT
emp.id,
emp.name,
DATE(at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata') AS date,
at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata' AS check_in,
at.check_out AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata' AS check_out,
at.worked_hours,
ROW_NUMBER() OVER (PARTITION BY emp.id, DATE(at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata') ORDER BY at.check_in) AS first_checkin_row,
ROW_NUMBER() OVER (PARTITION BY emp.id, DATE(at.check_in AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata') ORDER BY at.check_in DESC) AS last_checkout_row
FROM
hr_attendance at
LEFT JOIN
hr_employee emp ON at.employee_id = emp.id
""" + case + """
)
SELECT
id,
name,
date,
MAX(CASE WHEN first_checkin_row = 1 THEN check_in END) AS first_check_in,
MAX(CASE WHEN last_checkout_row = 1 THEN check_out END) AS last_check_out,
SUM(worked_hours) AS total_worked_hours
FROM
daily_checkins
GROUP BY
id, name, date
ORDER BY
id, date;
"""
# Execute the query with parameters
self.env.cr.execute(query, params)
rows = self.env.cr.dictfetchall()
data = []
a = 0
for r in rows:
a += 1
# Calculate worked hours in Python, but here it's better done in the query itself.
worked_hours = r['last_check_out'] - r['first_check_in'] if r['first_check_in'] and r[
'last_check_out'] else 0
data.append({
'id': a,
'employee_id': r['id'],
'employee_name': r['name'],
'date': r['date'],
'check_in': r['first_check_in'],
'check_out': r['last_check_out'],
'worked_hours': worked_hours,
})
return data
@api.model
def export_to_excel(self, employee_id, start_date, end_date):
# Fetch the attendance data (replace with your logic to fetch attendance data)
attendance_data = self.get_attendance_report(employee_id, start_date, end_date)
if not attendance_data:
raise UserError("No data to export!")
# Create an Excel workbook and a sheet
workbook = xlwt.Workbook()
sheet = workbook.add_sheet('Attendance Report')
# Define the column headers
headers = ['Employee Name', 'Check-in', 'Check-out', 'Worked Hours']
# Write headers to the first row
for col_num, header in enumerate(headers):
sheet.write(0, col_num, header)
# Write the attendance data to the sheet
for row_num, record in enumerate(attendance_data, start=1):
sheet.write(row_num, 0, record['employee_name'])
sheet.write(row_num, 1, record['check_in'].strftime("%Y-%m-%d %H:%M:%S"))
sheet.write(row_num, 2, record['check_out'].strftime("%Y-%m-%d %H:%M:%S"))
if isinstance(record['worked_hours'], timedelta):
hours = record['worked_hours'].seconds // 3600
minutes = (record['worked_hours'].seconds % 3600) // 60
# Format as "X hours Y minutes"
worked_hours_str = f"{record['worked_hours'].days * 24 + hours} hours {minutes} minutes"
sheet.write(row_num, 3, worked_hours_str)
else:
sheet.write(row_num, 3, record['worked_hours'])
# Save the workbook to a BytesIO buffer
output = BytesIO()
workbook.save(output)
# Convert the output to base64 for saving in Odoo
file_data = base64.b64encode(output.getvalue())
# Create an attachment record to save the Excel file in Odoo
attachment = self.env['ir.attachment'].create({
'name': 'attendance_report.xls',
'type': 'binary',
'datas': file_data,
'mimetype': 'application/vnd.ms-excel',
})
# Return the attachment's URL to allow downloading in the Odoo UI
return '/web/content/%d/%s' % (attachment.id, attachment.name),

View File

@ -0,0 +1,69 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class OnDutyForm(models.Model):
_name = "on.duty.form"
_description = "On Duty Form"
_rec_name = "unique_id"
_inherit = ['mail.thread', 'mail.activity.mixin']
unique_id = fields.Char(string='Reference', readonly=True, copy=False, default='/')
employee_id = fields.Many2one('hr.employee', string="Employee", required=True , default=lambda self: self.env.user.employee_id.id)
department_id = fields.Many2one('hr.department', string="Department", related='employee_id.department_id', readonly=True)
start_date = fields.Datetime(string='Start Date', required=True)
end_date = fields.Datetime(string='End Date', required=True)
reason = fields.Text(string='Reason', required=True)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('approved', 'Approved'),
('rejected', 'Rejected')
], string='Status', default='draft', tracking=True)
@api.model
def create(self, vals):
vals['unique_id'] = self.env['ir.sequence'].next_by_code('on.duty.form') or '/'
return super(OnDutyForm, self).create(vals)
def action_submit(self):
for record in self:
overlapping_requests = self.env['on.duty.form'].search([
('employee_id', '=', record.employee_id.id),
('state', '=', 'submitted'),
'|', '|',
'&', ('start_date', '<=', record.start_date), ('end_date', '>=', record.start_date), # Overlap at Start
'&', ('start_date', '<=', record.end_date), ('end_date', '>=', record.end_date), # Overlap at End
'&', ('start_date', '>=', record.start_date), ('end_date', '<=', record.end_date) # Complete Overlap
])
if overlapping_requests:
raise ValidationError("The selected date range conflicts with an existing submitted On-Duty request.")
record.state = 'submitted'
def action_approve(self):
for rec in self:
if self.env.user.has_group('hr_attendance.group_hr_attendance_manager') or rec.employee_id.parent_id.id == self.env.user.id:
rec.state = 'approved'
else:
raise ValidationError(_("You don't have access to Approve this request"))
def action_reject(self):
for rec in self:
if self.env.user.has_group('hr_attendance.group_hr_attendance_manager') or rec.employee_id.parent_id.id == self.env.user.id:
rec.state = 'rejected'
else:
raise ValidationError(_("You don't have access to Reject this request"))
def action_reset(self):
for rec in self:
if rec.employee_id.user_id.id == self.env.user.id:
rec.state = 'draft'
else:
raise ValidationError(_("Only Employee can Reset"))
@api.constrains('start_date', 'end_date')
def _check_dates(self):
for record in self:
if record.start_date >= record.end_date:
raise ValidationError("End Date must be greater than Start Date.")

View File

@ -1,3 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_attendance_attendance_user,attendance.attendance.access,model_attendance_attendance,base.group_user,1,1,1,1
access_attendance_data_user,attendance.data.access,model_attendance_data,base.group_user,1,1,1,1
access_on_duty_form_user,on_duty_form_user,model_on_duty_form,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_attendance_attendance_user attendance.attendance.access model_attendance_attendance base.group_user 1 1 1 1
3 access_attendance_data_user attendance.data.access model_attendance_data base.group_user 1 1 1 1
4 access_on_duty_form_user on_duty_form_user model_on_duty_form base.group_user 1 1 1 1

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="0">
<record id="on_duty_form_user_rule" model="ir.rule">
<field name="name">On Duty Form User Rule</field>
<field ref="model_on_duty_form" name="model_id"/>
<field name="domain_force">[('employee_id.user_id.id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="on_duty_form_manager_rule" model="ir.rule">
<field name="name">On Duty Form Manager Rule</field>
<field ref="model_on_duty_form" name="model_id"/>
<field name="domain_force">[('employee_id.parent_id.user_id.id','=',user.id),('state','!=','draft')]</field>
<field name="groups" eval="[(4, ref('hr.group_hr_user'))]"/>
</record>
<record id="on_duty_form_admin_rule" model="ir.rule">
<field name="name">On Duty Form Admin Rule</field>
<field ref="model_on_duty_form" name="model_id"/>
<field name="domain_force">[('state','!=','draft'),'|',('employee_id.parent_id.user_id.id','=',user.id),('employee_id.parent_id.user_id.id','!=',user.id)]</field>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
</data>
</odoo>

View File

@ -1,185 +0,0 @@
import { useService } from "@web/core/utils/hooks";
import { Component, xml, useState, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { getOrigin } from "@web/core/utils/urls";
export default class AttendanceReport extends Component {
static props = ['*'];
static template = 'attendance_report_template';
setup() {
super.setup(...arguments);
this.orm = useService("orm");
this.state = useState({
startDate: "", // To store the start date parameter
endDate: "",
attendanceData: [], // Initialized as an empty array
groupedData: [], // To store the grouped attendance data by employee_id
employeeIDS: [] // List of employee IDs to bind with select dropdown
});
onMounted(() => {
this.loademployeeIDS();
});
}
async loademployeeIDS() {
try {
const employee = await this.orm.searchRead('hr.employee', [], ['id', 'display_name']);
this.state.employeeIDS = employee;
this.initializeSelect2();
this.render();// Initialize Select2 after data is loaded
this.reload();
} catch (error) {
console.error("Error loading employeeIDS:", error);
}
}
// Initialize Select2 with error handling and ensuring it's initialized only once
initializeSelect2() {
const employeeIDS = this.state.employeeIDS;
// Ensure the <select> element is initialized only once
const $empSelect = $('#emp');
const from_date = $("#from_date").datepicker({
dateFormat: "dd/mm/yy", // Date format
showAnim: "slideDown", // Animation
changeMonth: true, // Allow month selection
changeYear: true, // Allow year selection
yearRange: "2010:2030", // Year range
// ... other options
});
const to_date = $("#to_date").datepicker({
dateFormat: "dd/mm/yy", // Date format
showAnim: "slideDown", // Animation
changeMonth: true, // Allow month selection
changeYear: true, // Allow year selection
yearRange: "2010:2030", // Year range
// ... other options
});
// Debugging the employeeIDS array to verify its structure
console.log("employeeIDS:", employeeIDS);
// Check if employeeIDS is an array and has the necessary properties
if (Array.isArray(employeeIDS) && employeeIDS.length > 0) {
// Clear the current options (if any)
$empSelect.empty();
// Add options for each employee
employeeIDS.forEach(emp => {
$empSelect.append(
`<option value="${emp.id}">${emp.display_name}</option>`
);
});
$empSelect.append(
`<option value="-">All</option>`
);
// Initialize the select with the 'multiple' attribute for multi-select
// $empSelect.attr('multiple', 'multiple');
// Enable tagging (you can manually add tags as well)
$empSelect.on('change', (ev) => {
const selectedEmployeeIds = $(ev.target).val();
console.log('Selected Employee IDs: ', selectedEmployeeIds);
const selectedEmployees = employeeIDS.filter(emp => selectedEmployeeIds.includes(emp.id.toString()));
console.log('Selected Employees: ', selectedEmployees);
});
} else {
console.error("Invalid employee data format:", employeeIDS);
}
}
// Method called when a date is selected in the input fields
async ExportToExcel() {
var startdate = $('#from_date').val()
var enddate = $('#to_date').val()
let domain = [
['check_in', '>=', startdate],
['check_in', '<=', enddate],
];
// If employee(s) are selected, filter the data by employee_id
if ($('#emp').val() && $('#emp').val().length > 0 && $('#emp').val() !== '-') {
domain.push(['employee_id', '=', parseInt($('#emp').val())]);
}
try {
debugger;
// Fetch the attendance data based on the date range and selected employees
const URL = await this.orm.call('attendance.report', 'export_to_excel', [$('#emp').val(), startdate, enddate]);
window.open(getOrigin()+URL, '_blank');
} catch (error) {
console.error("Error generating report:", error);
}
}
async generateReport() {
let { startDate, endDate, selectedEmployeeIds } = this.state;
startDate = $('#from_date').val()
endDate = $('#to_date').val()
if (!startDate || !endDate) {
alert("Please specify both start and end dates!");
return;
}
// Build the domain for the search query
let domain = [
['check_in', '>=', startDate],
['check_in', '<=', endDate],
];
// If employee(s) are selected, filter the data by employee_id
if ($('#emp').val() && $('#emp').val().length > 0 && $('#emp').val() !== '-') {
domain.push(['employee_id', '=', parseInt($('#emp').val())]);
}
try {
// Fetch the attendance data based on the date range and selected employees
// const attendanceData = await this.orm.searchRead('hr.attendance', domain, ['employee_id', 'check_in', 'check_out', 'worked_hours']);
const attendanceData = await this.orm.call('attendance.report','get_attendance_report',[$('#emp').val(),startDate,endDate]);
// Group data by employee_id
const groupedData = this.groupDataByEmployee(attendanceData);
// Update state with the fetched and grouped data
this.state.attendanceData = attendanceData;
this.state.groupedData = groupedData;
} catch (error) {
console.error("Error generating report:", error);
}
}
// Helper function to group data by employee_id
groupDataByEmployee(data) {
const grouped = {};
data.forEach(record => {
const employeeId = record.employee_id; // employee_id[1] is the name
if (employeeId) {
if (!grouped[employeeId]) {
grouped[employeeId] = [];
}
grouped[employeeId].push(record);
}
});
// Convert the grouped data into an array to be used in t-foreach
return Object.values(grouped);
}
}
// Register the action in the actions registry
registry.category("actions").add("AttendanceReport", AttendanceReport);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="attendance_report_template">
<script t-att-src="'/web/static/lib/jquery/jquery.js'"></script>
<script t-att-src="'/hr_attendance_extended/static/src/js/jquery-ui.min.js'"></script>
<link rel="stylesheet" type="text/css" href="/hr_attendance_extended/static/src/js/jquery-ui.min.css"/>
<style>
.ui-datepicker {
background-color: #f0f0f0;
}
.ui-state-highlight {
background-color: #ffcc00;
}
</style>
<div class="header pt-5" style="text-align:center">
<h1>Attendance Report</h1>
<div class="navbar navbar-expand-lg container">
<h4 class="p-3 text-nowrap">Employee </h4>
<div class="input-group input-group-lg">
<select type="text" id="emp" class="form-control" />
</div>
<h4 class="p-3 text-nowrap"> From Date</h4>
<div class="input-group input-group-lg">
<input type="text" id="from_date" class="form-control" value="DD/MM/YYYY"/>
</div>
<h4 class="p-3 text-nowrap"> To Date</h4>
<div class="input-group input-group-lg">
<input type="text" id="to_date" class="form-control" value="DD/MM/YYYY"/>
</div>
</div>
<button class="btn btn-outline-success" t-on-click="generateReport" >Generate Report</button>
<button class="btn btn-outline-success" t-on-click="ExportToExcel">Export Report</button>
<div t-if="this.state.groupedData.length > 0" style="max-height: 800px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
<div t-foreach="this.state.groupedData" t-as="group" t-key="group[0].employee_id">
<div class="employee-group">
<h3 style="text-align:left" class="p-2">
Employee:
<t t-if="group[0].employee_id">
<t t-esc="group[0].employee_name"/>
</t>
<t t-else="">
<span>Unknown Employee</span>
</t>
</h3>
<!-- Scrollable Container for the Table -->
<div class="scrollable-table-container">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Employee</th>
<th>Check In</th>
<th>Check Out</th>
<th>Worked Hours</th>
</tr>
</thead>
<tbody>
<tr t-foreach="group" t-as="data" t-key="data.id">
<td><t t-esc="data.date"/></td>
<td>
<t t-if="data.employee_id">
<t t-esc="data.employee_name"/>
</t>
<t t-else="">
<span>Unknown Employee</span>
</t>
</td>
<td><t t-esc="data.check_in"/></td>
<td><t t-esc="data.check_out"/></td>
<td><t t-esc="data.worked_hours"/></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div t-else="">
<p>No data available for the selected date range.</p>
</div>
</div>
</t>
</templates>

View File

@ -1,7 +0,0 @@
<odoo>
<record id="action_attendance_report_s" model="ir.actions.client">
<field name="name">Attendance Report</field>
<field name="tag">AttendanceReport</field>
</record>
<menuitem action="action_attendance_report_s" id="menu_hr_attendance_day" groups="hr_attendance.group_hr_attendance_officer" parent="hr_attendance_extended.menu_attendance_attendance"/>
</odoo>

View File

@ -0,0 +1,70 @@
<odoo>
<record id="view_on_duty_form_list" model="ir.ui.view">
<field name="name">on.duty.form.list</field>
<field name="model">on.duty.form</field>
<field name="arch" type="xml">
<list>
<field name="unique_id"/>
<field name="employee_id"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state" widget="badge" decoration-success="state == 'approved'" decoration-warning="state == 'rejected'" decoration-info="state == 'submitted'"/>
</list>
</field>
</record>
<record id="view_on_duty_form_form" model="ir.ui.view">
<field name="name">on.duty.form.form</field>
<field name="model">on.duty.form</field>
<field name="arch" type="xml">
<form string="On Duty Form">
<header>
<button name="action_submit" type="object" string="Submit" class="btn btn-primary"
invisible="state != 'draft'"/>
<button name="action_approve" type="object" string="Approve" class="btn btn-primary"
invisible="state != 'submitted'" groups="hr.group_hr_user"/>
<button name="action_reject" type="object" string="Reject"
invisible="state not in ['submitted','approved']" groups="hr.group_hr_user"/>
<button name="action_reset" type="object" string="Reset"
invisible="state != 'rejected'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,submitted"/>
</header>
<sheet>
<group>
<field name="unique_id" readonly="1"/>
<field name="employee_id" readonly="1"/>
<field name="department_id" readonly="1"/>
</group>
<group col="2"> <!-- Two-column layout -->
<field name="start_date" readonly="state != 'draft'"/>
<field name="end_date" readonly="state != 'draft'"/>
</group>
<group>
<field name="reason" readonly="state != 'draft'"/>
</group>
</sheet>
<chatter reload_on_post="True" reload_on_attachment="True"/>
</form>
</field>
</record>
<record id="action_on_duty_form" model="ir.actions.act_window">
<field name="name">On Duty Forms</field>
<field name="res_model">on.duty.form</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_on_duty_form_root"
name="On Duty Forms"
parent="hr_attendance.menu_hr_attendance_root"
sequence="10"/>
<!-- Menu for Attendance -->
<menuitem id="menu_on_duty_form"
name="On Duty Requests"
action="action_on_duty_form"
parent="menu_on_duty_form_root"
sequence="20"/>
</odoo>

View File

@ -7,12 +7,12 @@
<field name="sequence">17</field>
</record>
<record id="group_external_user" model="res.groups">
<field name="name">External User</field>
<field name="name">Client-Side User</field>
<field name="category_id" ref="hr_employee_extended.module_internal_user_category"/>
</record>
<record id="group_internal_user" model="res.groups">
<field name="name">Internal User</field>
<field name="name">In-House User</field>
<field name="implied_ids" eval="[(4, ref('group_external_user'))]"/>
<field name="category_id" ref="hr_employee_extended.module_internal_user_category"/>
</record>

View File

@ -25,6 +25,7 @@
'security/security.xml',
'security/ir.model.access.csv',
'data/cron.xml',
'data/data.xml',
'data/sequence.xml',
'data/mail_template.xml',
'views/job_category.xml',
@ -43,6 +44,8 @@
'views/skills.xml',
'wizards/post_onboarding_attachment_wizard.xml',
'wizards/applicant_refuse_reason.xml',
'wizards/ats_invite_mail_template_wizard.xml',
'wizards/client_submission_mail_template_wizard.xml',
# 'views/resume_pearser.xml',
],
'assets': {
@ -51,6 +54,7 @@
],
'web.assets_frontend': [
'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js',
'hr_recruitment_extended/static/src/js/pre_onboarding_attachment_requests.js',
'hr_recruitment_extended/static/src/js/post_onboarding_form.js',
],
}

View File

@ -84,6 +84,64 @@ class website_hr_recruitment_applications(http.Controller):
@http.route(['/FTPROTECH/DocRequests/<int:applicant_id>'], type='http', auth="public",
website=True)
def doc_request_form(self, applicant_id, **kwargs):
"""Renders the website form for applicants to submit additional details."""
applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists():
return request.not_found()
if applicant:
if applicant.doc_requests_form_status == 'done':
return request.render("hr_recruitment_extended.thank_you_template")
else:
return request.render("hr_recruitment_extended.doc_request_form_template", {
'applicant': applicant
})
else:
return request.not_found()
@http.route(['/FTPROTECH/submit/<int:applicant_id>/docRequest'], type='http', auth="public",
methods=['POST'], website=True, csrf=False)
def process_applicant_doc_submission_form(self, applicant_id, **post):
applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists():
return request.not_found() # Return 404 if applicant doesn't exist
if applicant.doc_requests_form_status == 'done':
return request.render("hr_recruitment_extended.thank_you_template")
applicant_data = {
'applicant_id': int(post.get('applicant_id', 0)),
'candidate_image': post.get('candidate_image_base64', ''),
'doc_requests_form_status': 'done'
}
applicant_data = {k: v for k, v in applicant_data.items() if v != '' and v != 0}
# attachments
attachments_data_json = post.get('attachments_data_json', '[]')
attachments_data = json.loads(attachments_data_json) if attachments_data_json else []
if attachments_data:
applicant_data['joining_attachment_ids'] = [
(4, existing_id) for existing_id in
(applicant.joining_attachment_ids).ids
] + [
(0, 0, {
'name': attachment.get('file_name', ''),
'recruitment_attachment_id': attachment.get(
'attachment_rec_id', ''),
'file': attachment.get('file_content', '')
}) for attachment in attachments_data if
attachment.get('attachment_rec_id')
]
applicant.write(applicant_data)
return request.render("hr_recruitment_extended.thank_you_template")
@http.route(['/FTPROTECH/JoiningForm/<int:applicant_id>'], type='http', auth="public",
website=True)
@ -102,6 +160,7 @@ class website_hr_recruitment_applications(http.Controller):
else:
return request.not_found()
@http.route(['/FTPROTECH/submit/<int:applicant_id>/JoinForm'], type='http', auth="public",
methods=['POST'], website=True, csrf=False)
def process_employee_joining_form(self,applicant_id,**post):
@ -209,12 +268,17 @@ class website_hr_recruitment_applications(http.Controller):
if attachments_data:
applicant_data['joining_attachment_ids'] = [
(0,0,{
'name': attachment.get('file_name',''),
'recruitment_attachment_id': attachment.get('attachment_rec_id',''),
'file': attachment.get('file_content','')
}) for attachment in attachments_data if attachment.get('attachment_rec_id')
]
(4, existing_id) for existing_id in
(applicant.joining_attachment_ids).ids
] + [
(0, 0, {
'name': attachment.get('file_name', ''),
'recruitment_attachment_id': attachment.get(
'attachment_rec_id', ''),
'file': attachment.get('file_content', '')
}) for attachment in attachments_data if
attachment.get('attachment_rec_id')
]
applicant.write(applicant_data)

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="1">
<!-- employee.recruitment.attachments-->
<record model="ir.attachment" id="employee_recruitment_attachments_preview">
<field name="name">Attachment Preview</field>
<field name="type">binary</field>
</record>
</data>
</odoo>

View File

@ -191,6 +191,104 @@
<field name="auto_delete" eval="True"/>
</record>
<record id="email_template_request_documents" model="mail.template">
<field name="name">Document Submission Request</field>
<field name="model_id" ref="hr_recruitment.model_hr_applicant"/>
<field name="email_from">{{ user.company_id.email or user.email_formatted }}</field>
<field name="email_to">{{ object.email_from }}</field>
<field name="subject">Important: Document Submission Request | FTPROTECH</field>
<field name="description">
Email requesting necessary documents from applicants.
</field>
<field name="body_html" type="html">
<t t-set="applicant_name" t-value="object.candidate_id.partner_name or 'Applicant'"/>
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; padding: 20px; line-height: 1.6;">
<p>Dear
<strong>
<t t-esc="applicant_name">Applicant</t>
</strong>
,
</p>
<p>We hope you are doing well! As part of our recruitment process, we kindly request you to submit
the following documents at the earliest.
</p>
<t t-if="ctx.get('personal_docs') or ctx.get('education_docs') or ctx.get('previous_employer_docs') or ctx.get('other_docs')">
<p>Please ensure to provide soft copies of the required documents:</p>
<!-- Personal Documents -->
<t t-if="ctx.get('personal_docs')">
<strong>Personal Documents:</strong>
<ul>
<t t-foreach="ctx.get('personal_docs')" t-as="doc">
<li t-esc="doc"/>
</t>
</ul>
</t>
<!-- Education Documents -->
<t t-if="ctx.get('education_docs')">
<strong>Education Documents:</strong>
<ul>
<t t-foreach="ctx.get('education_docs')" t-as="doc">
<li t-esc="doc"/>
</t>
</ul>
</t>
<!-- Previous Employer Documents -->
<t t-if="ctx.get('previous_employer_docs')">
<strong>Previous Employer Documents:</strong>
<ul>
<t t-foreach="ctx.get('previous_employer_docs')" t-as="doc">
<li t-esc="doc"/>
</t>
</ul>
</t>
<!-- Additional Documents -->
<t t-if="ctx.get('other_docs')">
<strong>Additional Documents:</strong>
<ul>
<t t-foreach="ctx.get('other_docs')" t-as="doc">
<li t-esc="doc"/>
</t>
</ul>
</t>
</t>
<p>Please upload your documents via the following link:</p>
<t t-set="base_url" t-value="object.env['ir.config_parameter'].sudo().get_param('web.base.url')"/>
<t t-set="upload_url" t-value="base_url + '/FTPROTECH/DocRequests/%s' % object.id"/>
<p style="text-align: center; margin-top: 20px;">
<a t-att-href="upload_url" target="_blank"
style="background-color: #007bff; color: #fff; padding: 10px 20px; text-decoration: none;
font-weight: bold; border-radius: 5px; display: inline-block;">
Upload Your Documents
</a>
</p>
<p>If you have any questions or need assistance, please contact us at
<a href="mailto:hr@ftprotech.com" style="color: #007bff; text-decoration: none;">
hr@ftprotech.com</a>.
</p>
<p>We appreciate your cooperation and look forward to completing the process smoothly.</p>
<p>Best Regards,
<br/>
<strong>
<t t-esc="object.company_id.name or 'HR Team'">HR Team</t>
</strong>
</p>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="email_template_post_onboarding_form" model="mail.template">
<field name="name">Joining Formalities Notification</field>
@ -421,5 +519,104 @@
<field name="auto_delete" eval="True"/>
</record>
<record id="email_template_recruiter_assignment_template" model="mail.template">
<field name="name">Recruitment Assignment Notification</field>
<field name="model_id" ref="model_hr_job_recruitment"/>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.user_id.email }}</field>
<field name="subject">Job Assignment - {{ object.job_id.name }}</field>
<field name="description">
Notification to recruiter regarding new job requisition assignment.
</field>
<field name="body_html" type="html">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear <t t-esc="ctx['recruiter_name']">Recruiter</t>,
<br/>
<br/>
A new job requisition has been assigned to you.
<br/>
<br/>
<ul>
<t t-if="object.recruitment_sequence">
<li>
<strong>Job ID:</strong>
<t t-out="object.recruitment_sequence"/>
</li>
</t>
<t t-if="object.job_id">
<li>
<strong>Job Title:</strong>
<t t-out="object.job_id.name"/>
</li>
</t>
<t t-if="object.job_priority">
<li>
<strong>Priority Level:</strong>
<t t-out="object.job_priority"/>
</li>
</t>
<t t-if="object.locations and object.locations.mapped('location_name')">
<li>
<strong>Location:</strong>
<t t-out="', '.join(object.locations.mapped('location_name'))"/>
</li>
</t>
<t t-if="object.budget">
<li>
<strong>Budget:</strong>
<t t-out="object.budget"/>
</li>
</t>
<t t-if="object.id">
<li>
<strong>Job Description:</strong>
<a t-att-href="'%s/web#id=%d&amp;model=hr.job.recruitment&amp;action=%s&amp;view_type=form' %
(object.env['ir.config_parameter'].sudo().get_param('web.base.url'), object.id, object.env.ref('hr_recruitment_extended.action_hr_job_recruitment').id)"
target="_blank">
View Job Details
</a>
</li>
</t>
</ul>
<br/>
Kindly review the requisition and start sourcing suitable candidates in the ATS.
<br/>
<br/>
Regards,
<br/>
<t t-out="user.name or 'Hiring Manager'">Hiring Manager</t>
</p>
</field>
</record>
<record id="application_client_submission_email_template" model="mail.template">
<field name="name">Applicant Client Submissions</field>
<field name="model_id" ref="hr_recruitment.model_hr_applicant"/>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.hr_job_recruitment.requested_by.email }}</field>
<field name="subject">Applicant Submission</field>
<field name="description">
Submitting the Applicant Details to Client.
</field>
<field name="body_html" type="html">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear <t t-esc="ctx['client_name']">Sir/Madam</t>,
<br/>
<br/>
Submitting new applicant.
<br/>
Kindly review the Applicant.
<br/>
<br/>
Regards,
<br/>
<t t-out="user.name or 'Hiring Manager'">Hiring Manager</t>
</p>
</field>
</record>
</data>
</odoo>

View File

@ -20,6 +20,12 @@ class HRApplicant(models.Model):
refused_stage = fields.Many2one('hr.recruitment.stage')
refused_comments = fields.Text()
@api.constrains('candidate_id','hr_job_recruitment')
def hr_applicant_constrains(self):
for rec in self:
if rec.candidate_id and rec.hr_job_recruitment:
self.sudo().search([('candidate_id','=',rec.candidate_id.id),('hr_job_recruitment','=',rec.hr_job_recruitment.id),('id','!=',rec.id)])
@api.model
def _read_group_recruitment_stage_ids(self, stages, domain):
# retrieve job_id from the context and write the domain: ids + contextual columns (job or default)
@ -101,7 +107,7 @@ class HRApplicant(models.Model):
second_application_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft')
send_post_onboarding_form = fields.Boolean(related='recruitment_stage_id.post_onboarding_form')
post_onboarding_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft')
doc_requests_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft')
legend_blocked = fields.Char(related='recruitment_stage_id.legend_blocked', string='Kanban Blocked')
legend_done = fields.Char(related='recruitment_stage_id.legend_done', string='Kanban Valid')
legend_normal = fields.Char(related='recruitment_stage_id.legend_normal', string='Kanban Ongoing')
@ -129,11 +135,19 @@ class HRApplicant(models.Model):
warnings.warn(
"Max no of submissions for this JD has been reached",
DeprecationWarning,
stacklevel=2,
)
rec.submitted_to_client = True
rec.client_submission_date = fields.Datetime.now()
rec.submitted_stage = rec.recruitment_stage_id.id
return {
'type': 'ir.actions.act_window',
'name': 'Submission',
'res_model': 'client.submission.mails.template.wizard',
'view_mode': 'form',
'view_id': self.env.ref('hr_recruitment_extended.view_client_submission_mails_template_wizard_form').id,
'target': 'new',
'context': {'default_template_id': self.env.ref(
"hr_recruitment_extended.application_client_submission_email_template").id,
},
}
def submit_for_approval(self):
for rec in self:
@ -208,7 +222,16 @@ class HRApplicant(models.Model):
}
def send_pre_onboarding_doc_request_form_to_candidate(self):
return {
'type': 'ir.actions.act_window',
'name': 'Select Attachments',
'res_model': 'post.onboarding.attachment.wizard',
'view_mode': 'form',
'view_type': 'form',
'target': 'new',
'context': {'default_attachment_ids': [],'default_is_pre_onboarding_attachment_request': True}
}
def _track_template(self, changes):
res = super(HRApplicant, self)._track_template(changes)
applicant = self[0]

View File

@ -158,7 +158,7 @@ class HRJobRecruitment(models.Model):
'res.partner', "Job Location", default=_default_address_id,
domain="[('is_company','=',True),('contact_type','=',recruitment_type)]",
help="Select the location where the applicant will work. Addresses listed here are defined on the company's contact information.", exportable=False)
recruitment_type = fields.Selection([('internal','Internal'),('external','External')], required=True, default='internal')
recruitment_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='internal')
requested_by = fields.Many2one('res.partner', string="Requested By",
default=lambda self: self.env.user.partner_id, domain="[('contact_type','=',recruitment_type)]")
@ -167,6 +167,26 @@ class HRJobRecruitment(models.Model):
self.requested_by = False
self.address_id = False
def send_mail_to_recruiters(self):
for rec in self:
""" Open the email wizard """
users = rec.interviewer_ids.ids
primary_user = rec.user_id.id
# template = self.env.ref('hr_recruitment_extended.email_template_recruiter_assignment_template')
# template.sudo().send_mail(rec.id, force_send=True)
users.append(primary_user)
return {
'type': 'ir.actions.act_window',
'name': 'Send Email',
'res_model': 'ats.invite.mail.template.wizard',
'view_mode': 'form',
'view_id': self.env.ref('hr_recruitment_extended.view_ats_invite_mail_template_wizard_form').id,
'target': 'new',
'context': {'default_partner_ids': [(6, 0, users)],'default_template_id': self.env.ref("hr_recruitment_extended.email_template_recruiter_assignment_template").id,
},
}
@api.onchange('requested_by')
def _onchange_requested_by(self):
for rec in self:
@ -209,7 +229,7 @@ class HRJobRecruitment(models.Model):
job_category = fields.Many2one("job.category", string="Category")
job_priority = fields.Selection([('low','Low'),('medium','Medium'),('high','High')], string="Pirority")
@api.onchange('job_id','job_category')
def onchange_job_id(self):

View File

@ -199,8 +199,8 @@ class HRApplicant(models.Model):
relevant_exp = fields.Float(string="Relevant Experience")
total_exp_type = fields.Selection([('month',"Month's"),('year',"Year's")], default='year')
relevant_exp_type = fields.Selection([('month',"Month's"),('year',"Year's")], default='year')
notice_period = fields.Integer(string="Notice Period")
notice_period_type = fields.Selection([('day',"Day's"),('month',"Month's"),('year',"Year's")], string='Type', default='day')
notice_period = fields.Char(string="Notice Period")
notice_period_type = fields.Selection([('day',"Day's"),('month',"Month's"),('year',"Year's")], string='Type', default='day', invisible=True)
current_ctc = fields.Float(string="Current CTC", aggregator="avg", help="Applicant Current Salary", tracking=True, groups="hr_recruitment.group_hr_recruitment_user")
salary_expected = fields.Float("Expected CTC", aggregator="avg", help="Salary Expected by Applicant", tracking=True, groups="hr_recruitment.group_hr_recruitment_user")

View File

@ -22,8 +22,21 @@ class EmployeeRecruitmentAttachments(models.Model):
recruitment_attachment_id = fields.Many2one('recruitment.attachments')
recruitment_attachment_type = fields.Selection([('personal','Personal Documents'),('education','Education Documents'),('previous_employer','Previous Employer'),('others','Others')],related='recruitment_attachment_id.attachment_type')
file = fields.Binary(string='File', required=True)
review_status = fields.Selection([('draft','Under Review'),('pass','PASS'),('fail','FAIL')], default='draft')
review_comments = fields.Char()
def action_preview_file(self):
""" Returns a URL to preview the attachment in a popup """
for record in self:
if record.file:
attachment = self.env.ref("hr_recruitment_extended.employee_recruitment_attachments_preview")
attachment.datas = record.file
return {
'name': "File Preview",
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=false',
'target': 'current', # Opens in a new tab
}
@api.model
def create(self, vals):

View File

@ -4,4 +4,4 @@ from odoo import models, fields, api, _
class ResPartner(models.Model):
_inherit = 'res.partner'
contact_type = fields.Selection([('internal','Internal'),('external','External')], required=True, default='external')
contact_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='external')

View File

@ -25,4 +25,8 @@ hr_recruitment.access_hr_applicant_interviewer,hr.applicant.interviewer,hr_recru
hr_recruitment.access_hr_recruitment_stage_user,hr.recruitment.stage.user,hr_recruitment.model_hr_recruitment_stage,hr_recruitment.group_hr_recruitment_user,1,1,1,0
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25
26
27
28
29
30
31
32

View File

@ -0,0 +1,436 @@
import publicWidget from "@web/legacy/js/public/public_widget";
import { _t } from "@web/core/l10n/translation";
import { rpc } from "@web/core/network/rpc";
import { assets, loadCSS, loadJS } from "@web/core/assets";
publicWidget.registry.hrRecruitmentDocs = publicWidget.Widget.extend({
selector: "#doc_request_form",
events: {
"change [name='candidate_image']": "previewApplicantPhoto",
"click #delete-photo-btn": "deleteCandidatePhoto",
"click #preview-photo-btn": "previewFullImage",
'change .attachment-input': 'handleAttachmentUpload',
'click .upload-new-btn': 'handleUploadNewFile',
'click .delete-file-btn': 'handleDeleteUploadedFile',
'input .file-name-input': 'handleFileNameChange',
"click .remove-file": "removeFile",
"click .preview-file": "previewFile",
"click .view-attachments-btn": "openAttachmentModal", // Opens modal with files
"click .close-modal-btn": "closeAttachmentModal", // Close modal
"submit": "handleFormSubmit",
},
uploadedFiles: {}, // Store files per attachment ID
addUploadedFileRow(attachmentId, file, base64String) {
const tableBody = this.$(`#preview_body_${attachmentId}`);
if (!this.uploadedFiles[attachmentId]) {
this.uploadedFiles[attachmentId] = [];
}
// Generate a unique file ID using attachmentId and a timestamp
const fileId = `${attachmentId}-${Date.now()}`;
const fileRecord = {
attachment_rec_id : attachmentId,
id: fileId, // Unique file ID
name: file.name,
base64: base64String,
type: file.type,
};
this.uploadedFiles[attachmentId].push(fileRecord);
const fileIndex = this.uploadedFiles[attachmentId].length - 1;
const previewImageId = `preview_image_${fileId}`;
const fileNameInputId = `file_name_input_${fileId}`;
let previewContent = '';
let previewClickHandler = '';
// Check if the file is an image or PDF and set preview content accordingly
if (file.type.startsWith('image/')) {
previewContent = `<div class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;">
<img src="data:image/png;base64,${base64String}" style="max-width: 100%; max-height: 100%; object-fit: contain; cursor: pointer;" />
</div>`;
previewClickHandler = () => {
this.$('#modal_attachment_photo_preview').attr('src', `data:image/png;base64,${base64String}`);
this.$('#modal_attachment_photo_preview').show();
this.$('#modal_attachment_pdf_preview').hide(); // Hide PDF preview
this.$('#attachmentPreviewModal').modal('show');
};
} else if (file.type === 'application/pdf') {
previewContent = `<div class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;">
<iframe src="data:application/pdf;base64,${base64String}" style="width: 100%; height: 100%; border: none; cursor: pointer;"></iframe>
</div>`;
previewClickHandler = () => {
this.$('#modal_attachment_pdf_preview').attr('src', `data:application/pdf;base64,${base64String}`);
this.$('#modal_attachment_pdf_preview').show();
this.$('#modal_attachment_photo_preview').hide(); // Hide image preview
this.$('#attachmentPreviewModal').modal('show');
};
}
// Append new row to the table with a preview and buttons
tableBody.append(`
<tr data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<td>
<input type="text" class="form-control file-name-input" id="${fileNameInputId}" value="${file.name}"/>
</td>
<td class="text-center">
<div class="d-flex flex-column align-items-center justify-content-center gap-2">
${previewContent}
<div class="d-flex gap-2">
<button type="button" class="btn btn-danger btn-sm delete-file-btn" data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<i class="fa fa-trash"></i>
</button>
<button type="button" class="btn btn-info btn-sm preview-btn" data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<i class="fa fa-eye"></i>
</button>
</div>
</div>
</td>
</tr>
`);
this.$(`#preview_table_container_${attachmentId}`).removeClass('d-none');
// Attach click handler for preview (image or PDF)
this.$(`#preview_wrapper_${fileId}`).on('click', previewClickHandler);
// Attach click handler for the preview button (to trigger modal)
this.$(`.preview-btn[data-attachment-id="${attachmentId}"][data-file-id="${fileId}"]`).on('click', previewClickHandler);
},
handleDeleteUploadedFile(ev) {
const button = ev.currentTarget;
const attachmentId = $(button).data('attachment-id');
const fileId = $(button).data('file-id');
// Find the index of the file to delete based on unique file ID
const fileIndex = this.uploadedFiles[attachmentId].findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.uploadedFiles[attachmentId].splice(fileIndex, 1); // Remove from array
}
// Remove the row from DOM
this.$(`tr[data-file-id="${fileId}"]`).remove();
// Hide table if no files left
if (this.uploadedFiles[attachmentId].length === 0) {
this.$(`#preview_table_container_${attachmentId}`).addClass('d-none');
}
},
handleAttachmentUpload(ev) {
const input = ev.target;
const attachmentId = $(input).data('attachment-id');
if (input.files.length > 0) {
Array.from(input.files).forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target.result.split(',')[1];
this.addUploadedFileRow(attachmentId, file, base64String);
};
reader.readAsDataURL(file);
});
}
},
handleUploadNewFile(ev) {
console.log("hello upload file");
const button = $(ev.currentTarget);
const attachmentId = button.data('attachment-id');
const index = button.data('index');
// Find the hidden file input specific to this attachmentId
const hiddenInput = this.$(`.upload-new-file-input[data-attachment-id='${attachmentId}']`);
// When file is selected, update preview and replace old file
hiddenInput.off('change').on('change', (e) => {
const file = e.target.files[0];
if (!file) {
return; // Do nothing if no file is selected
}
const reader = new FileReader();
reader.onload = (event) => {
const base64String = event.target.result.split(',')[1];
// Replace the existing file in uploadedFiles
this.uploadedFiles[attachmentId][index] = {
name: file.name,
base64: base64String,
};
// Update the preview image in the table
const imageId = `preview_image_${attachmentId}_${index}`;
const filePreviewWrapperId = `preview_wrapper_${attachmentId}_${index}`;
// Check if the file is an image or PDF and update accordingly
const fileType = file.type;
if (fileType.startsWith('image/')) {
this.$(`#${filePreviewWrapperId}`).html(`
<img id="${imageId}" src="data:image/png;base64,${base64String}" class="img-thumbnail"
style="width: 80px; height: 80px; object-fit: cover; cursor: pointer;" />
`);
} else if (fileType === 'application/pdf') {
this.$(`#${filePreviewWrapperId}`).html(`
<iframe src="data:application/pdf;base64,${base64String}" width="80" height="80" class="img-thumbnail" style="border: none; cursor: pointer;"></iframe>
`);
}
// Optional: Update the file name in the input field
const fileNameInputId = `file_name_input_${attachmentId}_${index}`;
this.$(`#${fileNameInputId}`).val(file.name);
};
reader.readAsDataURL(file);
});
// Trigger the hidden file input to open the file selection dialog
console.log("Triggering file input...");
hiddenInput.trigger('click'); // Ensure this is working properly
},
handleFileNameChange(event) {
const attachmentId = $(event.target).closest('tr').data('attachment-id');
const fileId = $(event.target).closest('tr').data('file-id');
const newFileName = event.target.value;
if (!attachmentId || !fileId) {
console.error('Missing attachmentId or fileId');
return;
}
const fileList = this.uploadedFiles[attachmentId];
if (!fileList) {
console.error(`No files found for attachmentId: ${attachmentId}`);
return;
}
const fileRecord = fileList.find(file => file.id === fileId);
if (!fileRecord) {
console.error(`File with ID ${fileId} not found under attachment ${attachmentId}`);
return;
}
fileRecord.name = newFileName;
},
renderFilePreview(attachmentId) {
const container = this.$(`#preview_container_${attachmentId}`);
container.empty();
if (this.uploadedFiles[attachmentId].length === 0) {
container.addClass("d-none");
return;
}
container.removeClass("d-none");
this.uploadedFiles[attachmentId].forEach((file, index) => {
const fileHtml = $(`
<div class="d-flex flex-column align-items-center">
<div class="position-relative">
<img src="${file.base64}" class="rounded-circle shadow-sm" style="width: 80px; height: 80px; object-fit: cover; border: 1px solid #ddd; cursor: pointer;" data-index="${index}" data-attachment-id="${attachmentId}" />
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 remove-file" data-attachment-id="${attachmentId}" data-index="${index}" style="transform: translate(50%, -50%);"><i class="fa fa-trash"></i></button>
</div>
<small>${file.name}</small>
</div>
`);
fileHtml.find("img").on("click", this.previewAttachmentImage.bind(this));
fileHtml.find(".remove-file").on("click", this.removeFile.bind(this));
container.append(fileHtml);
});
},
previewAttachmentImage(ev) {
const attachmentId = $(ev.currentTarget).data("attachment-id");
const index = $(ev.currentTarget).data("index");
const fileData = this.uploadedFiles[attachmentId][index];
this.$("#attachment_modal_preview").attr("src", fileData.base64);
this.$("#attachmentPreviewModal").modal("show");
},
removeFile(ev) {
const attachmentId = $(ev.currentTarget).data("attachment-id");
const index = $(ev.currentTarget).data("index");
this.uploadedFiles[attachmentId].splice(index, 1);
this.renderFilePreview(attachmentId);
},
previewFile(ev) {
const fileUrl = ev.currentTarget.dataset.fileUrl;
const modal = this.$("#photoPreviewModal");
this.$("#modal_photo_preview").attr("src", fileUrl);
modal.modal("show");
},
/**
* Open modal and display uploaded files
*/
openAttachmentModal(event) {
console.log("openAttachmentModal");
const rowId = $(event.currentTarget).closest("tr").index(); // Get the row index
this.currentRowId = rowId; // Store rowId for reference
this.renderAttachmentModal(rowId); // Render the modal for the row
},
renderAttachmentModal(rowId) {
const fileList = this.uploadedFiles && this.uploadedFiles[rowId] ? this.uploadedFiles[rowId] : [];
let modalHtml = `
<div id="attachmentModal" class="modal fade show" tabindex="-1" style="display: block; background: rgba(0,0,0,0.5);" aria-modal="true">
<div class="modal-dialog modal-lg" style="max-width: 600px;">
<div class="modal-content" style="border-radius: 8px; box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.2);">
<!-- Modal Header -->
<div class="modal-header" style="background: #143d5d; color: white; border-bottom: 2px solid #dee2e6; font-weight: bold; padding: 12px 15px;">
<h5 class="modal-title" style="margin: 0;">Uploaded Attachments</h5>
<button type="button" class="close close-modal-btn" data-dismiss="modal" aria-label="Close" style="background: none; border: none; font-size: 20px; color: white; cursor: pointer;">
&times;
</button>
</div>
<!-- Modal Body -->
<div class="modal-body" style="padding: 15px;">
<ul class="attachment-list" style="list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto;">
${fileList.length ? fileList.map((file, index) => `
<li style="display: flex; justify-content: space-between; align-items: center; padding: 10px 15px; border: 1px solid #dee2e6; border-radius: 5px; margin-bottom: 8px; background: #f8f9fa;">
<span style="font-weight: 500; flex-grow: 1;">${file.name}</span>
<button type="button" class="remove-file" data-index="${index}"
style="background: #dc3545; color: white; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 14px;">
<i style="margin-right: 5px;">🗑</i>
</button>
</li>
`).join("") : `<p style="color: #6c757d; text-align: center; font-size: 16px; padding: 10px;">No files uploaded.</p>`}
</ul>
</div>
<!-- Modal Footer -->
<div class="modal-footer" style="border-top: 1px solid #dee2e6; padding: 12px;">
<button type="button" class="btn close-modal-btn" data-dismiss="modal"
style="background: #6c757d; color: white; border: none; padding: 8px 15px; border-radius: 5px; cursor: pointer; font-size: 14px;">
Close
</button>
</div>
</div>
</div>
</div>`;
// Remove old modal and append new one
$("#attachmentModal").remove();
$("body").append(modalHtml);
// Attach remove event to new modal content
$("#attachmentModal").on("click", ".remove-file", this.removeFile.bind(this));
$("#attachmentModal").on("click", ".close-modal-btn", this.closeAttachmentModal.bind(this));
},
/**
* Close attachment modal
*/
closeAttachmentModal() {
console.log("Closing modal");
$("#attachmentModal").remove(); // Remove the modal from the DOM
},
// Function to preview the uploaded image
previewApplicantPhoto(ev) {
const input = ev.currentTarget;
const preview = this.$("#photo_preview");
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target.result.split(",")[1]; // Get only Base64 part
preview.attr("src", e.target.result);
// Store the base64 in a hidden input field
this.$("input[name='candidate_image_base64']").val(base64String);
};
reader.readAsDataURL(input.files[0]);
}
},
// Function to delete the uploaded image
deleteCandidatePhoto() {
const preview = this.$("#photo_preview");
const inputFile = this.$("input[name='candidate_image']");
preview.attr("src", "data:image/png;base64,"); // Reset preview
inputFile.val(""); // Reset file input
},
// Function to preview full image inside a modal
previewFullImage() {
const previewSrc = this.$("#photo_preview").attr("src");
if (previewSrc) {
this.$("#modal_photo_preview").attr("src", previewSrc);
this.$("#photoPreviewModal").modal("show"); // Use jQuery to show the modal
}
},
handleFormSubmit(ev) {
ev.preventDefault();
let attachments = [];
let fileReadPromises = [];
let attachmentInputs = this.uploadedFiles; // your object {1: Array(1), 2: Array(2)}
Object.keys(attachmentInputs).forEach(key => {
let filesArray = attachmentInputs[key]; // This is an array
filesArray.forEach(file => {
attachments.push({
attachment_rec_id: file.attachment_rec_id,
file_name: file.name,
file_content: file.base64, // Assuming base64 is already present
});
});
});
Promise.all(fileReadPromises).then(() => {
this.$("#attachments_data_json").val(JSON.stringify(attachments));
let formElement = this.$el.is("form") ? this.$el[0] : this.$el.find("form")[0];
if (formElement) {
formElement.submit();
} else {
console.error("Form element not found.");
}
}).catch((error) => {
console.error("Error reading files:", error);
});
},
start() {
this._super(...arguments);
this.$el.on("submit", this.handleFormSubmit.bind(this));
},
});

View File

@ -50,6 +50,7 @@
<field name="send_post_onboarding_form"/>
<field name="post_onboarding_form_status" readonly="not send_post_onboarding_form"/>
<field name="doc_requests_form_status" readonly="1"/>
</xpath>
<xpath expr="//field[@name='stage_id']" position="attributes">
@ -69,6 +70,10 @@
<button string="Send Post Onboarding Form" name="send_post_onboarding_form_to_candidate" type="object"
groups="hr.group_hr_user"
invisible="not employee_id or not send_post_onboarding_form or post_onboarding_form_status in ['email_sent_to_candidate','done']"/>
<button string="Request Documents" name="send_pre_onboarding_doc_request_form_to_candidate" type="object"
groups="hr.group_hr_user"
invisible="doc_requests_form_status in ['email_sent_to_candidate'] or send_post_onboarding_form"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page name="Attachments" id="attachment_ids_page">
@ -108,11 +113,18 @@
<!-- Group for One2many field with Full Width -->
<group string="Post Onboarding Attachments" colspan="2">
<field name="joining_attachment_ids" nolabel="1">
<list editable="bottom" default_group_by="recruitment_attachment_id">
<list editable="bottom" default_group_by="recruitment_attachment_id" decoration-success="review_status == 'pass'" decoration-danger="review_status == 'fail'">
<field name="recruitment_attachment_id"/>
<field name="name"/>
<field name="recruitment_attachment_type"/>
<field name="file" widget="binary" options="{'download':true}"/>
<button name="action_preview_file"
type="object"
string="Preview"
class="oe_highlight"
icon="fa-eye"/>
<field name="review_status"/>
<field name="review_comments" optional="hide"/>
</list>
</field>
</group>

View File

@ -404,15 +404,6 @@
<field name="employee_id" invisible="1"/>
</group>
</group>
<!-- <group>-->
<!-- <field name="attachments" widget="one2many_list">-->
<!-- <list editable="bottom">-->
<!-- <field name="name"/>-->
<!-- <field name="file"/>-->
<!-- </list>-->
<!-- </field>-->
<!-- </group>-->
</sheet>
</form>
</field>

View File

@ -3,7 +3,7 @@
<data>
<record id="view_hr_job_recruitment_tree" model="ir.ui.view">
<field name="name">hr.job.recruitment.form</field>
<field name="name">hr.job.recruitment.list</field>
<field name="model">hr.job.recruitment</field>
<field name="arch" type="xml">
<list js_class="recruitment_list_view">
@ -24,6 +24,7 @@
<field name="alias_id" invisible="not alias_name" column_invisible="True" optional="hide"/>
<field name="user_id" widget="many2one_avatar_user" optional="hide"/>
<field name="no_of_employee"/>
<field name="job_priority" optional="hide"/>
<field name="submission_status" optional="hide"/>
</list>
@ -51,7 +52,9 @@
<!-- Add the recruitment_sequence field into the form -->
<form string="Job" js_class="recruitment_form_view">
<header/> <!-- inherited in other module -->
<header>
<button name="send_mail_to_recruiters" type="object" string="Send Recruiters Notification" class="oe_highlight" groups="hr_recruitment.group_hr_recruitment_user"/>
</header> <!-- inherited in other module -->
<field name="active" invisible="1"/>
<field name="company_id" invisible="1" on_change="1" can_create="True" can_write="True"/>
<sheet>
@ -108,7 +111,14 @@
<field name="recruitment_sequence" readonly="0" force_save="1"/>
<group>
<field name="job_id" string="Job Position"/>
<field name="job_category" force_save="1"/>
</group>
<group>
<group>
<field name="job_category" force_save="1"/>
</group>
<group>
<field name="job_priority"/>
</group>
</group>
</div>
@ -339,6 +349,10 @@
</button>
</div>
<br/><br/>
<div t-if="record.job_priority.value">
<strong>Priority : </strong> <field name="job_priority"/>
</div>
<br/>
<div t-if="record.budget.value">
<strong>Budget : </strong> <field name="budget"/>
</div>

View File

@ -310,96 +310,12 @@
</div>
</t>
</xpath>
<!-- <t t-if="record.applicant_ids and record.applicant_ids.value">-->
<!-- <field name="applicant_ids" widget="kanban_one2many" options="{'display_field': ['job_id']}" />-->
<!--&lt;!&ndash; <t t-foreach="record.applicant_ids.value" t-as="application">&ndash;&gt;-->
<!--&lt;!&ndash; <div class="d-flex align-items-center">&ndash;&gt;-->
<!--&lt;!&ndash; <span class="badge bg-info">&ndash;&gt;-->
<!--&lt;!&ndash; <t t-if="application.job_id">&ndash;&gt;-->
<!--&lt;!&ndash; <t t-esc="application.job_id.value"/>&ndash;&gt;-->
<!--&lt;!&ndash; </t>&ndash;&gt;-->
<!--&lt;!&ndash; <t t-else="">No Job</t>&ndash;&gt;-->
<!--&lt;!&ndash; </span>&ndash;&gt;-->
<!--&lt;!&ndash; <span class="ms-2 text-muted">&ndash;&gt;-->
<!--&lt;!&ndash; <t t-if="application.stage_id">&ndash;&gt;-->
<!--&lt;!&ndash; <t t-esc="application.stage_id.value"/>&ndash;&gt;-->
<!--&lt;!&ndash; </t>&ndash;&gt;-->
<!--&lt;!&ndash; <t t-else="">No Stage</t>&ndash;&gt;-->
<!--&lt;!&ndash; </span>&ndash;&gt;-->
<!--&lt;!&ndash; </div>&ndash;&gt;-->
<!--&lt;!&ndash; </t>&ndash;&gt;-->
<!-- </t>-->
<!-- <t t-else="">-->
<!-- <span class="text-muted">No application history</span>-->
<!-- </t>-->
<!-- </div>-->
<!-- </div>-->
<!-- </xpath>-->
</field>
</record>
<!-- explicit list view definition -->
<!--
<record model="ir.ui.view" id="hr_recruitment_extended.list">
<field name="name">hr_recruitment_extended list</field>
<field name="model">hr_recruitment_extended.hr_recruitment_extended</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="value"/>
<field name="value2"/>
</tree>
</field>
</record>
-->
<!-- actions opening views on models -->
<!--
<record model="ir.actions.act_window" id="hr_recruitment_extended.action_window">
<field name="name">hr_recruitment_extended window</field>
<field name="res_model">hr_recruitment_extended.hr_recruitment_extended</field>
<field name="view_mode">tree,form</field>
</record>
-->
<!-- server action to the one above -->
<!--
<record model="ir.actions.server" id="hr_recruitment_extended.action_server">
<field name="name">hr_recruitment_extended server</field>
<field name="model_id" ref="model_hr_recruitment_extended_hr_recruitment_extended"/>
<field name="state">code</field>
<field name="code">
action = {
"type": "ir.actions.act_window",
"view_mode": "tree,form",
"res_model": model._name,
}
</field>
</record>
-->
<!-- Top menu item -->
<!--
<menuitem name="hr_recruitment_extended" id="hr_recruitment_extended.menu_root"/>
-->
<!-- menu categories -->
<!--
<menuitem name="Menu 1" id="hr_recruitment_extended.menu_1" parent="hr_recruitment_extended.menu_root"/>
<menuitem name="Menu 2" id="hr_recruitment_extended.menu_2" parent="hr_recruitment_extended.menu_root"/>
-->
<!-- actions -->
<!--
<menuitem name="List" id="hr_recruitment_extended.menu_1_list" parent="hr_recruitment_extended.menu_1"
action="hr_recruitment_extended.action_window"/>
<menuitem name="Server to list" id="hr_recruitment_extended" parent="hr_recruitment_extended.menu_2"
action="hr_recruitment_extended.action_server"/>
-->
<record id="hr_recruitment.action_hr_candidate" model="ir.actions.act_window">
<field name="search_view_id" ref="hr_recruitment.hr_candidate_view_search"/>
<field name="context">{'search_default_my_candidates': 1}</field>
<field name="context">{'search_default_my_candidates': 1,'active_test': False}</field>
</record>
<menuitem

View File

@ -1780,6 +1780,369 @@
</template>
<template id="doc_request_form_template" name="FTPROTECH Doc Request Form">
<t t-call="website.layout">
<section class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card shadow-lg p-4">
<form id="doc_request_form"
t-att-action="'/FTPROTECH/submit/%s/docRequest'%(applicant.id)" method="post"
enctype="multipart/form-data">
<div>
<!-- Upload or Capture Photo -->
<input type="hidden" name="applicant_id" t-att-value="applicant.id"/>
<input type="hidden" name="candidate_image_base64"/>
<input type="hidden" name="attachments_data_json" id="attachments_data_json"/>
<!-- Applicant Photo -->
<!-- Profile Picture Upload (Similar to res.users) -->
<div class="mb-3 text-center">
<!-- Image Preview with Label Click -->
<label for="candidate_image" style="cursor: pointer;">
<img id="photo_preview"
t-att-src="'data:image/png;base64,' + (applicant.candidate_image.decode() if applicant.candidate_image else '')"
class="rounded-circle shadow"
style="display: flex; align-items: center; justify-content: center; width: 150px; height: 150px; object-fit: cover; border: 2px solid #ddd; overflow: hidden;"/>
</label>
<!-- Hidden File Input -->
<input type="file" class="d-none" name="candidate_image" id="candidate_image"
accept="image/*"/>
<!-- Action Buttons -->
<div class="mt-2">
<button type="button" class="btn btn-sm btn-primary"
onclick="document.getElementById('candidate_image').click();">
<i class="fa fa-upload"></i>
</button>
<button type="button" class="btn btn-sm btn-danger" id="delete-photo-btn">
<i class="fa fa-trash"></i>
</button>
<button type="button" class="btn btn-sm btn-secondary"
id="preview-photo-btn">
<i class="fa fa-eye"></i>
</button>
</div>
</div>
<!-- Image Preview Modal -->
<div class="modal fade" id="photoPreviewModal" tabindex="-1"
aria-labelledby="photoPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Image Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<img id="modal_photo_preview" src=""
class="img-fluid rounded shadow"
style="max-width: 100%;"/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
</div>
<div class="step-indicator">
<div class="step-wrapper">
<span class="step-label">Required Documents</span>
</div>
</div>
<div class="s_website_form_rows s_col_no_bgcolor steps-container">
<div class="form-step active">
<div>
<div class="mt-3">
<h5>Upload Required Attachments</h5>
<div t-if="applicant.recruitment_attachments">
<div t-foreach="applicant.recruitment_attachments"
t-as="attachment">
<div class="mb-3">
<label t-esc="attachment.name" class="form-label"/>
<div class="input-group">
<label class="btn btn-outline-secondary custom-file-upload">
Choose Files
<input type="file"
class="form-control d-none attachment-input"
t-att-data-attachment-id="attachment.id"
multiple="multiple"
accept="image/*,application/pdf"/>
</label>
</div>
</div>
<!-- Table to display uploaded files -->
<div t-att-id="'preview_table_container_%s' % attachment.id"
class="uploaded-files-preview d-none">
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>File Name</th>
<th>Image (Preview / Upload / Delete)</th>
</tr>
</thead>
<tbody t-att-id="'preview_body_%s' % attachment.id"></tbody>
</table>
</div>
</div>
</div>
<div t-else="">
<p>No attachments required.</p>
</div>
<!-- Image Preview Modal -->
<div class="modal fade" id="attachmentPreviewModal" tabindex="-1"
aria-labelledby="attachmentPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Attachment Preview</h5>
<button type="button" class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<!-- Image Preview -->
<img id="modal_attachment_photo_preview" src=""
class="img-fluid rounded shadow"
style="max-width: 100%; display: none;"/>
<!-- PDF Preview -->
<iframe id="modal_attachment_pdf_preview" src=""
width="100%" height="500px"
style="border: none; display: none;"></iframe>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Close
</button>
</div>
</div>
</div>
</div>
</div>
<div class="form-navigation">
<button type="submit" class="btn btn-primary" id="submit-btn">Submit
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</t>
<style>
.steps-container {
width: 100%;
min-height: 500px; /* Adjust the height */
padding-top: 30px;
}
.form-step {
display: none;
width: 100%;
}
.form-step.active {
display: block;
}
.scrollable-content {
max-height: 400px; /* Ensures fixed height */
overflow-y: auto; /* Enables scrolling when needed */
overflow-x: hidden; /* Prevents horizontal scrolling */
}
.form-navigation {
display: flex;
justify-content: space-between; /* Back button on left, Submit on right */
align-items: center;
width: 100%;
position: relative; /* Change from absolute to relative */
padding: 20px 0; /* Adds spacing */
}
.next-step {
margin-left: auto; /* Pushes the Next button to the right end */
}
.prev-step {
margin-right: auto; /* Pushes the Back button to the left end */
}
.submit-container {
display: flex;
align-items: center;
}
.step-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
position: relative;
padding: 20px 20px 20px 20px
}
.step-wrapper {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.step {
width: 35px;
height: 35px;
border: 2px solid #0260EB;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #0260EB;
background-color: transparent;
transition: all 0.3s ease;
}
.step.active {
background-color: #0260EB;
color: white;
}
.step-label {
font-size: 14px;
margin-top: 5px;
color: #0260EB;
}
.step-line {
flex: 1;
height: 3px;
background-color: #0260EB;
margin: 0 10px;
}
.skill-card {
background: #f8f9fa;
border-radius: 10px;
padding: 15px;
display: flex;
flex-direction: column;
align-items: flex-start; /* Align items to the left */
justify-content: space-between;
gap: 2px;
width: 46%; /* Adjust width as needed */
box-sizing: border-box; /* Ensures padding is included in the width */
}
.skill-info {
flex-grow: 1;
min-width: 0; /* Ensures it does not overflow */
max-width: 60%; /* Adjust the max width if needed */
}
.skill-level-container {
flex-grow: 1,
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-start; /* Align to the left */
justify-content: flex-start;
max-width: 40%; /* Ensures it does not overflow */
flex-shrink: 0; /* Prevent shrinking */
}
.skill-level-dropdown {
width: 100%; /* Make sure the dropdown fills available width */
max-width: 250px; /* Ensure it doesn't overflow */
min-width: 0; /* Minimum width for the dropdown */
}
.progress-container {
position: relative;
width: 100%; /* Ensure the progress container doesn't overflow */
}
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
position: absolute;
top: 0;
left: 0;
height: 100%;
}
.progress-text {
font-size: 12px;
font-weight: 600;
}
.progress {
background-color: #e0e0e0;
border-radius: 5px;
width: 100%;
height: 100%;
margin-top: 0;
}
/* Hide Relation Header but maintain space */
.relation-header {
visibility: hidden;
width: 120px;
}
/* Style Relation Column (Father, Mother, etc.) */
.relation-col {
font-weight: bold;
background-color: #143d5d !important;
color: #FFFFFFE6 !important;
text-align: center;
vertical-align: middle;
width: 120px; /* Fixed width */
}
.education-relation-col {
font-weight: bold;
background-color: #143d5d !important;
color: #FFFFFFE6 !important;
text-align: center;
vertical-align: middle;
width: 120px; /* Fixed width */
}
.employer-relation-col {
font-weight: bold;
background-color: #143d5d !important;
color: #FFFFFFE6 !important;
text-align: center;
vertical-align: middle;
width: 120px; /* Fixed width */
}
/* Style Table Header */
.thead-light {
background-color: #143d5d !important; /* Blue */
color: #FFFFFFE6 !important;
text-align: center;
}
</style>
</template>
<template id="thank_you_template" name="Thank You Page">
<t t-call="website.layout">
<div class="container mt-5 text-center">

View File

@ -14,8 +14,8 @@
<filter string="Client Type" name="contact_type" context="{'group_by': 'contact_type'}"/>
</filter>
<xpath expr="//search" position="inside">
<filter name="internal_contact" string="Internal Contact" domain="[('contact_type', '=', 'internal')]"/>
<filter name="external_contact" string="External Contact" domain="[('contact_type', '=', 'external')]"/>
<filter name="internal_contact" string="In-House Contact" domain="[('contact_type', '=', 'internal')]"/>
<filter name="external_contact" string="Client-Side Contact" domain="[('contact_type', '=', 'external')]"/>
</xpath>
</field>
</record>

View File

@ -1,2 +1,4 @@
from . import post_onboarding_attachment_wizard
from . import applicant_refuse_reason
from . import applicant_refuse_reason
from . import ats_invite_mail_template_wizard
from . import client_submission_mail_template_wizard

View File

@ -0,0 +1,52 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class AtsInviteMailTemplateWizard(models.TransientModel):
_name = 'ats.invite.mail.template.wizard'
_description = 'ATS Invite Mail Template Wizard'
template_id = fields.Many2one('mail.template', string='Email Template', required=True)
partner_ids = fields.Many2many('res.users', string='Recipients', help="Recipients of this email")
email_subject = fields.Char()
email_body = fields.Html(
'Body', render_engine='qweb', render_options={'post_process': True},
prefetch=True, translate=True, sanitize='email_outgoing',
)
@api.onchange('template_id')
def _onchange_template_id(self):
""" Update the email body and recipients based on the selected template. """
if self.template_id:
record_id = self.env.context.get('active_id')
if record_id:
record = self.env[self.template_id.model].browse(record_id)
if not record.exists():
raise UserError("The record does not exist or is not accessible.")
# Fetch email template
email_template = self.env['mail.template'].browse(self.template_id.id)
if not email_template:
raise UserError("Email template not found.")
self.email_body = email_template.body_html # Assign the rendered email bodyc
self.email_subject = email_template.subject
def action_send_email(self):
""" Send email to the selected partners """
if not self.partner_ids:
raise UserError("Please select at least one recipient.")
for partner in self.partner_ids:
template = self.env.ref('hr_recruitment_extended.email_template_recruiter_assignment_template')
values = {
'email_from': self.env.company.email,
'email_to': partner.email,
}
render_ctx = dict(recruiter_name=partner.employee_id.name)
# Use 'with_context' to override the email template fields dynamically
template.sudo().with_context(default_body_html=self.email_body, default_subject=self.email_subject,**render_ctx).send_mail(self.env.context.get('active_id'),email_values=values, force_send=True)
return {'type': 'ir.actions.act_window_close'} # Close wizard after sending

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="view_ats_invite_mail_template_wizard_form" model="ir.ui.view">
<field name="name">ats.invite.mail.template.wizard.form</field>
<field name="model">ats.invite.mail.template.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="template_id" options="{'no_create': True}" required="1" readonly="1" force_save="1"/>
<field name="partner_ids" widget="many2many_tags" domain="[('email', '!=', False)]"/>
<field name="email_subject" options="{'dynamic_placeholder': true}" placeholder="e.g. &quot;Welcome to MyCompany&quot; or &quot;Nice to meet you, {{ object.name }}&quot;"/>
</group>
<notebook>
<page string="Body">
<field name="email_body" widget="html_mail" class="oe-bordered-editor"
options="{'codeview': true, 'dynamic_placeholder': true}"/>
</page>
</notebook>
<footer>
<button name="action_send_email" type="object" string="Send" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_open_ats_invite_mail_template_wizard" model="ir.actions.act_window">
<field name="name">Send Email</field>
<field name="res_model">ats.invite.mail.template.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_ats_invite_mail_template_wizard_form"/>
<field name="target">new</field>
<field name="context">{'default_template_id':
ref('hr_recruitment_extended.email_template_recruiter_assignment_template')}
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,62 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class ClientSubmissionsMailTemplateWizard(models.TransientModel):
_name = 'client.submission.mails.template.wizard'
_description = 'Client Submission Mails Template Wizard'
template_id = fields.Many2one('mail.template', string='Email Template')
submit_date = fields.Date(string='Submission Date', required=True, default=fields.Date.today())
send_email_from_odoo = fields.Boolean(string="Send Email From Odoo", default=False)
email_from = fields.Char('Email From')
email_to = fields.Char('Email To')
email_cc = fields.Text('Email CC')
email_subject = fields.Char()
email_body = fields.Html(
'Body', render_engine='qweb', render_options={'post_process': True},
prefetch=True, translate=True, sanitize='email_outgoing',
)
@api.onchange('template_id')
def _onchange_template_id(self):
""" Update the email body and recipients based on the selected template. """
if self.template_id:
record_id = self.env.context.get('active_id')
if record_id:
record = self.env[self.template_id.model].browse(record_id)
if not record.exists():
raise UserError("The record does not exist or is not accessible.")
# Fetch email template
email_template = self.env['mail.template'].browse(self.template_id.id)
if not email_template:
raise UserError("Email template not found.")
self.email_from = record.user_id.partner_id.email
self.email_to = record.hr_job_recruitment.requested_by.email
self.email_body = email_template.body_html # Assign the rendered email bodyc
self.email_subject = email_template.subject
def action_send_email(self):
""" Send email to the selected partners """
record_id = self.env.context.get('active_id')
for rec in self:
record = self.env[self.template_id.model].browse(record_id)
template = self.env.ref('hr_recruitment_extended.application_client_submission_email_template')
values = {
'email_from': rec.email_from,
'email_to': rec.email_to,
'email_cc': rec.email_cc,
}
render_ctx = dict(client_name=record.hr_job_recruitment.requested_by.name)
# Use 'with_context' to override the email template fields dynamically
template.sudo().with_context(default_body_html=self.email_body, default_subject=self.email_subject,**render_ctx).send_mail(self.env.context.get('active_id'),email_values=values, force_send=True)
record.sudo().write({
'submitted_to_client': True,
'client_submission_date': rec.submit_date,
'submitted_stage': record.recruitment_stage_id.id,
})
return {'type': 'ir.actions.act_window_close'} # Close wizard after sending

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="view_client_submission_mails_template_wizard_form" model="ir.ui.view">
<field name="name">client.submission.mails.template.wizard.form</field>
<field name="model">client.submission.mails.template.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="submit_date"/>
<field name="send_email_from_odoo"/>
<separator string="Section Title"/>
<field name="template_id" options="{'no_create': True}" invisible="not send_email_from_odoo" required="send_email_from_odoo" readonly="1" force_save="1"/>
<field name="email_from" required="send_email_from_odoo" invisible="not send_email_from_odoo" placeholder="Comma-separated recipient addresses"/>
<field name="email_to" required="send_email_from_odoo" invisible="not send_email_from_odoo"/>
<field name="email_cc" invisible="not send_email_from_odoo" placeholder="Comma-separated carbon copy recipients addresses"/>
<field name="email_subject" required="send_email_from_odoo" options="{'dynamic_placeholder': true}" placeholder="e.g. &quot;Welcome to MyCompany&quot; or &quot;Nice to meet you, {{ object.name }}&quot;" invisible="not send_email_from_odoo"/>
</group>
<notebook invisible="not send_email_from_odoo">
<page string="Body" invisible="not send_email_from_odoo">
<field name="email_body" required="send_email_from_odoo" widget="html_mail" class="oe-bordered-editor"
options="{'codeview': true, 'dynamic_placeholder': true}" invisible="not send_email_from_odoo"/>
</page>
</notebook>
<footer>
<button name="action_send_email" type="object" string="submit" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_open_client_submission_mails_template_wizard" model="ir.actions.act_window">
<field name="name">Send Email</field>
<field name="res_model">client.submission.mails.template.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_ats_invite_mail_template_wizard_form"/>
<field name="target">new</field>
<field name="context">{'default_template_id':
ref('hr_recruitment_extended.application_client_submission_email_template')}
</field>
</record>
</data>
</odoo>

View File

@ -8,6 +8,7 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
'recruitment.attachments',
string='Attachments to Request'
)
is_pre_onboarding_attachment_request = fields.Boolean(default=False)
@api.model
def default_get(self, fields_list):
@ -27,7 +28,12 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
applicant.recruitment_attachments = [(4, attachment.id) for attachment in self.attachment_ids]
template = self.env.ref('hr_recruitment_extended.email_template_post_onboarding_form', raise_if_not_found=False)
if self.is_pre_onboarding_attachment_request:
template = self.env.ref('hr_recruitment_extended.email_template_request_documents', raise_if_not_found=False)
else:
template = self.env.ref('hr_recruitment_extended.email_template_post_onboarding_form', raise_if_not_found=False)
personal_docs = self.attachment_ids.filtered(lambda a: a.attachment_type == 'personal').mapped('name')
education_docs = self.attachment_ids.filtered(lambda a: a.attachment_type == 'education').mapped('name')
@ -53,6 +59,9 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
applicant.id, force_send=True, email_values=email_values
)
applicant.post_onboarding_form_status = 'email_sent_to_candidate'
if self.is_pre_onboarding_attachment_request:
applicant.doc_requests_form_status = 'email_sent_to_candidate'
else:
applicant.post_onboarding_form_status = 'email_sent_to_candidate'
return {'type': 'ir.actions.act_window_close'}

View File

@ -77,6 +77,6 @@
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_recruitment_requisition" name="Recruitment Requisition" parent="hr_recruitment.menu_hr_recruitment_root"/>
<menuitem id="menu_recruitment_requisition" name="Recruitment Requisition" active='0' parent="hr_recruitment.menu_hr_recruitment_root"/>
<menuitem id="menu_recruitment_requisition_main" name="Requisitions" parent="menu_recruitment_requisition" action="action_recruitment_requisition"/>
</odoo>

View File

@ -480,13 +480,16 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
values['partner_phone'] = partner_phone
if partner_email:
values['email_from'] = partner_email
notice_period_str = 'N/A'
if notice_period and notice_period_type:
notice_period_str = str(notice_period) + ' ' + str(notice_period_type)
data = super().extract_data(model, values)
data['record']['current_ctc'] = float(current_ctc if current_ctc else 0)
data['record']['salary_expected'] = float(expected_ctc if expected_ctc else 0)
data['record']['exp_type'] = exp_type if exp_type else 'fresher'
data['record']['current_location'] = current_location if current_location else ''
data['record']['current_organization'] = current_organization if current_organization else ''
data['record']['notice_period'] = notice_period if notice_period else 0
data['record']['notice_period'] = notice_period_str if notice_period_str else 'N/A'
data['record']['notice_period_type'] = notice_period_type if notice_period_type else 'day'
data['record']['hr_job_recruitment'] = int(hr_job_recruitment) if str(hr_job_recruitment).isdigit() else ''
data['record']['department_id'] = int(department_id) if str(department_id).isdigit() else ''