Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 999b0f8

Browse files
committedMar 20, 2025·
[ADD] time_tracking_kiosk: record working hours for project and tasks from kiosk
This module allows employees to record their working hours via kiosk interface: - Scan employee badge or manually enter ID to identify employee. - Select projects and tasks assigned to the employee. - Start/stop timers to track work accurately. - Configure maximum allowed hours and email template for PM in settings. - Record timesheet as the minimum of actual timer hours and max. allowed hours. - Allow portal users to be assignee(project.task) and related user(hr.employee) - Notify project managers via configurable email templates defined in settings.
1 parent 4c650f3 commit 999b0f8

21 files changed

+957
-0
lines changed
 

‎time_tracking_kiosk/__init__.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import Command
4+
from . import models
5+
from . import controllers
6+
from . import tests
7+
8+
def set_menu_visibility_for_kiosk(env):
9+
"""Set visibility of menus to show only kiosk app for internal users."""
10+
menus = env['ir.ui.menu'].search([])
11+
internal_user_group = env.ref('base.group_user')
12+
system_admin_group = env.ref('base.group_system')
13+
kiosk_root_menu = env.ref('time_tracking_kiosk.menu_timesheet_kiosk_root')
14+
kiosk_mode_menu = env.ref('time_tracking_kiosk.menu_timesheet_kiosk')
15+
16+
for menu in menus:
17+
if menu.id in [kiosk_root_menu.id, kiosk_mode_menu.id]:
18+
menu.groups_id = [Command.set([internal_user_group.id, system_admin_group.id])]
19+
else:
20+
menu.groups_id = [Command.set([system_admin_group.id])]

‎time_tracking_kiosk/__manifest__.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
{
4+
"name": "Employee Timesheet Kiosk",
5+
"version": "1.0",
6+
"summary": "Record working hours from kiosk mode for projects and tasks",
7+
"category": "Tutorials",
8+
"description": """
9+
This module allows employees to record their working hours via a kiosk interface:
10+
- Scan employee badge to identify user
11+
- Select projects and tasks assigned to the employee
12+
- Start/stop timers to track work accurately
13+
- Configure maximum allowed hours and minimum time entries
14+
- Allow portal users access to timesheets
15+
- Notify project managers via configurable email templates
16+
""",
17+
"author": "nmak",
18+
"depends": ["base", "hr_timesheet", "project", "hr_attendance",],
19+
"data": [
20+
"security/ir.model.access.csv",
21+
"security/ir.rule.xml",
22+
"data/email_template.xml",
23+
"views/timesheet_kiosk_actions.xml",
24+
"views/hr_employee_form_views.xml",
25+
"views/res_config_settings_view.xml",
26+
"views/timesheet_menus.xml",
27+
],
28+
"assets": {
29+
"web.assets_backend": [
30+
"time_tracking_kiosk/static/src/kiosk_main.js",
31+
"time_tracking_kiosk/static/src/scss/style.scss",
32+
"time_tracking_kiosk/static/src/timesheet_kiosk_templates.xml",
33+
],
34+
},
35+
"application": True,
36+
'post_init_hook': 'set_menu_visibility_for_kiosk',
37+
"installable": True,
38+
"license": "LGPL-3",
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import timesheet_controller
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import http, fields
4+
from odoo.http import request
5+
6+
7+
class TimesheetController(http.Controller):
8+
9+
@http.route("/timesheet/create", type="json", auth="user")
10+
def create_timesheet(self, **kwargs):
11+
"""Create a new timesheet entry when the timer starts."""
12+
try:
13+
params = kwargs.get("params", kwargs)
14+
project_id = params.get("project_id")
15+
task_id = params.get("task_id")
16+
employee_id = params.get("employee_id")
17+
18+
new_timesheet = request.env["account.analytic.line"].sudo().create({
19+
"project_id": project_id,
20+
"task_id": task_id,
21+
"employee_id": employee_id,
22+
"name": "Work in Progress",
23+
"unit_amount": 0.0,
24+
"date": fields.Date.today(),
25+
"timer_active": True,
26+
"timer_start_time": fields.Datetime.now(),
27+
})
28+
29+
return {"id": new_timesheet.id, "name": new_timesheet.name}
30+
except Exception as error:
31+
return {"id": False, "error": str(error)}
32+
33+
@http.route("/timesheet/stop", type="json", auth="user")
34+
def stop_timesheet(self, **kwargs):
35+
"""Stop the timer, record hours worked, and notify the project manager via email."""
36+
try:
37+
params = kwargs.get("params", kwargs)
38+
timesheet_id = params.get("timesheet_id")
39+
timesheet = request.env["account.analytic.line"].browse(timesheet_id)
40+
41+
if not timesheet.exists():
42+
return {"id": False, "error": "Timesheet not found"}
43+
44+
if timesheet.employee_id.user_id != request.env.user and not request.env.user._is_admin():
45+
return {"error": "Access denied"}
46+
47+
max_work_hours_per_day = float(
48+
request.env["ir.config_parameter"]
49+
.sudo()
50+
.get_param("time_tracking_kiosk.max_work_hours_per_day", 8)
51+
)
52+
53+
start_time = timesheet.timer_start_time
54+
end_time = fields.Datetime.now()
55+
hours_worked = (end_time - start_time).total_seconds() / 3600
56+
hours_worked = min(hours_worked, max_work_hours_per_day)
57+
58+
timesheet.sudo().write({
59+
"unit_amount": hours_worked,
60+
"name": "Work Done",
61+
"timer_active": False,
62+
})
63+
64+
project_manager = timesheet.task_id.project_id.user_id
65+
if project_manager:
66+
email_template = request.env.ref(
67+
"time_tracking_kiosk.email_template_pm_notification",
68+
raise_if_not_found=True,
69+
)
70+
email_template.sudo().send_mail(timesheet.id, force_send=True)
71+
72+
return {
73+
"id": timesheet.id,
74+
"unit_amount": timesheet.unit_amount,
75+
"name": timesheet.name,
76+
}
77+
except Exception as error:
78+
return {"id": False, "error": str(error)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<odoo>
3+
<record id="email_template_pm_notification" model="mail.template">
4+
<field name="name">Project Manager Timesheet Alert</field>
5+
<field name="model_id" ref="model_account_analytic_line"/>
6+
<field name="email_from">${(object.create_uid.email or 'noreply@yourcompany.com')|safe}</field>
7+
<field name="email_to">${(object.task_id.project_id.user_id.partner_id.email or 'admin@yourcompany.com')|safe}</field>
8+
<field name="subject">[Timesheet Alert] Employee Exceeded Work Hours</field>
9+
<field name="body_html"><![CDATA[
10+
<p>Dear ${object.task_id.project_id.user_id.name},</p>
11+
<p>The following employee has recorded work hours:</p>
12+
<ul>
13+
<li><strong>Employee:</strong> ${object.employee_id.name or 'Unknown'}</li>
14+
<li><strong>Worked Hours:</strong> ${object.unit_amount or '0'}</li>
15+
<li><strong>Allowed Hours:</strong> ${object.env['ir.config_parameter'].sudo().get_param('time_tracking_kiosk.max_work_hours_per_day', 8)}</li>
16+
</ul>
17+
<p>Please review the timesheet.</p>
18+
<p>Best regards,</p>
19+
<p>Your HR Team</p>
20+
]]></field>
21+
</record>
22+
</odoo>
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import res_config_settings
4+
from . import project_task
5+
from . import account_analytic_line
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import models, fields, api
4+
5+
6+
class AccountAnalyticLine(models.Model):
7+
_inherit = "account.analytic.line"
8+
9+
timer_active = fields.Boolean()
10+
timer_start_time = fields.Datetime()
11+
max_work_hours_per_day = fields.Float(string='Maximum Work Hours', default=8.0)
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import models, fields
4+
5+
6+
class ProjectTask(models.Model):
7+
_inherit = "project.task"
8+
9+
user_ids = fields.Many2many(
10+
comodel_name="res.users",
11+
relation="project_task_user_rel",
12+
column1="task_id",
13+
column2="user_id",
14+
string="Assignees",
15+
tracking=True,
16+
domain=[("share", "=", True), ("active", "=", True)],
17+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import api, models, fields
4+
5+
6+
class ResConfigSettings(models.TransientModel):
7+
_inherit = "res.config.settings"
8+
9+
max_work_hours_per_day = fields.Float(
10+
string="Max Work Hours per Day",
11+
config_parameter="time_tracking_kiosk.max_work_hours_per_day",
12+
help="Maximum allowed work hours per day for employees using the kiosk.",
13+
)
14+
15+
pm_notification_template_id = fields.Many2one(
16+
"mail.template",
17+
string="PM Notification Email Template",
18+
domain=[("model", "=", "account.analytic.line")],
19+
config_parameter="time_tracking_kiosk.pm_notification_template_id",
20+
help="Select the email template to notify project managers.",
21+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2+
access_project_task_portal,project.task.portal,project.model_project_task,base.group_portal,1,1,0,0
3+
access_project_project_portal,project.project.portal,project.model_project_project,base.group_portal,1,0,0,0
4+
access_account_analytic_line_portal,account.analytic.line.portal,analytic.model_account_analytic_line,base.group_portal,1,1,1,0
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<odoo>
2+
<record id="rule_project_task_portal_user_assignment" model="ir.rule">
3+
<field name="name">Allow Portal Users to be Assigned to Tasks</field>
4+
<field name="model_id" ref="project.model_project_task"/>
5+
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
6+
<field name="domain_force">[('user_ids', 'in', user.id)]</field>
7+
</record>
8+
</odoo>
79.7 KB
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
/** @odoo-module **/
2+
3+
import { Component, useState } from "@odoo/owl";
4+
import { useService } from "@web/core/utils/hooks";
5+
import { registry } from "@web/core/registry";
6+
import { scanBarcode } from "@web/core/barcode/barcode_dialog";
7+
import { rpc } from "@web/core/network/rpc";
8+
9+
export class KioskMainScreen extends Component {
10+
static template = "time_tracking_kiosk.KioskMainScreen";
11+
12+
setup() {
13+
this.state = useState({
14+
employeeId: "",
15+
employee: null,
16+
currentProject: null,
17+
currentTask: null,
18+
isTimerRunning: false,
19+
timerStartTime: null,
20+
timesheetId: null,
21+
screen: "scan",
22+
elapsedTime: "00:00:00",
23+
timerInterval: null,
24+
});
25+
this.orm = useService("orm");
26+
this.notification = useService("notification");
27+
this.scanBarcode = () => scanBarcode(this.env, this.facingMode);
28+
}
29+
30+
async openMobileScanner() {
31+
let error = null;
32+
let barcode = null;
33+
try {
34+
barcode = await this.scanBarcode();
35+
} catch (err) {
36+
error = err.message;
37+
}
38+
39+
if (barcode) {
40+
this.props.onBarcodeScanned(barcode);
41+
if ("vibrate" in window.navigator) {
42+
window.navigator.vibrate(100);
43+
}
44+
} else {
45+
this.notification.add(error || _t("Please, Scan again!"), {
46+
type: "warning",
47+
});
48+
}
49+
}
50+
51+
async onScanBadge() {
52+
if (!this.state.employeeId) {
53+
this.notification.add("Please scan or enter an employee ID", {
54+
type: "warning",
55+
});
56+
return;
57+
}
58+
59+
try {
60+
const employees = await this.orm.call(
61+
"hr.employee",
62+
"search_read",
63+
[[["barcode", "=", this.state.employeeId]]],
64+
{ limit: 1 }
65+
);
66+
if (employees.length) {
67+
const employee = employees[0];
68+
const tasks = await this.orm.call(
69+
"project.task",
70+
"search_read",
71+
[[["user_ids", "in", [employee.user_id[0]]]]],
72+
{
73+
fields: ["id", "name", "project_id", "effective_hours", "stage_id"],
74+
}
75+
);
76+
if (tasks.length == 0) {
77+
this.notification.add(
78+
`Employee ${employee.name} has no tasks currently assigned. Please assign tasks to enable time tracking.`,
79+
{
80+
type: "warning",
81+
}
82+
);
83+
this.state.screen = "scan";
84+
return;
85+
}
86+
const stages = await this.orm.call(
87+
"project.task.type",
88+
"search_read",
89+
[[["id", "in", tasks.map((task) => task.stage_id[0])]]],
90+
{ fields: ["id", "fold"] }
91+
);
92+
93+
const stageMap = stages.reduce((map, stage) => {
94+
map[stage.id] = stage.fold;
95+
return map;
96+
}, {});
97+
98+
const projectsMap = {};
99+
100+
tasks.forEach((task) => {
101+
if (stageMap[task.stage_id[0]]) return;
102+
const projectId = task.project_id[0];
103+
const projectName = task.project_id[1];
104+
if (!projectsMap[projectId]) {
105+
projectsMap[projectId] = {
106+
id: projectId,
107+
name: projectName,
108+
tasks: [],
109+
};
110+
}
111+
112+
projectsMap[projectId].tasks.push({
113+
id: task.id,
114+
name: task.name,
115+
effective_hours: task.effective_hours,
116+
});
117+
});
118+
119+
this.state.employee = {
120+
...employee,
121+
assigned_projects: Object.values(projectsMap),
122+
};
123+
124+
const activeTimesheet = await this.orm.call(
125+
"account.analytic.line",
126+
"search_read",
127+
[
128+
[
129+
["employee_id", "=", employee.id],
130+
["timer_active", "=", true],
131+
],
132+
],
133+
{
134+
limit: 1,
135+
fields: ["id", "timer_start_time", "project_id", "task_id"],
136+
}
137+
);
138+
139+
if (activeTimesheet.length) {
140+
this.state.isTimerRunning = true;
141+
this.state.timerStartTime = new Date(
142+
activeTimesheet[0].timer_start_time
143+
);
144+
145+
this.state.timerStartTime = new Date(
146+
this.state.timerStartTime.getTime() -
147+
this.state.timerStartTime.getTimezoneOffset() * 60000
148+
);
149+
this.state.timesheetId = activeTimesheet[0].id;
150+
151+
if (activeTimesheet[0].project_id && activeTimesheet[0].task_id) {
152+
const projectId = activeTimesheet[0].project_id[0];
153+
const projectName = activeTimesheet[0].project_id[1];
154+
155+
this.state.currentProject =
156+
this.state.employee.assigned_projects.find(
157+
(proj) => proj.id === projectId
158+
);
159+
160+
if (!this.state.currentProject) {
161+
this.state.currentProject = {
162+
id: projectId,
163+
name: projectName,
164+
tasks: [],
165+
};
166+
}
167+
168+
const taskId = activeTimesheet[0].task_id[0];
169+
const taskName = activeTimesheet[0].task_id[1];
170+
171+
this.state.currentTask = this.state.currentProject.tasks.find(
172+
(task) => task.id === taskId
173+
);
174+
175+
if (!this.state.currentTask) {
176+
this.state.currentTask = {
177+
id: taskId,
178+
name: taskName,
179+
};
180+
181+
if (
182+
!this.state.currentProject.tasks.some(
183+
(task) => task.id === taskId
184+
)
185+
) {
186+
this.state.currentProject.tasks.push(this.state.currentTask);
187+
}
188+
}
189+
}
190+
191+
this.state.screen = "timer";
192+
this.startElapsedTimeUpdater();
193+
} else {
194+
this.state.screen = "projects";
195+
}
196+
197+
this.notification.add(`Employee Found: ${employee.name}`, {
198+
type: "success",
199+
});
200+
} else {
201+
this.notification.add("Employee not found!", { type: "danger" });
202+
}
203+
} catch (error) {
204+
this.notification.add("An error occurred while fetching employee data.", {
205+
type: "danger",
206+
});
207+
}
208+
}
209+
210+
onProjectChange(event) {
211+
const projectId = Number(event.target.value);
212+
213+
this.state.currentProject =
214+
this.state.employee.assigned_projects.find(
215+
(proj) => proj.id === projectId
216+
) || null;
217+
218+
this.state.currentTask = null;
219+
}
220+
221+
onTaskChange(event) {
222+
const taskId = Number(event.target.value);
223+
224+
this.state.currentTask =
225+
this.state.currentProject?.tasks.find((task) => task.id === taskId) ||
226+
null;
227+
}
228+
229+
backToMainScreen() {
230+
if (this.state.timerInterval) {
231+
clearInterval(this.state.timerInterval);
232+
this.state.timerInterval = null;
233+
}
234+
235+
this.state.employeeId = "";
236+
this.state.employee = null;
237+
this.state.currentProject = null;
238+
this.state.currentTask = null;
239+
this.state.timerStartTime = null;
240+
this.state.screen = "scan";
241+
}
242+
243+
async startTimer() {
244+
const { currentProject, currentTask, employee } = this.state;
245+
246+
if (!currentProject || !currentTask) {
247+
this.notification.add("Please select a project and task first!", {
248+
type: "warning",
249+
});
250+
return;
251+
}
252+
253+
try {
254+
const timesheetData = await rpc("/timesheet/create", {
255+
project_id: currentProject.id,
256+
task_id: currentTask.id,
257+
employee_id: employee.id,
258+
});
259+
260+
if (timesheetData.id) {
261+
this.state.isTimerRunning = true;
262+
this.state.timerStartTime = new Date();
263+
this.state.timesheetId = timesheetData.id;
264+
this.state.screen = "timer";
265+
this.startElapsedTimeUpdater();
266+
this.notification.add("Timer started successfully!", { type: "success" });
267+
} else {
268+
this.notification.add("Failed to start timer.", { type: "danger" });
269+
}
270+
} catch (error) {
271+
this.notification.add("Error starting timer.", { type: "danger" });
272+
}
273+
}
274+
275+
async stopTimer() {
276+
if (!this.state.isTimerRunning || !this.state.timesheetId) {
277+
this.notification.add("No active timer found.", { type: "warning" });
278+
return;
279+
}
280+
281+
try {
282+
const response = await rpc("/timesheet/stop", {
283+
timesheet_id: this.state.timesheetId,
284+
});
285+
286+
if (response.error) {
287+
this.notification.add(response.error, { type: "danger" });
288+
return;
289+
}
290+
291+
if (response.id) {
292+
this.notification.add(
293+
`Timesheet stopped. Worked ${
294+
Math.round(response.unit_amount * 60) / 100
295+
} hours`,
296+
{ type: "success" }
297+
);
298+
} else {
299+
this.notification.add("Failed to stop timer.", { type: "danger" });
300+
}
301+
} catch (error) {
302+
this.notification.add("Error stopping timer.", { type: "danger" });
303+
}
304+
305+
if (this.state.timerInterval) {
306+
clearInterval(this.state.timerInterval);
307+
this.state.timerInterval = null;
308+
}
309+
310+
this.state.isTimerRunning = false;
311+
this.state.timerStartTime = null;
312+
this.state.timesheetId = null;
313+
this.state.screen = "scan";
314+
}
315+
316+
startElapsedTimeUpdater() {
317+
if (this.state.timerInterval) {
318+
clearInterval(this.state.timerInterval);
319+
}
320+
321+
this.updateElapsedTime();
322+
323+
this.state.timerInterval = setInterval(() => {
324+
this.updateElapsedTime();
325+
}, 1000);
326+
}
327+
328+
updateElapsedTime() {
329+
if (!this.state.timerStartTime) return;
330+
331+
const now = new Date();
332+
const elapsedMilliseconds = now - this.state.timerStartTime;
333+
const totalSeconds = Math.floor(elapsedMilliseconds / 1000);
334+
const hours = Math.floor(totalSeconds / 3600);
335+
const minutes = Math.floor((totalSeconds % 3600) / 60);
336+
const seconds = totalSeconds % 60;
337+
338+
this.state.elapsedTime = `${hours.toString().padStart(2, "0")}:${minutes
339+
.toString()
340+
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
341+
}
342+
343+
formatTime(date) {
344+
if (!date) return "00:00:00";
345+
346+
const hours = date.getHours().toString().padStart(2, "0");
347+
const minutes = date.getMinutes().toString().padStart(2, "0");
348+
const seconds = date.getSeconds().toString().padStart(2, "0");
349+
350+
return `${hours}:${minutes}:${seconds}`;
351+
}
352+
}
353+
354+
registry.category("actions").add("time_tracking_kiosk_main", KioskMainScreen);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
.o_barcode_tap_to_scan {
2+
position: relative;
3+
4+
img {
5+
width: 100%;
6+
}
7+
8+
.o_stock_barcode_laser {
9+
opacity: 0;
10+
width: 120%;
11+
height: 4px;
12+
position: absolute;
13+
top: 0;
14+
left: -10%;
15+
background: red;
16+
animation: o_barcode_scanner_intro 1.4s cubic-bezier(0.6, -0.28, 0.735, 0.045) 0.4s;
17+
animation-fill-mode: forwards;
18+
}
19+
}
20+
21+
.kiosk-container {
22+
display: flex;
23+
flex-direction: column;
24+
align-items: center;
25+
justify-content: center;
26+
padding: 2rem;
27+
28+
.screen {
29+
transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out;
30+
opacity: 0;
31+
transform: translateY(20px);
32+
33+
&.active {
34+
opacity: 1;
35+
transform: translateY(0);
36+
}
37+
}
38+
}
39+
40+
.kiosk-card {
41+
width: 500px;
42+
max-width: 90%;
43+
border-radius: 10px;
44+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
45+
padding: 2rem;
46+
}
47+
48+
.screen-container {
49+
opacity: 0;
50+
visibility: hidden;
51+
transition: opacity 0.5s ease, visibility 0.5s ease;
52+
position: absolute;
53+
top: 0;
54+
left: 0;
55+
width: 100%;
56+
57+
&.active {
58+
opacity: 1;
59+
visibility: visible;
60+
position: static;
61+
}
62+
63+
&.hidden {
64+
opacity: 0;
65+
visibility: hidden;
66+
}
67+
}
68+
69+
.employee-avatar {
70+
width: 150px;
71+
height: 150px;
72+
border-radius: 50%;
73+
overflow: hidden;
74+
margin: 0 auto 10px;
75+
76+
img {
77+
width: 100%;
78+
height: 100%;
79+
object-fit: cover;
80+
}
81+
}
82+
83+
.o_kiosk_input {
84+
border-radius: 25px;
85+
}
86+
87+
.o_kiosk_button {
88+
border-radius: 25px;
89+
min-width: 120px;
90+
}
91+
92+
.o_kiosk_button-lg {
93+
border-radius: 25px;
94+
min-width: 150px;
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<templates xml:space="preserve">
2+
<t t-name="time_tracking_kiosk.KioskMainScreen">
3+
<div class="kiosk-container d-flex flex-column align-items-center justify-content-center vh-100 p-5">
4+
<div class="card shadow p-4 rounded-lg kiosk-card">
5+
<!-- Scan Screen -->
6+
<div t-attf-class="screen-container {{ state.screen === 'scan' ? 'active' : 'hidden' }}">
7+
<h1 class="text-center mb-4">Timesheet Kiosk</h1>
8+
<div class="my-5 text-center" t-on-click="openMobileScanner">
9+
<div class="o_barcode_tap_to_scan m-auto mw-50 cursor-pointer">
10+
<img src="/stock_barcode/static/img/barcode.png" alt="Barcode" class="my-1" />
11+
<div class="o_stock_barcode_laser" />
12+
</div>
13+
<div class="o_stock_mobile_barcode text-primary fw-bold">Scan or tap</div>
14+
</div>
15+
<div class="mb-3 text-center">
16+
<input type="text" t-model="state.employeeId" class="form-control form-control-lg text-center border-primary o_kiosk_input" placeholder="Scan / Enter Employee ID" />
17+
</div>
18+
<div class="d-grid mb-3">
19+
<button class="btn btn-primary btn-lg o_kiosk_button" t-on-click="onScanBadge">Find Employee</button>
20+
</div>
21+
</div>
22+
<!-- Projects Screen -->
23+
<div t-attf-class="screen-container {{ state.screen === 'projects' ? 'active' : 'hidden' }}">
24+
<t t-if="state.employee">
25+
<div class="text-center mb-4">
26+
<div class="employee-avatar">
27+
<img t-att-src="'data:image/png;base64,' + state.employee.avatar_1920" alt="Employee Avatar" />
28+
</div>
29+
<h2 class="mt-3 fw-bold" t-out="state.employee.name" />
30+
</div>
31+
<!-- Project Dropdown -->
32+
<div class="mb-3">
33+
<select class="form-select form-select-lg border-primary o_kiosk_input" t-model="state.currentProject" t-on-change="onProjectChange">
34+
<option value="">Select Project</option>
35+
<t t-foreach="state.employee.assigned_projects || []" t-as="project" t-key="project.id">
36+
<option t-att-value="project.id" t-out="project.name" />
37+
</t>
38+
</select>
39+
</div>
40+
<!-- Task Dropdown -->
41+
<t t-if="state.currentProject">
42+
<div class="mb-3">
43+
<select class="form-select form-select-lg border-primary o_kiosk_input" t-model="state.currentTask" t-on-change="onTaskChange">
44+
<option value="">Select Task</option>
45+
<t t-foreach="state.currentProject.tasks || []" t-as="task" t-key="task.id">
46+
<option t-att-value="task.id" t-out="task.name" />
47+
</t>
48+
</select>
49+
</div>
50+
</t>
51+
<!-- Start Timer and Back Buttons -->
52+
<div class="d-flex justify-content-center gap-3 mt-4">
53+
<button class="btn btn-success btn-lg o_kiosk_button" t-on-click="startTimer" t-att-disabled="!state.currentProject or !state.currentTask">Start Timer</button>
54+
<button class="btn btn-secondary btn-lg o_kiosk_button-lg" t-on-click="backToMainScreen">Back</button>
55+
</div>
56+
</t>
57+
</div>
58+
<!-- Timer Screen -->
59+
<div t-attf-class="screen-container {{ state.screen === 'timer' ? 'active' : 'hidden' }}">
60+
<div class="text-center">
61+
<h1 class="mb-4">Tracking Time</h1>
62+
<t t-if="state.employee">
63+
<div class="text-center mb-4">
64+
<div class="employee-avatar">
65+
<img t-att-src="'data:image/png;base64,' + state.employee.avatar_1920" alt="Employee Avatar" />
66+
</div>
67+
<h2 class="mt-3" t-out="state.employee.name" />
68+
</div>
69+
</t>
70+
<t t-if="state.currentProject and state.currentTask">
71+
<div class="mb-3">
72+
<select class="form-select form-select-lg border-primary o_kiosk_input" t-att-disabled="1">
73+
<option t-out="state.currentProject.name" />
74+
</select>
75+
</div>
76+
<div class="mb-3">
77+
<select class="form-select form-select-lg border-primary o_kiosk_input" t-att-disabled="1">
78+
<option t-out="state.currentTask.name" />
79+
</select>
80+
</div>
81+
</t>
82+
<h2 class="mt-4 fw-bold text-success" t-out="state.elapsedTime" />
83+
<div class="mt-2 mb-4 text-muted">
84+
Started at: <span t-out="state.timerStartTime ? formatTime(state.timerStartTime) : ''" />
85+
</div>
86+
<div class="d-grid mt-4">
87+
<button class="btn btn-danger btn-lg o_kiosk_button mb-2" t-on-click="stopTimer">Stop Timer</button>
88+
<button class="btn btn-secondary btn-lg o_kiosk_button-lg" t-on-click="backToMainScreen">Back</button>
89+
</div>
90+
</div>
91+
</div>
92+
</div>
93+
</div>
94+
</t>
95+
</templates>

‎time_tracking_kiosk/tests/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
import odoo
4+
5+
if odoo.tools.config.get("test_enable"):
6+
from . import test_time_tracking_kiosk
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo.tests.common import tagged
4+
from odoo import fields
5+
from datetime import datetime, timedelta
6+
from odoo.tests import HttpCase
7+
import json, time
8+
9+
@tagged('post_install', '-at_install')
10+
class TimesheetKioskTestCase(HttpCase):
11+
12+
@classmethod
13+
def setUpClass(cls):
14+
super(TimesheetKioskTestCase, cls).setUpClass()
15+
16+
cls.test_user = cls.env['res.users'].create({
17+
'login': 'test_employee_user',
18+
'password': 'test_employee_user',
19+
'name': 'Test Employee User',
20+
'notification_type': 'inbox',
21+
'company_id': cls.env.company.id,
22+
})
23+
24+
cls.test_employee = cls.env['hr.employee'].create({
25+
'name': 'Test Employee',
26+
'barcode': '12345',
27+
'user_id': cls.test_user.id,
28+
})
29+
30+
cls.test_project = cls.env['project.project'].create({
31+
'name': 'Test Project',
32+
'user_id': cls.env.user.id,
33+
})
34+
35+
cls.test_task = cls.env['project.task'].create({
36+
'name': 'Test Task',
37+
'project_id': cls.test_project.id,
38+
'user_ids': [(6, 0, [cls.env.user.id])],
39+
})
40+
41+
cls.initial_timesheet = cls.env['account.analytic.line'].create({
42+
'project_id': cls.test_project.id,
43+
'task_id': cls.test_task.id,
44+
'employee_id': cls.test_employee.id,
45+
'name': 'Initial Timesheet',
46+
'unit_amount': 0.0,
47+
'date': fields.Date.today(),
48+
'timer_active': False,
49+
})
50+
51+
def send_json_post_request(self, url, payload):
52+
"""Send a JSON POST request and return the parsed JSON response."""
53+
response = self.url_open(
54+
url=url,
55+
data=json.dumps(payload),
56+
headers={'Content-Type': 'application/json'},
57+
)
58+
return response.json()
59+
60+
def test_create_timesheet(self):
61+
"""Test the creation of a timesheet via the controller."""
62+
payload = {
63+
'params': {
64+
'project_id': self.test_project.id,
65+
'task_id': self.test_task.id,
66+
'employee_id': self.test_employee.id,
67+
},
68+
}
69+
self.authenticate('test_employee_user', 'test_employee_user')
70+
response = self.send_json_post_request('/timesheet/create', payload)
71+
72+
self.assertIn('result', response, "Response should contain a 'result' key")
73+
self.assertIn('id', response['result'], "Result should contain an 'id' key")
74+
self.assertTrue(response['result']['id'], "Timesheet ID should be truthy")
75+
76+
created_timesheet = self.env['account.analytic.line'].browse(response['result']['id'])
77+
self.assertTrue(created_timesheet.exists())
78+
self.assertEqual(created_timesheet.project_id, self.test_project)
79+
self.assertEqual(created_timesheet.task_id, self.test_task)
80+
self.assertEqual(created_timesheet.employee_id, self.test_employee)
81+
self.assertTrue(created_timesheet.timer_active)
82+
83+
def test_stopping_a_timesheet_updates_unit_amount(self):
84+
"""Test stopping a timesheet updates the unit_amount field."""
85+
start_time = fields.Datetime.now()
86+
timesheet = self.env['account.analytic.line'].create({
87+
'project_id': self.test_project.id,
88+
'task_id': self.test_task.id,
89+
'employee_id': self.test_employee.id,
90+
'name': 'Work in Progress',
91+
'unit_amount': 0.0,
92+
'date': fields.Date.today(),
93+
'timer_active': True,
94+
'timer_start_time': start_time,
95+
})
96+
self.authenticate('test_employee_user', 'test_employee_user')
97+
time.sleep(1)
98+
response = self.send_json_post_request('/timesheet/stop', {
99+
'params': {
100+
'timesheet_id': timesheet.id,
101+
},
102+
})
103+
updated_timesheet = self.env['account.analytic.line'].browse(response['result']['id'])
104+
self.assertIsNotNone(updated_timesheet)
105+
self.assertFalse(updated_timesheet.timer_active)
106+
self.assertGreater(updated_timesheet.unit_amount, 0.0)
107+
108+
def test_stopping_a_timesheet_respects_max_hours(self):
109+
"""Test stopping a timesheet respects configured max hours."""
110+
max_hours = 1
111+
self.env['ir.config_parameter'].sudo().set_param('time_tracking_kiosk.max_work_hours_per_day', max_hours)
112+
start_time = datetime.now() - timedelta(hours=2)
113+
timesheet = self.env['account.analytic.line'].create({
114+
'project_id': self.test_project.id,
115+
'task_id': self.test_task.id,
116+
'employee_id': self.test_employee.id,
117+
'name': 'Work in Progress',
118+
'unit_amount': 0.0,
119+
'date': fields.Date.today(),
120+
'timer_active': True,
121+
'timer_start_time': start_time,
122+
})
123+
self.authenticate('test_employee_user', 'test_employee_user')
124+
response = self.send_json_post_request('/timesheet/stop', {
125+
'params': {
126+
'timesheet_id': timesheet.id,
127+
},
128+
})
129+
updated_timesheet = self.env['account.analytic.line'].browse(response['result']['id'])
130+
self.assertEqual(updated_timesheet.unit_amount, max_hours)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<odoo>
2+
<record id="view_employee_form_inherit_portal" model="ir.ui.view">
3+
<field name="name">hr.employee.form.inherit.portal</field>
4+
<field name="model">hr.employee</field>
5+
<field name="inherit_id" ref="hr.view_employee_form"/>
6+
<field name="arch" type="xml">
7+
<xpath expr="//page[@name='hr_settings']//field[@name='user_id']" position="attributes">
8+
<attribute name="domain">[('company_ids', 'in', company_id)]</attribute>
9+
</xpath>
10+
</field>
11+
</record>
12+
</odoo>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<odoo>
2+
<record id="view_res_config_settings_timesheet" model="ir.ui.view">
3+
<field name="name">res.config.settings.view.form.inherit.timesheet</field>
4+
<field name="model">res.config.settings</field>
5+
<field name="inherit_id" ref="hr_timesheet.res_config_settings_view_form"/>
6+
<field name="arch" type="xml">
7+
<xpath expr="//app[@name='hr_timesheet']/block[1]" position="before">
8+
<block title="Timesheet Settings">
9+
<setting name="max_work_hours_per_day_setting" help="Set the maximum allowed work hours per day">
10+
<field name="max_work_hours_per_day" />
11+
</setting>
12+
<setting name="pm_email_template_setting" help="Select an email template for PM notifications">
13+
<field name="pm_notification_template_id" />
14+
</setting>
15+
</block>
16+
</xpath>
17+
</field>
18+
</record>
19+
</odoo>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<odoo>
3+
<record id="time_tracking_kiosk_action" model="ir.actions.client">
4+
<field name="name">Timesheet Kiosk</field>
5+
<field name="tag">time_tracking_kiosk_main</field>
6+
</record>
7+
</odoo>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0"?>
2+
<odoo>
3+
<menuitem id="menu_timesheet_kiosk_root"
4+
name="Timesheet Kiosk"
5+
web_icon="time_tracking_kiosk,static/description/icon.png"/>
6+
7+
<menuitem id="menu_timesheet_kiosk_mode"
8+
name="Kiosk Mode"
9+
parent="menu_timesheet_kiosk_root"
10+
action="time_tracking_kiosk_action"/>
11+
</odoo>

0 commit comments

Comments
 (0)
Please sign in to comment.