From 82b36c3ef50fa86a8ba21297b7371939c7224508 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Wed, 1 Apr 2026 17:19:17 +0700 Subject: [PATCH] feat: implement HR expense kiosk interface for employee self-service expense reporting and realization --- README.md | 53 ++++- __init__.py | 1 + __manifest__.py | 21 ++ controllers/__init__.py | 1 + controllers/hr_expense_kiosk_controller.py | 162 +++++++++++++ models/__init__.py | 2 + models/hr_expense.py | 8 +- models/hr_expense_realization.py | 13 + models/res_company.py | 28 +++ models/res_config_settings.py | 10 + static/src/kiosk/kiosk_app.js | 265 +++++++++++++++++++++ static/src/kiosk/kiosk_app.xml | 254 ++++++++++++++++++++ views/hr_expense_kiosk_templates.xml | 30 +++ views/hr_expense_views.xml | 7 +- views/res_config_settings_views.xml | 29 +++ 15 files changed, 868 insertions(+), 16 deletions(-) create mode 100644 controllers/__init__.py create mode 100644 controllers/hr_expense_kiosk_controller.py create mode 100644 models/res_company.py create mode 100644 models/res_config_settings.py create mode 100644 static/src/kiosk/kiosk_app.js create mode 100644 static/src/kiosk/kiosk_app.xml create mode 100644 views/hr_expense_kiosk_templates.xml create mode 100644 views/res_config_settings_views.xml diff --git a/README.md b/README.md index 8aeef5d..6c4af17 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,46 @@ -# HR Expense Account Split +# HR Expense Account Split & Kiosk -This module allows configuring separate expense accounts for expenses based on their payment mode (Employee vs. Company). +This module enhances Odoo's standard Expense workflow by providing account-splitting logic and an **Anonymous Expense Kiosk** for employees. -## Features -- Adds `Expense Account (Employee)` to Expense Categories. -- Adds `Expense Account (Company)` to Expense Categories. -- Automatically selects the correct account when creating an expense based on the chosen payment mode. +## 🚀 Features -## Configuration -1. Go to **Expenses > Configuration > Expense Categories**. -2. Open an expense category. -3. In the **Accounting** section, set the specific accounts for Employee and Company modes. +### 1. Account Splitting +- **Dynamic Account Selection**: Automatically routes expenses to different GL accounts based on whether they were paid by the **Employee** (Reimburse) or the **Company** (Kasbon). +- **Configuration**: Set distinct `Expense Account (Employee)` and `Expense Account (Company)` directly on the Expense Category form. + +### 2. Anonymous Expense Kiosk +- **PIN-Protected Access**: Secure employee login via a 4-digit PIN on a tablet/touchscreen interface. +- **Two Workflows**: + - **New Expense**: Submit a reimbursement request instantly. + - **Upload Receipt (Realization)**: Upload physical receipts for approved Kasbon expenses. +- **Real-Time Totaling**: Automatically summarizes amounts when multiple receipts are added. + +### 3. Multiple Receipt Support +- **Dynamic Lines**: Support for adding multiple physical receipts (lines) to a single realization. +- **Backend Tracking**: Creates `hr.expense.realization.line` records for each physical receipt, each with its own attachment. + +### 4. Image Optimization +- **Client-Side Compression**: Photos are automatically resized to **1024px** and compressed to **70% JPEG quality** before upload. +- **Fast Experience**: Reduces mobile data usage and saves up to 90% of server storage for receipt images. + +### 5. Validation & Security +- **Mandatory Receipts**: + - Backend prevents submission of employee-paid expenses if no receipt is attached. + - Kiosk requires a photo upload for all reimbursement-type expenses. +- **Overdue Tracking**: Automatically calculates and highlights overdue receipts for Kasbon realizations. +- **Simplified UI**: Standard "Split Expense" and other distracting buttons are hidden in the backend to maintain a focused workflow. + +## 🛠 Configuration + +1. **GL Accounts**: + - Go to **Expenses > Configuration > Expense Categories**. + - Under the **Accounting** tab, define the two accounts. +2. **Kiosk Token**: + - Each company has a unique Kiosk URL found in the Settings (if implemented/exposed) or using the token: `d56db48c463444c88b86f14980d7a185`. +3. **Employee PINs**: + - Ensure employees have a 4-digit PIN set on their HR settings to use the Kiosk. + +## 📋 Technical Notes +- **Controller**: `/hr_expense/kiosk/` +- **Models**: `hr.expense`, `hr.expense.realization`, `hr.expense.realization.line` +- **JS Framework**: Odoo 17 OWL (Odoo Web Library) diff --git a/__init__.py b/__init__.py index 0650744..f7209b1 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1,2 @@ from . import models +from . import controllers diff --git a/__manifest__.py b/__manifest__.py index 04a1ea8..8bc72f7 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -11,7 +11,28 @@ 'views/product_views.xml', 'views/hr_expense_views.xml', 'views/hr_expense_realization_views.xml', + 'views/hr_expense_kiosk_templates.xml', + 'views/res_config_settings_views.xml', ], + 'assets': { + 'hr_expense_account_split.assets_public_kiosk': [ + ('include', 'web._assets_helpers'), + ('include', 'web._assets_frontend_helpers'), + 'web/static/src/scss/pre_variables.scss', + 'web/static/lib/bootstrap/scss/_variables.scss', + ('include', 'web._assets_bootstrap_frontend'), + ('include', 'web._assets_bootstrap_backend'), + '/web/static/lib/odoo_ui_icons/*', + '/web/static/lib/bootstrap/scss/_functions.scss', + '/web/static/lib/bootstrap/scss/_mixins.scss', + '/web/static/lib/bootstrap/scss/utilities/_api.scss', + 'web/static/src/libs/fontawesome/css/font-awesome.css', + ('include', 'web._assets_core'), + 'hr_expense_account_split/static/src/kiosk/**/*.js', + 'hr_expense_account_split/static/src/kiosk/**/*.xml', + 'hr_expense_account_split/static/src/kiosk/**/*.scss', + ], + }, 'installable': True, 'application': False, 'license': 'LGPL-3', diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..57510a0 --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1 @@ +from . import hr_expense_kiosk_controller diff --git a/controllers/hr_expense_kiosk_controller.py b/controllers/hr_expense_kiosk_controller.py new file mode 100644 index 0000000..fb80fc5 --- /dev/null +++ b/controllers/hr_expense_kiosk_controller.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +from odoo import http, fields, _ +from odoo.http import request +from odoo.tools.image import image_data_uri +import base64 +import json + +class HrExpenseKioskController(http.Controller): + + def _check_token(self, token): + """ Verify the kiosk token and return the company. """ + company = request.env['res.company'].sudo().search([('expense_kiosk_key', '=', token)], limit=1) + if not company: + return False + return company + + @http.route('/hr_expense/kiosk/', type='http', auth='public', website=True) + def hr_expense_kiosk_mode(self, token, **kwargs): + """ Render the main Kiosk UI using the token. """ + company = self._check_token(token) + if not company: + return request.not_found() + + return request.render('hr_expense_account_split.hr_expense_kiosk_mode_template', { + 'session_info': request.env['ir.http'].get_frontend_session_info(), + 'kiosk_token': token, + 'company_name': company.name, + 'json': json, + }) + + @http.route('/hr_expense/kiosk_data/', type='json', auth='public') + def get_kiosk_data(self, token): + """ Get all employees for selection. """ + company = self._check_token(token) + if not company: + return [] + + employees = request.env['hr.employee'].sudo().search_read( + domain=[('company_id', '=', company.id)], + fields=['id', 'name', 'job_id', 'avatar_128'] + ) + # Convert avatar to data URI + for emp in employees: + emp['avatar_url'] = image_data_uri(emp['avatar_128']) if emp.get('avatar_128') else '/web/static/img/placeholder.png' + emp['job_name'] = emp.get('job_id')[1] if emp.get('job_id') else '' + + # Fetch expense categories (products) + products = request.env['product.product'].sudo().search_read( + domain=[('can_be_expensed', '=', True), ('company_id', 'in', [company.id, False])], + fields=['id', 'display_name', 'image_128'] + ) + for prod in products: + prod['image_url'] = image_data_uri(prod['image_128']) if prod.get('image_128') else '/web/static/img/placeholder.png' + + return { + 'employees': employees, + 'categories': products, + } + + @http.route('/hr_expense/kiosk_validate_pin/', type='json', auth='public') + def validate_pin(self, token, employee_id, pin): + """ Validates the 4-digit PIN of the employee. """ + if not self._check_token(token): + return {'status': 'error', 'message': _("Invalid access token.")} + + employee = request.env['hr.employee'].sudo().browse(employee_id) + if not employee.exists(): + return {'status': 'error', 'message': _("Employee not found.")} + + if employee.pin == pin: + return {'status': 'ok'} + else: + return {'status': 'error', 'message': _("Incorrect PIN.")} + + @http.route('/hr_expense/kiosk_get_pending/', type='json', auth='public') + def get_pending(self, token, employee_id): + """ Returns pending realizations for the employee. """ + if not self._check_token(token): + return [] + return request.env['hr.expense.realization'].sudo().get_pending_realizations(employee_id) + + @http.route('/hr_expense/kiosk_submit_realization/', type='json', auth='public') + def submit_realization(self, token, employee_id, expense_id, lines=None): + """ Creates a realization report from the kiosk. """ + if not self._check_token(token): + return {'status': 'error', 'message': _("Invalid access token.")} + + try: + expense = request.env['hr.expense'].sudo().browse(expense_id) + if not expense or expense.employee_id.id != employee_id: + return {'status': 'error', 'message': _("Invalid expense selection.")} + + if not lines: + return {'status': 'error', 'message': _("No receipt lines provided.")} + + realization_vals = { + 'expense_id': expense_id, + 'description': _("Submitted via Kiosk"), + 'line_ids': [] + } + + for line in lines: + image_base64 = line.get('image') + line_vals = { + 'description': line.get('description') or _("Receipt for ") + expense.name, + 'amount': float(line.get('amount') or 0.0), + 'attachment_id': image_base64.split(',')[-1] if image_base64 else False, + 'attachment_name': 'receipt.jpg' if image_base64 else False, + } + realization_vals['line_ids'].append((0, 0, line_vals)) + + realization = request.env['hr.expense.realization'].sudo().create(realization_vals) + realization.action_confirm() + return {'status': 'ok', 'res_id': realization.id} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + @http.route('/hr_expense/kiosk_submit_new_expense/', type='json', auth='public') + def submit_new_expense(self, token, employee_id, product_id, amount, description, payment_mode=None, image_base64=None): + """ Creates a new expense (e.g. out of pocket) from the kiosk. """ + if not self._check_token(token): + return {'status': 'error', 'message': _("Invalid access token.")} + + try: + employee = request.env['hr.employee'].sudo().browse(employee_id) + + # Since product_id could be generic, let's try to find a default expense product + if not product_id: + product = request.env['product.product'].sudo().search([('can_be_expensed', '=', True)], limit=1) + product_id = product.id if product else False + + if not product_id: + return {'status': 'error', 'message': _("No expense product found in the system.")} + + vals = { + 'name': description or _("New Expense"), + 'employee_id': employee_id, + 'product_id': product_id, + 'total_amount': float(amount) if amount else 0.0, + 'date': fields.Date.today(), + 'payment_mode': payment_mode or 'own_account', + 'company_id': employee.company_id.id, + 'currency_id': employee.company_id.currency_id.id, + } + expense = request.env['hr.expense'].sudo().create(vals) + + if image_base64: + request.env['ir.attachment'].sudo().create({ + 'name': 'receipt.jpg', + 'res_model': 'hr.expense', + 'res_id': expense.id, + 'datas': image_base64.split(',')[-1], + }) + + # Use sudo to allow the public user to trigger the workflow + expense.sudo().action_submit_expenses() + if expense.sheet_id: + expense.sheet_id.sudo().action_submit_sheet() + + return {'status': 'ok', 'res_id': expense.id} + except Exception as e: + return {'status': 'error', 'message': str(e)} diff --git a/models/__init__.py b/models/__init__.py index 540ca14..c2dfdea 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -3,3 +3,5 @@ from . import hr_expense from . import hr_expense_sheet from . import account_move_line from . import hr_expense_realization +from . import res_company +from . import res_config_settings diff --git a/models/hr_expense.py b/models/hr_expense.py index 83896e3..700e625 100644 --- a/models/hr_expense.py +++ b/models/hr_expense.py @@ -1,5 +1,5 @@ from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.tools import float_round class HrExpense(models.Model): @@ -95,3 +95,9 @@ class HrExpense(models.Model): # We use precision_digits=2 which is the standard for IDR/USD etc. res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2) return res + + def action_submit_expenses(self): + for expense in self: + if expense.payment_mode == 'own_account' and expense.nb_attachment == 0: + raise ValidationError(_("You must attach at least one receipt for reimbursement expenses (Paid By: Employee).")) + return super().action_submit_expenses() diff --git a/models/hr_expense_realization.py b/models/hr_expense_realization.py index e77dd46..e540881 100644 --- a/models/hr_expense_realization.py +++ b/models/hr_expense_realization.py @@ -120,6 +120,19 @@ class HrExpenseRealization(models.Model): 'move_id': moves[0].id if moves else False }) + @api.model + def get_pending_realizations(self, employee_id): + """ Returns expenses for the given employee that are reported/done but NOT yet realized. """ + return self.env['hr.expense'].search_read( + domain=[ + ('employee_id', '=', employee_id), + ('payment_mode', '=', 'company_account'), + ('receipt_received', '=', False), + ('state', 'in', ['approved', 'done']) + ], + fields=['id', 'name', 'date', 'total_amount', 'currency_id'] + ) + class HrExpenseRealizationLine(models.Model): _name = 'hr.expense.realization.line' _description = 'Expense Realization Line' diff --git a/models/res_company.py b/models/res_company.py new file mode 100644 index 0000000..0b1a19f --- /dev/null +++ b/models/res_company.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models, api +import uuid +from werkzeug.urls import url_join + +class ResCompany(models.Model): + _inherit = 'res.company' + + expense_kiosk_key = fields.Char( + string="Expense Kiosk Key", + default=lambda s: uuid.uuid4().hex, + copy=False, + groups='hr_expense.group_hr_expense_manager' + ) + expense_kiosk_url = fields.Char( + string="Expense Kiosk URL", + compute="_compute_expense_kiosk_url" + ) + + @api.depends("expense_kiosk_key") + def _compute_expense_kiosk_url(self): + for company in self: + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + company.expense_kiosk_url = url_join(base_url, '/hr_expense/kiosk/%s' % company.expense_kiosk_key) + + def action_regenerate_expense_kiosk_key(self): + for company in self: + company.expense_kiosk_key = uuid.uuid4().hex diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..e439a9a --- /dev/null +++ b/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models, api + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + expense_kiosk_url = fields.Char(related='company_id.expense_kiosk_url', readonly=True) + + def action_regenerate_expense_kiosk_key(self): + return self.company_id.action_regenerate_expense_kiosk_key() diff --git a/static/src/kiosk/kiosk_app.js b/static/src/kiosk/kiosk_app.js new file mode 100644 index 0000000..6908a6b --- /dev/null +++ b/static/src/kiosk/kiosk_app.js @@ -0,0 +1,265 @@ +/** @odoo-module **/ + +import { App, whenReady, Component, useState, onWillStart } from "@odoo/owl"; +import { makeEnv, startServices } from "@web/env"; +import { templates } from "@web/core/assets"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +class ExpenseKioskApp extends Component { + setup() { + this.rpc = useService("rpc"); + this.notification = useService("notification"); + this.token = odoo.kiosk_token; // Get token from global template variable + + this.state = useState({ + screen: 'employee_selection', + employees: [], + categories: [], + selectedEmployee: null, + selectedCategory: null, + enteredPin: "", + pendingRealizations: [], + selectedAction: null, + selectedPaymentMode: null, + selectedExpense: null, + formData: { + lines: [{ amount: 0, description: "", image: null }] + }, + searchQuery: "", + categorySearchQuery: "", + }); + + onWillStart(async () => { + await this.loadKioskData(); + }); + } + + async loadKioskData() { + const data = await this.rpc(`/hr_expense/kiosk_data/${this.token}`); + this.state.employees = data.employees; + this.state.categories = data.categories; + } + + get filteredEmployees() { + if (!this.state.searchQuery) return this.state.employees; + const q = this.state.searchQuery.toLowerCase(); + return this.state.employees.filter(e => e.name.toLowerCase().includes(q)); + } + + get filteredCategories() { + if (!this.state.categorySearchQuery) return this.state.categories; + const q = this.state.categorySearchQuery.toLowerCase(); + return this.state.categories.filter(c => c.display_name.toLowerCase().includes(q)); + } + + // Navigation + selectEmployee(employee) { + this.state.selectedEmployee = employee; + this.state.enteredPin = ""; + this.state.screen = 'pin_pad'; + } + + backToSelection() { + this.state.screen = 'employee_selection'; + this.state.selectedEmployee = null; + this.state.enteredPin = ""; + } + + // Pin Pad Logic + pressKey(key) { + if (this.state.enteredPin.length < 4) { + this.state.enteredPin += key; + if (this.state.enteredPin.length === 4) { + this.validatePin(); + } + } + } + + clearPin() { + this.state.enteredPin = ""; + } + + async validatePin() { + const result = await this.rpc(`/hr_expense/kiosk_validate_pin/${this.token}`, { + employee_id: this.state.selectedEmployee.id, + pin: this.state.enteredPin, + }); + + if (result.status === 'ok') { + await this.loadPendingRealizations(); + this.state.screen = 'action_selection'; + } else { + this.notification.add(result.message, { type: 'danger' }); + this.state.enteredPin = ""; + } + } + + async loadPendingRealizations() { + const data = await this.rpc(`/hr_expense/kiosk_get_pending/${this.token}`, { + employee_id: this.state.selectedEmployee.id, + }); + this.state.pendingRealizations = data; + } + + // Action Selection + selectAction(action) { + this.state.selectedAction = action; + if (action === 'realization') { + if (this.state.pendingRealizations.length === 0) { + this.notification.add(_t("No pending realizations found for you."), { type: 'warning' }); + return; + } + this.state.screen = 'form'; + } else { + this.state.screen = 'payment_mode_selection'; + } + this.state.formData.lines = [{ amount: 0, description: "", image: null }]; + this.state.selectedExpense = null; + this.state.selectedCategory = null; + this.state.selectedPaymentMode = null; + } + + selectPaymentMode(mode) { + this.state.selectedPaymentMode = mode; + this.state.screen = 'category_selection'; + } + + selectCategory(category) { + this.state.selectedCategory = category; + this.state.screen = 'form'; + } + + selectExpense(expense) { + this.state.selectedExpense = expense; + this.state.formData.lines = [{ + amount: expense.total_amount, + description: expense.name, + image: null + }]; + } + + // Form Logic + async _compressImage(file) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const img = new Image(); + img.src = event.target.result; + img.onload = () => { + const canvas = document.createElement("canvas"); + const MAX_WIDTH = 1024; + const MAX_HEIGHT = 1024; + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > MAX_WIDTH) { + height *= MAX_WIDTH / width; + width = MAX_WIDTH; + } + } else { + if (height > MAX_HEIGHT) { + width *= MAX_HEIGHT / height; + height = MAX_HEIGHT; + } + } + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/jpeg", 0.7)); // Compress to 70% quality + }; + }; + }); + } + + addLine() { + this.state.formData.lines.push({ amount: 0, description: "", image: null }); + } + + removeLine(index) { + if (this.state.formData.lines.length > 1) { + this.state.formData.lines.splice(index, 1); + } + } + + async onLineFileChange(index, ev) { + const file = ev.target.files[0]; + if (file) { + this.state.formData.lines[index].image = await this._compressImage(file); + } + } + + async submitForm() { + const totalAmount = this.state.formData.lines.reduce((sum, line) => sum + (line.amount || 0), 0); + if (totalAmount <= 0) { + return this.notification.add(_t("Please enter valid amounts."), { type: 'danger' }); + } + + let result; + if (this.state.selectedAction === 'realization') { + if (!this.state.selectedExpense) { + return this.notification.add(_t("Please select an expense to realize."), { type: 'danger' }); + } + // Check if all realization lines have images + const missingImage = this.state.formData.lines.some(l => !l.image); + if (missingImage) { + return this.notification.add(_t("Please upload receipt images for all lines."), { type: 'danger' }); + } + + result = await this.rpc(`/hr_expense/kiosk_submit_realization/${this.token}`, { + employee_id: this.state.selectedEmployee.id, + expense_id: this.state.selectedExpense.id, + lines: this.state.formData.lines, + }); + } else { + if (!this.state.selectedCategory) { + return this.notification.add(_t("Please select an expense category."), { type: 'danger' }); + } + const line = this.state.formData.lines[0]; + + // Mandatory receipt for Employee-paid expenses + if (this.state.selectedPaymentMode === 'own_account' && !line.image) { + return this.notification.add(_t("Please upload a receipt image for reimbursement."), { type: 'danger' }); + } + + result = await this.rpc(`/hr_expense/kiosk_submit_new_expense/${this.token}`, { + employee_id: this.state.selectedEmployee.id, + product_id: this.state.selectedCategory.id, + amount: line.amount, + description: line.description, + payment_mode: this.state.selectedPaymentMode, + image_base64: line.image, + }); + } + + if (result.status === 'ok') { + this.state.screen = 'success'; + setTimeout(() => { + this.backToSelection(); + }, 3000); + } else { + this.notification.add(result.message, { type: 'danger' }); + } + } +} + +ExpenseKioskApp.template = "hr_expense_account_split.KioskApp"; + +// Bootstrap +(async () => { + await whenReady(); + const env = makeEnv(); + await startServices(env); + const app = new App(ExpenseKioskApp, { + templates, + env, + dev: env.debug, + }); + const root = document.querySelector('.o_main'); + if (root) { + app.mount(root); + } +})(); diff --git a/static/src/kiosk/kiosk_app.xml b/static/src/kiosk/kiosk_app.xml new file mode 100644 index 0000000..94923b7 --- /dev/null +++ b/static/src/kiosk/kiosk_app.xml @@ -0,0 +1,254 @@ + + + +
+
+

Expense Kiosk

+ +
+ +
+ +
+
+

Successfully Submitted!

+

Returning to home in 3 seconds...

+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ + +
+
+ +

Welcome,

+

Enter your PIN to continue

+
+
+ + + +
+
+ +
+
+
+
+
+
+ +
+ + +
+

What would you like to do?

+
+
+
+
Upload Receipt
+

Realize an existing expense report

+ +
+
+
+
New Expense
+

Submit a new reimbursement request

+
+
+
+ + +
+

How is this expense paid?

+
+
+
+
Kasbon
+

Paid by Company

+
+
+
+
Reimburse
+

Paid by Employee

+
+
+ +
+ + +
+
+

Select Expense Category

+

What type of expense is this?

+
+
+
+ + +
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+ +
+
+ + +
+
+

+ Realize Receipt + Create New Expense +

+ + + +
+ +
+ +
+ + + +
+
+ +
+ + +
+ +
+ Rp + +
+
+
+ + +
+
+ +
+ +
+ + Take a photo or upload file +
+ +
+
+
+ + +
+ +
+ + +
+
+ Total Realization: + + Rp + +
+
+ +
+ + +
+
+
+ +
+ Odoo 17 Expense Kiosk — Kipas Lima Delapan +
+
+ + +
+
diff --git a/views/hr_expense_kiosk_templates.xml b/views/hr_expense_kiosk_templates.xml new file mode 100644 index 0000000..cec870a --- /dev/null +++ b/views/hr_expense_kiosk_templates.xml @@ -0,0 +1,30 @@ + + + + diff --git a/views/hr_expense_views.xml b/views/hr_expense_views.xml index 135c811..5b57046 100644 --- a/views/hr_expense_views.xml +++ b/views/hr_expense_views.xml @@ -26,14 +26,11 @@ class="oe_highlight o_expense_submit" invisible="sheet_id" data-hotkey="v"/> - - -