Compare commits

...

16 Commits
17.0 ... 19.0

Author SHA1 Message Date
b6c634d54a docs: fix alignment of header underline in README.rst 2026-05-06 13:45:04 +07:00
35934e75ab docs: convert README from Markdown to reStructuredText format 2026-05-06 11:33:11 +07:00
f667025d92 refactor: update kiosk controller routes to use jsonrpc type instead of json 2026-05-05 15:29:19 +07:00
6fc645230d refactor: use self.env.context instead of self._context to safely retrieve skip_expense_lock flag 2026-05-05 14:53:55 +07:00
3d96ca446b refactor: use env.context instead of self._context to safely retrieve skip_expense_lock flag 2026-05-05 14:42:50 +07:00
15e9c93dad feat: prevent accidental journal reversals for expense payments by hiding the reverse button 2026-04-30 21:53:04 +07:00
819bce0bd0 refactor: simplify expense kiosk configuration view layout and improve button styling 2026-04-30 17:03:40 +07:00
1d41708bfa refactor: reorient payment wizard to operate on individual expenses instead of expense sheets 2026-04-30 16:46:17 +07:00
f2a4c72101 feat: add payment registration wizard for expense sheets and introduce wait_post state for company account reconciliations 2026-04-30 09:32:15 +07:00
58db2572a4 fix: remove limit from hr_expense search in kiosk controller to fetch all expenses 2026-04-21 12:13:42 +07:00
eb17bb8e2a refactor: consolidate expense lock skip logic in account payment write method 2026-04-21 12:07:39 +07:00
07b70336c6 feat: add history view for recent expense submissions to kiosk dashboard 2026-04-21 11:48:23 +07:00
a981456e93 feat: add search and group-by filters to hr.expense.realization view 2026-04-21 11:08:56 +07:00
bfaacbfe7c refactor: migrate Odoo 19 view components, update payment reference fields, and simplify account domain constraints 2026-04-21 10:59:47 +07:00
4a64d2b73c refactor: remove HrExpenseSheet model and consolidate expense status logic into HrExpense and HrExpenseRealization models 2026-04-21 09:23:39 +07:00
8d38d49c85 migrate to odoo 19.0 2026-04-20 10:51:36 +07:00
22 changed files with 527 additions and 526 deletions

View File

@ -1,53 +0,0 @@
# HR Expense Account Split & Kiosk
This module enhances Odoo's standard Expense workflow by providing account-splitting logic, an **Anonymous Expense Kiosk** for employees, and automated realization accounting.
## 🚀 Features
### 1. Dynamic Sequences
- **Employee Reimbursement (RMBS)**: Expenses paid by the employee follow the prefix `RMBS/YYYY/MM/XXXXX`.
* **Company Advance (KSBN)**: Expenses paid by the company (Kasbon) follow the prefix `KSBN/YYYY/MM/XXXXX`.
* This ensures clear separation between standard reimbursements and company-issued advances at a glance.
### 2. Enhanced Status Workflow
- **Wait Post Status (Yellow)**: A new intermediate status for company-paid expense reports.
- **Workflow**: `Approved` -> `Posted` -> **`Wait Post`** -> `Done`.
- **Logic**: The report moves to **Wait Post** after the advance is paid. It stays here until the employee submits all receipts and the accountant posts the final realization journal.
- **Done (Green)**: Only reached when all realization accounting is completed, ensuring the physical and financial cycles are fully synchronized.
### 3. Account Splitting
- **Dynamic Selection**: Automatically routes expenses to different GL accounts based on the `Paid By` field.
- **Configuration**: Set distinct `Expense Account (Employee)` and `Expense Account (Company)` directly on the Expense Category form.
### 4. Anonymous Expense Kiosk
- **PIN-Protected Access**: Secure employee login via a 4-digit PIN on a tablet interface.
- **Real-Time Totaling**: Automatically summarizes multiple physical receipts into a single realization.
- **Image Optimization**: Client-side JPEG compression (1024px, 70% quality) reduces server storage usage by ~90%.
### 5. Automated Realization Accounting
- **Balanced Journal Entries**: Automatically calculates discrepancies between the advance paid and actual spending.
- **Discrepancy Accounts**:
- **Over-spent (Spent > Paid)**: Balance is moved to `216109 Biaya Lain yang masih harus dibayar` (Liability/Payable).
- **Under-spent (Spent < Paid)**: Balance is moved to `114101 Piutang Karyawan` (Receivable).
- **Discrepancy Settlement**:
- **Create Vendor Payment**: One-click button on the realization form to pay the employee the difference.
- **Create Customer Payment**: One-click button on the realization form to record the employee returning the excess funds.
### 6. Validation & Security
- **Mandatory Receipts**: Prevents submission of employee-paid expenses without attachments.
- **Overdue Tracking**: Highlights missing realization receipts in red/alerts for company-paid expenses.
## 🛠 Configuration
1. **GL Accounts**:
- **Expenses > Configuration > Expense Categories**.
- Define the two accounts under the **Accounting** tab.
2. **Kiosk Token**:
- URL structure: `/hr_expense/kiosk/d56db48c463444c88b86f14980d7a185`.
3. **Employee PINs**:
- Set 4-digit PINs on employee records for Kiosk access.
## 📋 Technical Notes
- **Controller**: `/hr_expense/kiosk/<token>`
- **Models**: `hr.expense`, `hr.expense.sheet`, `hr.expense.realization`, `account.payment`
- **JS Framework**: Odoo 17 OWL

68
README.rst Normal file
View File

@ -0,0 +1,68 @@
==========================================
HR Expense Account Split & Kiosk (Odoo 19)
==========================================
This module enhances Odoo's standard Expense workflow by providing account-splitting logic,
an **Anonymous Expense Kiosk** for employees, and automated realization accounting.
Features
========
1. Dynamic Sequences
--------------------
* **Employee Reimbursement (RMBS)**: Expenses paid by the employee follow the prefix `RMBS/YYYY/MM/XXXXX`.
* **Company Advance (KSBN)**: Expenses paid by the company (Kasbon) follow the prefix `KSBN/YYYY/MM/XXXXX`.
* This ensures clear separation between standard reimbursements and company-issued advances at a glance.
2. Enhanced Status Workflow (Odoo 19 Refined)
---------------------------------------------
* **Wait Post Status (Yellow)**: A new intermediate status for company-paid expense reports.
* **Workflow**: `Approved` -> `Posted` -> **`Wait Post`** -> `Done`.
* **Logic**: The report moves to **Wait Post** after the advance is paid. It stays here until the employee submits all receipts and the accountant posts the final realization journal.
* **Done (Green)**: Only reached when all realization accounting is completed, ensuring the physical and financial cycles are fully synchronized.
* **Architectural Note**: In Odoo 19, the legacy `hr.expense.sheet` logic has been merged directly into the core `hr.expense` model for better performance and consistency.
3. Account Splitting & Kasbon Logic
-----------------------------------
* **Dynamic Selection**: Automatically routes expenses to different GL accounts based on the `Paid By` field.
* **Configuration**: Set distinct `Expense Account (Employee)` and `Expense Account (Company)` directly on the Expense Category form.
* **Advance Account**: Company-paid advances (Kasbon) use account **115101** for the debit side of the initial payment journal entry.
4. Journal Entry Safeguards
---------------------------
* **Reversal Protection**: The **Reverse Entry** button is automatically hidden on journal entries linked to expenses. This prevents accidental reversals that would cause the accounting ledger to fall out of sync with the expense status.
5. Anonymous Expense Kiosk
--------------------------
* **PIN-Protected Access**: Secure employee login via a 4-digit PIN on a tablet interface.
* **Real-Time Totaling**: Automatically summarizes multiple physical receipts into a single realization.
* **Image Optimization**: Client-side JPEG compression (1024px, 70% quality) reduces server storage usage by ~90%.
6. Automated Realization Accounting
-----------------------------------
* **Balanced Journal Entries**: Automatically calculates discrepancies between the advance paid and actual spending.
* **Discrepancy Accounts**:
* **Over-spent (Spent > Paid)**: Balance is moved to `216109 Biaya Lain yang masih harus dibayar` (Liability/Payable).
* **Under-spent (Spent < Paid)**: Balance is moved to `114101 Piutang Karyawan` (Receivable).
* **Discrepancy Settlement**:
* **Create Vendor Payment**: One-click button on the realization form to pay the employee the difference.
* **Create Customer Payment**: One-click button on the realization form to record the employee returning the excess funds.
Configuration
=============
1. **GL Accounts**:
* **Expenses > Configuration > Expense Categories**.
* Define the two accounts under the **Accounting** tab.
2. **Kiosk Activation**:
* Go to **Expenses > Configuration > Settings**.
* The **Kiosk URL** is displayed in the "Expense Kiosk" block. You can regenerate the token here if needed.
3. **Employee PINs**:
* Set 4-digit PINs on employee records for Kiosk access.
Technical Notes
===============
* **Odoo Version**: 19.0
* **Controller**: `/hr_expense/kiosk/<token>`
* **Models**: `hr.expense`, `hr.expense.realization`, `account.move`, `account.payment`
* **JS Framework**: Odoo 19 OWL

View File

@ -1,6 +1,6 @@
{ {
'name': 'HR Expense: Split Account by Payment Mode', 'name': 'HR Expense: Split Account by Payment Mode',
'version': '17.0.1.0.1', 'version': '19.0.1.0.1',
'summary': 'Set different expense accounts for Employee paid vs Company paid expenses on Expense Categories.', 'summary': 'Set different expense accounts for Employee paid vs Company paid expenses on Expense Categories.',
'category': 'Human Resources/Expenses', 'category': 'Human Resources/Expenses',
'author': 'Suherdy Yacob', 'author': 'Suherdy Yacob',
@ -10,9 +10,11 @@
'data/ir_sequence_data.xml', 'data/ir_sequence_data.xml',
'views/product_views.xml', 'views/product_views.xml',
'views/hr_expense_views.xml', 'views/hr_expense_views.xml',
'views/hr_expense_payment_wizard_views.xml',
'views/hr_expense_realization_views.xml', 'views/hr_expense_realization_views.xml',
'views/hr_expense_kiosk_templates.xml', 'views/hr_expense_kiosk_templates.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/account_move_views.xml',
], ],
'assets': { 'assets': {
'hr_expense_account_split.assets_public_kiosk': [ 'hr_expense_account_split.assets_public_kiosk': [

View File

@ -28,7 +28,7 @@ class HrExpenseKioskController(http.Controller):
'json': json, 'json': json,
}) })
@http.route('/hr_expense/kiosk_data/<string:token>', type='json', auth='public') @http.route('/hr_expense/kiosk_data/<string:token>', type='jsonrpc', auth='public')
def get_kiosk_data(self, token): def get_kiosk_data(self, token):
""" Get all employees for selection. """ """ Get all employees for selection. """
company = self._check_token(token) company = self._check_token(token)
@ -57,7 +57,7 @@ class HrExpenseKioskController(http.Controller):
'categories': products, 'categories': products,
} }
@http.route('/hr_expense/kiosk_validate_pin/<string:token>', type='json', auth='public') @http.route('/hr_expense/kiosk_validate_pin/<string:token>', type='jsonrpc', auth='public')
def validate_pin(self, token, employee_id, pin): def validate_pin(self, token, employee_id, pin):
""" Validates the 4-digit PIN of the employee. """ """ Validates the 4-digit PIN of the employee. """
if not self._check_token(token): if not self._check_token(token):
@ -72,14 +72,45 @@ class HrExpenseKioskController(http.Controller):
else: else:
return {'status': 'error', 'message': _("Incorrect PIN.")} return {'status': 'error', 'message': _("Incorrect PIN.")}
@http.route('/hr_expense/kiosk_get_pending/<string:token>', type='json', auth='public') @http.route('/hr_expense/kiosk_get_pending/<string:token>', type='jsonrpc', auth='public')
def get_pending(self, token, employee_id): def get_pending(self, token, employee_id):
""" Returns pending realizations for the employee. """ """ Returns pending realizations for the employee. """
if not self._check_token(token): if not self._check_token(token):
return [] return []
return request.env['hr.expense.realization'].sudo().get_pending_realizations(employee_id) return request.env['hr.expense.realization'].sudo().get_pending_realizations(employee_id)
@http.route('/hr_expense/kiosk_submit_realization/<string:token>', type='json', auth='public') @http.route('/hr_expense/kiosk_get_submitted/<string:token>', type='jsonrpc', auth='public')
def get_submitted(self, token, employee_id):
""" Returns submitted expenses for the employee. """
if not self._check_token(token):
return []
expenses = request.env['hr.expense'].sudo().search([
('employee_id', '=', employee_id),
('state', 'not in', ['draft', 'refused'])
], order='date desc, id desc')
result = []
state_selection = dict(request.env['hr.expense']._fields['state']._description_selection(request.env))
# Get payment state labels from account.move if possible
payment_selection = dict(request.env['account.move']._fields['payment_state']._description_selection(request.env))
for exp in expenses:
payment_state = exp.account_move_id.payment_state if exp.account_move_id else 'not_paid'
result.append({
'id': exp.id,
'name': exp.name,
'sequences': exp.sequence_name or '',
'date': exp.date.strftime('%Y-%m-%d') if exp.date else '',
'total_amount': exp.currency_id.symbol + " " + "{:,.2f}".format(exp.total_amount),
'state': state_selection.get(exp.state),
'state_raw': exp.state,
'payment_status': payment_selection.get(payment_state, _("Not Paid")),
'payment_state_raw': payment_state,
})
return result
@http.route('/hr_expense/kiosk_submit_realization/<string:token>', type='jsonrpc', auth='public')
def submit_realization(self, token, employee_id, expense_id, lines=None): def submit_realization(self, token, employee_id, expense_id, lines=None):
""" Creates a realization report from the kiosk. """ """ Creates a realization report from the kiosk. """
if not self._check_token(token): if not self._check_token(token):
@ -115,7 +146,7 @@ class HrExpenseKioskController(http.Controller):
except Exception as e: except Exception as e:
return {'status': 'error', 'message': str(e)} return {'status': 'error', 'message': str(e)}
@http.route('/hr_expense/kiosk_submit_new_expense/<string:token>', type='json', auth='public') @http.route('/hr_expense/kiosk_submit_new_expense/<string:token>', type='jsonrpc', auth='public')
def submit_new_expense(self, token, employee_id, product_id, amount, description, payment_mode=None, image_base64=None): 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. """ """ Creates a new expense (e.g. out of pocket) from the kiosk. """
if not self._check_token(token): if not self._check_token(token):
@ -153,9 +184,7 @@ class HrExpenseKioskController(http.Controller):
}) })
# Use sudo to allow the public user to trigger the workflow # Use sudo to allow the public user to trigger the workflow
expense.sudo().action_submit_expenses() expense.sudo().action_submit()
if expense.sheet_id:
expense.sheet_id.sudo().action_submit_sheet()
return {'status': 'ok', 'res_id': expense.id} return {'status': 'ok', 'res_id': expense.id}
except Exception as e: except Exception as e:

View File

@ -1,6 +1,6 @@
from . import product_template from . import product_template
from . import hr_expense from . import hr_expense
from . import hr_expense_sheet from . import hr_expense_payment_wizard
from . import account_move_line from . import account_move_line
from . import account_move from . import account_move
from . import hr_expense_realization from . import hr_expense_realization

View File

@ -1,8 +1,19 @@
from odoo import models, api from odoo import models, fields, api
class AccountMove(models.Model): class AccountMove(models.Model):
_inherit = 'account.move' _inherit = 'account.move'
is_expense_payment = fields.Boolean(
string="Is Expense Payment",
compute="_compute_is_expense_payment",
store=True,
)
@api.depends('expense_ids')
def _compute_is_expense_payment(self):
for move in self:
move.is_expense_payment = bool(move.sudo().expense_ids)
def _get_hr_expense_base_class(self): def _get_hr_expense_base_class(self):
""" Returns the hr_expense class in the MRO to jump over it. """ """ Returns the hr_expense class in the MRO to jump over it. """
mro = type(self).mro() mro = type(self).mro()
@ -11,6 +22,6 @@ class AccountMove(models.Model):
def write(self, vals): def write(self, vals):
# Surgical Jumper to bypass hr_expense's account.move lock # Surgical Jumper to bypass hr_expense's account.move lock
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'): if hr_expense_class and self.env.context.get('skip_expense_lock'):
return super(hr_expense_class, self).write(vals) return super(hr_expense_class, self).write(vals)
return super().write(vals) return super().write(vals)

View File

@ -28,7 +28,7 @@ class AccountMoveLine(models.Model):
# 2. Surgical Jumper to bypass hr_expense's account.move.line lock # 2. Surgical Jumper to bypass hr_expense's account.move.line lock
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'): if hr_expense_class and self.env.context.get('skip_expense_lock'):
return super(hr_expense_class, self).write(vals) return super(hr_expense_class, self).write(vals)
return super().write(vals) return super().write(vals)
@ -36,6 +36,6 @@ class AccountMoveLine(models.Model):
def unlink(self): def unlink(self):
# Surgical Jumper to bypass hr_expense's account.move.line lock # Surgical Jumper to bypass hr_expense's account.move.line lock
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'): if hr_expense_class and self.env.context.get('skip_expense_lock'):
return super(hr_expense_class, self).unlink() return super(hr_expense_class, self).unlink()
return super().unlink() return super().unlink()

View File

@ -42,7 +42,7 @@ class AccountPayment(models.Model):
# Triple Jumper: If we have the bypass flag, we jump over the hr_expense method. # Triple Jumper: If we have the bypass flag, we jump over the hr_expense method.
# This works in tandem with our Model-level jumpers in account_move and account_move_line. # This works in tandem with our Model-level jumpers in account_move and account_move_line.
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if self._context.get('skip_expense_lock') and hr_expense_class: if self.env.context.get('skip_expense_lock') and hr_expense_class:
super(hr_expense_class, self)._synchronize_to_moves(changed_fields) super(hr_expense_class, self)._synchronize_to_moves(changed_fields)
else: else:
super()._synchronize_to_moves(changed_fields) super()._synchronize_to_moves(changed_fields)
@ -56,7 +56,7 @@ class AccountPayment(models.Model):
def _synchronize_from_moves(self, changed_fields): def _synchronize_from_moves(self, changed_fields):
# 1. Standard sync with jumper support # 1. Standard sync with jumper support
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if self._context.get('skip_expense_lock') and hr_expense_class: if self.env.context.get('skip_expense_lock') and hr_expense_class:
super(hr_expense_class, self)._synchronize_from_moves(changed_fields) super(hr_expense_class, self)._synchronize_from_moves(changed_fields)
else: else:
super()._synchronize_from_moves(changed_fields) super()._synchronize_from_moves(changed_fields)
@ -87,11 +87,10 @@ class AccountPayment(models.Model):
def write(self, vals): def write(self, vals):
# Propagate bypass flag during writes to avoid locked checks # Propagate bypass flag during writes to avoid locked checks
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'): if hr_expense_class:
return super(hr_expense_class, self).write(vals) if self.env.context.get('skip_expense_lock') or any(p.expense_ids and p.state == 'draft' for p in self):
return super(hr_expense_class, self.with_context(skip_expense_lock=True)).write(vals)
if self.expense_sheet_id and self.state == 'draft':
return super(AccountPayment, self.with_context(skip_expense_lock=True)).write(vals)
return super().write(vals) return super().write(vals)
def action_cancel(self): def action_cancel(self):
@ -103,10 +102,10 @@ class AccountPayment(models.Model):
res = super().action_cancel() res = super().action_cancel()
for payment in self: for payment in self:
if payment.expense_sheet_id: if payment.expense_ids:
payment.expense_sheet_id.invalidate_recordset(['state']) payment.expense_ids.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_sheet_id: if payment.realization_id and payment.realization_id.expense_id:
payment.realization_id.expense_sheet_id.invalidate_recordset(['state']) payment.realization_id.expense_id.invalidate_recordset(['state'])
return res return res
def action_draft(self): def action_draft(self):
@ -118,10 +117,10 @@ class AccountPayment(models.Model):
res = super().action_draft() res = super().action_draft()
for payment in self: for payment in self:
if payment.expense_sheet_id: if payment.expense_ids:
payment.expense_sheet_id.invalidate_recordset(['state']) payment.expense_ids.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_sheet_id: if payment.realization_id and payment.realization_id.expense_id:
payment.realization_id.expense_sheet_id.invalidate_recordset(['state']) payment.realization_id.expense_id.invalidate_recordset(['state'])
return res return res
def _seek_for_lines(self): def _seek_for_lines(self):
@ -129,7 +128,7 @@ class AccountPayment(models.Model):
Override _seek_for_lines to explicitly force the destination_account_id Override _seek_for_lines to explicitly force the destination_account_id
to be recognized as the counterpart_line. to be recognized as the counterpart_line.
Native Odoo sometimes misclassifies the advance/expense account (e.g. 118101) Native Odoo sometimes misclassifies the advance/expense account (e.g. 115101)
as a write-off line if it's not strictly a receivable/payable account type. as a write-off line if it's not strictly a receivable/payable account type.
Simultaneously, if a tax deduction account (e.g. 217103 PPh 23) is a payable account type, Simultaneously, if a tax deduction account (e.g. 217103 PPh 23) is a payable account type,
Odoo erroneously classifies the deduction as the counterpart. Odoo erroneously classifies the deduction as the counterpart.

View File

@ -1,10 +1,16 @@
from odoo import api, fields, models, _ from odoo import api, fields, models, _, Command
from datetime import timedelta
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round from odoo.tools import float_round
from odoo.tools.misc import clean_context
class HrExpense(models.Model): class HrExpense(models.Model):
_inherit = 'hr.expense' _inherit = 'hr.expense'
state = fields.Selection(selection_add=[
('wait_post', 'Wait Post')
], ondelete={'wait_post': 'set default'})
@api.depends('product_id', 'company_id', 'payment_mode') @api.depends('product_id', 'company_id', 'payment_mode')
def _compute_account_id(self): def _compute_account_id(self):
super()._compute_account_id() super()._compute_account_id()
@ -51,6 +57,20 @@ class HrExpense(models.Model):
realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations') realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations')
realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count') realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count')
receipt_status = fields.Selection([
('pending', 'Pending Receipts'),
('received', 'Receipts Received'),
('none', 'No Receipt Required')
], string='Receipt Status', compute='_compute_receipt_status', store=True, tracking=True)
amount_paid = fields.Monetary(
string='Payment Total',
compute='_compute_amount_paid',
currency_field='currency_id',
store=True,
help="Total amount paid by the finance team."
)
@api.depends('realization_ids') @api.depends('realization_ids')
def _compute_realization_count(self): def _compute_realization_count(self):
for expense in self: for expense in self:
@ -61,6 +81,52 @@ class HrExpense(models.Model):
for expense in self: for expense in self:
expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount')) expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount'))
@api.depends('receipt_received', 'payment_mode')
def _compute_receipt_status(self):
for expense in self:
if expense.payment_mode != 'company_account':
expense.receipt_status = 'none'
elif expense.receipt_received:
expense.receipt_status = 'received'
else:
expense.receipt_status = 'pending'
@api.depends('account_move_id.payment_state', 'total_amount')
def _compute_amount_paid(self):
for expense in self:
total_paid = 0.0
if expense.account_move_id:
move = expense.account_move_id
if move.payment_state in ('paid', 'in_payment'):
total_paid = expense.total_amount
expense.amount_paid = total_paid
@api.depends('account_move_id.state', 'receipt_received', 'realization_ids.state', 'approval_state')
def _compute_state(self):
# Store original states to detect transition to 'paid'
original_states = {expense.id: expense.state for expense in self}
super()._compute_state()
for expense in self:
# Check for Company Account expenses
if expense.payment_mode == 'company_account':
# If Odoo thought it was 'paid' (fully or partially paid/in_payment),
# we may need to hold it at 'wait_post' until realization is complete.
if expense.state == 'paid':
realizations = expense.realization_ids
has_posted_realization = realizations and all(r.state == 'posted' for r in realizations)
if expense.receipt_status != 'received' or not has_posted_realization:
expense.state = 'wait_post'
if original_states.get(expense.id) != 'paid' and expense.state == 'paid':
# Transitioned to 'Paid'
today = fields.Date.today()
if not expense.receipt_due_date:
due_days = expense.product_id.receipt_due_days or 0
expense.receipt_due_date = today + timedelta(days=due_days)
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
for vals in vals_list: for vals in vals_list:
@ -119,8 +185,69 @@ class HrExpense(models.Model):
res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2) res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2)
return res return res
def action_submit_expenses(self): def action_submit(self):
for expense in self: for expense in self:
if expense.payment_mode == 'own_account' and expense.nb_attachment == 0: 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).")) raise ValidationError(_("You must attach at least one receipt for reimbursement expenses (Paid By: Employee)."))
return super().action_submit_expenses() return super().action_submit()
def _do_refuse(self, reason):
""" Bypass the standard Odoo lock: 'You cannot cancel an expense linked to a journal entry'. """
self._do_reverse_moves()
# Handle realizations on refusal as well
for expense in self:
realizations = expense.realization_ids
if realizations.filtered(lambda r: r.state == 'posted'):
raise UserError(_("You cannot refuse this expense because it has Posted Realizations. Revert them first."))
realizations.write({'state': 'draft'})
return super()._do_refuse(reason)
def _do_reverse_moves(self):
self = self.with_context(clean_context(self.env.context))
for expense in self:
if expense.account_move_id:
move = expense.sudo().account_move_id
payment = move.origin_payment_id
if payment:
if payment.state in ('posted', 'draft'):
payment.action_cancel()
if move.state == 'posted':
move._reverse_moves(
default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False}],
cancel=True
)
# After reversal or if it was draft/cancel, we can unlink or at least it won't block _do_refuse
if move.state in ('draft', 'cancel'):
move.unlink()
def action_recompute_state(self):
""" Public wrapper to allow triggering recompute from a button. """
self._compute_state()
def action_reset_expense_sheets(self):
""" Overriding reset to handle realizations. """
for expense in self:
realizations = expense.realization_ids
posted_realizations = realizations.filtered(lambda r: r.state == 'posted')
if posted_realizations:
raise UserError(_("You cannot reset this expense because it has one or more Posted Realizations (%s). Please reverse or cancel the realization journal entries first.") % ", ".join(posted_realizations.mapped('name')))
# Reset draft/confirmed ones back to draft if resetting
realizations.filtered(lambda r: r.state != 'posted').write({'state': 'draft'})
return super().action_reset_expense_sheets()
def action_open_payment_wizard(self):
self.ensure_one()
return {
'name': _('Register Payment'),
'type': 'ir.actions.act_window',
'res_model': 'hr.expense.payment.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_expense_id': self.id,
'default_amount': self.total_amount,
}
}

View File

@ -0,0 +1,79 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class HrExpensePaymentWizard(models.TransientModel):
_name = 'hr.expense.payment.wizard'
_description = 'Expense Payment Wizard'
expense_id = fields.Many2one('hr.expense', string='Expense', required=True)
amount = fields.Monetary(string='Payment Amount', required=True, readonly=True)
currency_id = fields.Many2one('res.currency', related='expense_id.currency_id')
company_id = fields.Many2one('res.company', related='expense_id.company_id')
partner_id = fields.Many2one(
'res.partner',
string='Vendor',
required=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"
)
journal_id = fields.Many2one(
'account.journal',
string='Payment Journal',
required=True,
domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]"
)
payment_method_line_id = fields.Many2one(
'account.payment.method.line',
string='Payment Method',
required=True,
domain="[('journal_id', '=', journal_id), ('payment_type', '=', 'outbound')]"
)
payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True)
@api.onchange('journal_id')
def _onchange_journal_id(self):
if self.journal_id:
available_payment_methods = self.journal_id.outbound_payment_method_line_ids
if available_payment_methods:
self.payment_method_line_id = available_payment_methods[0].id
else:
self.payment_method_line_id = False
self.payment_method_line_id = False
def action_create_payment(self):
self.ensure_one()
# Find 115101 account (Uang Muka Operasional)
uang_muka_account = self.env['account.account'].search([
('code', '=', '115101'),
('company_id', '=', self.company_id.id)
], limit=1)
if not uang_muka_account:
raise UserError(_("Account 115101 Uang Muka Operasional not found for this company!"))
payment_vals = {
'date': self.payment_date,
'amount': self.amount,
'payment_type': 'outbound',
'partner_type': 'supplier',
'partner_id': self.partner_id.id,
'journal_id': self.journal_id.id,
'currency_id': self.currency_id.id,
'payment_method_line_id': self.payment_method_line_id.id,
'ref': self.expense_id.name,
'destination_account_id': uang_muka_account.id,
'expense_ids': [Command.set(self.expense_id.ids)],
}
payment = self.env['account.payment'].create(payment_vals)
payment.action_post()
# In Odoo 19, the payment move is linked to the expense via expense_ids on the payment.
# Standard Odoo should handle the state update if we followed the right hooks,
# but since we are doing a custom flow, let's trigger it.
# Update expense status
self.expense_id.action_recompute_state()
return {'type': 'ir.actions.act_window_close'}

View File

@ -88,9 +88,8 @@ class HrExpenseRealization(models.Model):
self.state = 'confirmed' self.state = 'confirmed'
if self.expense_id: if self.expense_id:
self.expense_id.write({'receipt_received': True}) self.expense_id.write({'receipt_received': True})
# Explicitly trigger recompute of the sheet status # Explicitly trigger recompute of the status
if self.expense_id.sheet_id: self.expense_id._compute_receipt_status()
self.expense_id.sheet_id._compute_receipt_status()
def action_apply_default_account(self): def action_apply_default_account(self):
self.ensure_one() self.ensure_one()
@ -109,7 +108,7 @@ class HrExpenseRealization(models.Model):
raise UserError(_("Please specify the Journal before posting.")) raise UserError(_("Please specify the Journal before posting."))
# 1. Determine accounts # 1. Determine accounts
# Advance Account (Product's expense account, e.g. 118101) # Advance Account (Product's expense account, e.g. 115101)
product = self.expense_id.product_id.with_company(self.company_id) product = self.expense_id.product_id.with_company(self.company_id)
advance_account = product.property_account_expense_company_id or product.property_account_expense_id advance_account = product.property_account_expense_company_id or product.property_account_expense_id
if not advance_account: if not advance_account:
@ -269,7 +268,7 @@ class HrExpenseRealization(models.Model):
('employee_id', '=', employee_id), ('employee_id', '=', employee_id),
('payment_mode', '=', 'company_account'), ('payment_mode', '=', 'company_account'),
('receipt_received', '=', False), ('receipt_received', '=', False),
('state', 'in', ['approved', 'done']) ('state', 'in', ['approved', 'posted', 'paid'])
], ],
fields=['id', 'name', 'date', 'total_amount', 'currency_id'] fields=['id', 'name', 'date', 'total_amount', 'currency_id']
) )

View File

@ -1,266 +0,0 @@
from odoo import api, fields, models, _, Command
from datetime import timedelta
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import clean_context
class HrExpenseSheet(models.Model):
_inherit = 'hr.expense.sheet'
state = fields.Selection(selection_add=[
('wait_post', 'Wait Post')
], ondelete={'wait_post': 'set default'})
@api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual', 'account_move_ids.state', 'expense_line_ids.receipt_received', 'expense_line_ids.realization_ids.state')
def _compute_state(self):
# Store original states to detect transition to 'done'
original_states = {sheet.id: sheet.state for sheet in self}
super()._compute_state()
for sheet in self:
# FIX: If we have moves but they are ALL canceled/draft, Odoo super() incorrectly sets state='post' or 'done'.
# We must force it back to approval_state (Approved) or draft.
active_moves = sheet.account_move_ids.filtered(lambda m: m.state == 'posted')
if not active_moves:
if sheet.state in ('post', 'done', 'wait_post'):
sheet.state = sheet.approval_state or 'draft'
# Check for Company Account expenses
company_paid = sheet.expense_line_ids.filtered(lambda e: e.payment_mode == 'company_account')
if company_paid:
# If Odoo thought it was 'done' (fully or partially paid/in_payment),
# we may need to hold it at 'wait_post' until realization is complete.
if sheet.state == 'done':
realizations = company_paid.mapped('realization_ids')
has_posted_realization = realizations and all(r.state == 'posted' for r in realizations)
# Also consider payment state: if it's NOT paid or in_payment, it should definitely stay in the state super() set (e.g. 'posted')
# Standard Odoo sets state='done' when payment_state is 'paid' or 'in_payment'.
if sheet.payment_state in ('paid', 'in_payment'):
if sheet.receipt_status != 'received' or not has_posted_realization:
sheet.state = 'wait_post'
else:
# If not paid, it should drop back
sheet.state = sheet.approval_state or 'draft'
if original_states.get(sheet.id) != 'done' and sheet.state == 'done':
# Transitioned to 'Paid'
today = fields.Date.today()
for expense in sheet.expense_line_ids:
if not expense.receipt_due_date:
due_days = expense.product_id.receipt_due_days or 0
expense.receipt_due_date = today + timedelta(days=due_days)
receipt_status = fields.Selection([
('pending', 'Pending Receipts'),
('received', 'Receipts Received'),
('none', 'No Receipt Required')
], string='Receipt Status', compute='_compute_receipt_status', store=True, tracking=True)
realization_total_amount = fields.Monetary(
string='Realization Total',
compute='_compute_realization_total_amount',
currency_field='currency_id',
store=True
)
expense_sequences = fields.Char(
string='Expense Sequences',
compute='_compute_expense_sequences',
store=True,
help="Concatenated sequences of all expenses in this report."
)
amount_paid = fields.Monetary(
string='Payment Total',
compute='_compute_amount_paid',
currency_field='currency_id',
store=True,
help="Total amount paid by the finance team (Total - Residual)."
)
@api.depends('account_move_ids.line_ids.matched_debit_ids', 'account_move_ids.line_ids.matched_credit_ids', 'total_amount', 'amount_residual')
def _compute_amount_paid(self):
for sheet in self:
total_paid = 0.0
seen_statement_line_ids = set()
seen_payment_ids = set()
seen_move_line_ids = set()
# Find all relevant lines of the sheet's moves (Payable or Clearing accounts)
reconcilable_lines = sheet.account_move_ids.line_ids.filtered(
lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable') or
l.account_id.reconcile
)
for line in reconcilable_lines:
# Get the counterpart lines from partial reconciliations
partials = line.matched_debit_ids | line.matched_credit_ids
for partial in partials:
counterpart = partial.debit_move_id if partial.credit_move_id == line else partial.credit_move_id
# If the counterpart is from a Bank/Cash journal, it's a "Payment"
if counterpart.journal_id.type in ('bank', 'cash'):
st_line = counterpart.move_id.statement_line_id
payment = counterpart.payment_id
if st_line:
if st_line.id not in seen_statement_line_ids:
total_paid += abs(st_line.amount)
seen_statement_line_ids.add(st_line.id)
elif payment:
if payment.id not in seen_payment_ids:
total_paid += payment.amount
seen_payment_ids.add(payment.id)
else:
# Fallback to the specific move line's balance (absolute)
if counterpart.id not in seen_move_line_ids:
total_paid += abs(counterpart.balance)
seen_move_line_ids.add(counterpart.id)
# If no bank transactions found but report is clearly paid/partially paid,
# fall back to standard calculation for non-bank flows (e.g. manual journal reconciliation)
if not total_paid and sheet.total_amount != sheet.amount_residual:
total_paid = sheet.total_amount - sheet.amount_residual
sheet.amount_paid = total_paid
@api.depends('expense_line_ids.realization_total_amount')
def _compute_realization_total_amount(self):
for sheet in self:
sheet.realization_total_amount = sum(sheet.expense_line_ids.mapped('realization_total_amount'))
@api.depends('expense_line_ids.sequence_name')
def _compute_expense_sequences(self):
for sheet in self:
sequences = sheet.expense_line_ids.mapped('sequence_name')
# Filter out false values and joins them
sheet.expense_sequences = ", ".join(filter(None, sequences))
@api.depends('expense_line_ids.receipt_received', 'expense_line_ids.payment_mode')
def _compute_receipt_status(self):
for sheet in self:
company_paid_expenses = sheet.expense_line_ids.filtered(lambda e: e.payment_mode == 'company_account')
if not company_paid_expenses:
sheet.receipt_status = 'none'
elif all(e.receipt_received for e in company_paid_expenses):
sheet.receipt_status = 'received'
else:
sheet.receipt_status = 'pending'
@api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual', 'account_move_ids.state', 'account_move_ids.payment_id.is_matched')
def _compute_from_account_move_ids(self):
"""
Overriding to fix the 'IN PAYMENT' ribbon issue.
Standard Odoo assumes 'paid' if any move exists for company_account.
We check if the moves are actually in 'posted' state.
"""
for sheet in self:
if sheet.payment_mode == 'company_account':
if sheet.account_move_ids:
# Filter for moves that are NOT canceled
active_moves = sheet.account_move_ids.filtered(lambda m: m.state == 'posted')
if active_moves:
# If there are active moves that are not reversed
moves = active_moves - active_moves.filtered('reversal_move_id')
if moves:
payments = moves.mapped('payment_id')
unmatched_payments = payments.filtered(lambda p: not p.is_matched)
if unmatched_payments:
sheet.payment_state = 'in_payment'
else:
sheet.payment_state = 'paid'
sheet.amount_residual = 0.
else:
sheet.payment_state = 'reversed'
sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
else:
# Moves exist but none are 'posted' (e.g. they are all 'cancel' or 'draft')
sheet.payment_state = 'not_paid'
sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
else:
sheet.payment_state = 'not_paid'
sheet.amount_residual = 0.0
else:
# Standard Odoo logic for own_account
if sheet.account_move_ids:
sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
sheet.payment_state = sheet.account_move_ids[:1].payment_state
else:
sheet.amount_residual = 0.0
sheet.payment_state = 'not_paid'
def _do_refuse(self, reason):
"""
Bypass the standard Odoo lock: 'You cannot cancel an expense sheet linked to a journal entry'.
We allow it but we'll try to cancel the moves first.
"""
self._do_reverse_moves()
# Explicitly call the original _do_refuse but WITHOUT the check,
# but since we already reversed/deleted moves, the original check won't trigger.
return super()._do_refuse(reason)
def _do_reverse_moves(self):
"""
Overriding to handle account.payment explicitly.
Odoo's _do_reverse_moves calls _reverse_moves, which fails for payments.
"""
self = self.with_context(clean_context(self.env.context))
moves = self.account_move_ids
if moves:
for sheet in self:
# Handle payments linked to this sheet
payments = sheet.account_move_ids.mapped('payment_id')
if payments:
# Cancel the payments directly
for payment in payments:
if payment.state == 'posted':
payment.action_cancel()
elif payment.state == 'draft':
payment.action_cancel()
# Standard reversal for non-payment moves (if any)
non_payment_moves = sheet.account_move_ids.filtered(lambda m: not m.payment_id)
if non_payment_moves:
non_payment_moves._reverse_moves(
default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False} for move in non_payment_moves],
cancel=True
)
# Unlink draft/canceled moves (including payment moves that are now draft/cancel)
sheet.account_move_ids.filtered(lambda m: m.state in ('draft', 'cancel')).unlink()
def action_reset_expense_sheets(self):
"""
Overriding reset to handle realizations.
If a realization is posted, we should probably warn or at least prevent
resetting if we want strict audit. For now, we'll allow it but
cancel any draft/confirmed realizations.
"""
for sheet in self:
realizations = sheet.expense_line_ids.mapped('realization_ids')
posted_realizations = realizations.filtered(lambda r: r.state == 'posted')
if posted_realizations:
raise UserError(_("You cannot reset this report because it has one or more Posted Realizations (%s). Please reverse or cancel the realization journal entries first.") % ", ".join(posted_realizations.mapped('name')))
# Reset draft/confirmed ones back to draft if resetting the sheet
realizations.filtered(lambda r: r.state != 'posted').write({'state': 'draft'})
return super().action_reset_expense_sheets()
def action_refuse_expense_sheets(self):
""" Handle realizations on refusal as well. """
for sheet in self:
realizations = sheet.expense_line_ids.mapped('realization_ids')
if realizations.filtered(lambda r: r.state == 'posted'):
raise UserError(_("You cannot refuse this report because it has Posted Realizations. Revert them first."))
realizations.write({'state': 'draft'})
return super().action_refuse_expense_sheets()
def action_recompute_state(self):
""" Public wrapper to allow triggering recompute from a button. """
self._compute_state()
self._compute_from_account_move_ids()

View File

@ -7,14 +7,14 @@ class ProductTemplate(models.Model):
'account.account', 'account.account',
string="Expense Account (Employee)", string="Expense Account (Employee)",
company_dependent=True, company_dependent=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('deprecated', '=', False)]", domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))]",
help="Account used for expenses paid by the employee (to be reimbursed)." help="Account used for expenses paid by the employee (to be reimbursed)."
) )
property_account_expense_company_id = fields.Many2one( property_account_expense_company_id = fields.Many2one(
'account.account', 'account.account',
string="Expense Account (Company)", string="Expense Account (Company)",
company_dependent=True, company_dependent=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('deprecated', '=', False)]", domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))]",
help="Account used for expenses paid by the company." help="Account used for expenses paid by the company."
) )
receipt_due_days = fields.Integer( receipt_due_days = fields.Integer(

View File

@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_expense_realization_user,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_user,1,1,1,0 access_hr_expense_realization_user,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_user,1,1,1,0
access_hr_expense_realization_manager,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_manager,1,1,1,1 access_hr_expense_realization_manager,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_manager,1,1,1,1
access_hr_expense_realization_line_user,hr.expense.realization.line,model_hr_expense_realization_line,hr_expense.group_hr_expense_user,1,1,1,1 access_hr_expense_realization_line_user,hr.expense.realization.line,model_hr_expense_realization_line,hr_expense.group_hr_expense_user,1,1,1,1
access_hr_expense_payment_wizard,hr.expense.payment.wizard,model_hr_expense_payment_wizard,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_hr_expense_realization_user hr.expense.realization model_hr_expense_realization hr_expense.group_hr_expense_user 1 1 1 0
3 access_hr_expense_realization_manager hr.expense.realization model_hr_expense_realization hr_expense.group_hr_expense_manager 1 1 1 1
4 access_hr_expense_realization_line_user hr.expense.realization.line model_hr_expense_realization_line hr_expense.group_hr_expense_user 1 1 1 1
5 access_hr_expense_payment_wizard hr.expense.payment.wizard model_hr_expense_payment_wizard base.group_user 1 1 1 1

View File

@ -20,6 +20,7 @@ class ExpenseKioskApp extends Component {
selectedCategory: null, selectedCategory: null,
enteredPin: "", enteredPin: "",
pendingRealizations: [], pendingRealizations: [],
submittedExpenses: [],
selectedAction: null, selectedAction: null,
selectedPaymentMode: null, selectedPaymentMode: null,
selectedExpense: null, selectedExpense: null,
@ -88,6 +89,7 @@ class ExpenseKioskApp extends Component {
if (result.status === 'ok') { if (result.status === 'ok') {
await this.loadPendingRealizations(); await this.loadPendingRealizations();
await this.loadSubmittedExpenses();
this.state.screen = 'action_selection'; this.state.screen = 'action_selection';
} else { } else {
this.notification.add(result.message, { type: 'danger' }); this.notification.add(result.message, { type: 'danger' });
@ -102,6 +104,13 @@ class ExpenseKioskApp extends Component {
this.state.pendingRealizations = data; this.state.pendingRealizations = data;
} }
async loadSubmittedExpenses() {
const data = await this.rpc(`/hr_expense/kiosk_get_submitted/${this.token}`, {
employee_id: this.state.selectedEmployee.id,
});
this.state.submittedExpenses = data;
}
// Action Selection // Action Selection
selectAction(action) { selectAction(action) {
this.state.selectedAction = action; this.state.selectedAction = action;
@ -237,6 +246,8 @@ class ExpenseKioskApp extends Component {
if (result.status === 'ok') { if (result.status === 'ok') {
this.state.screen = 'success'; this.state.screen = 'success';
await this.loadPendingRealizations();
await this.loadSubmittedExpenses();
setTimeout(() => { setTimeout(() => {
this.backToSelection(); this.backToSelection();
}, 3000); }, 3000);

View File

@ -79,6 +79,45 @@
<p class="text-muted small">Submit a new reimbursement request</p> <p class="text-muted small">Submit a new reimbursement request</p>
</div> </div>
</div> </div>
<!-- Recent Submissions List -->
<div class="mt-5 w-100 px-3" t-if="state.submittedExpenses.length > 0">
<h4 class="mb-3 text-start"><i class="fa fa-history me-2"></i>Your Recent Submissions</h4>
<div class="table-responsive rounded shadow-sm bg-white">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Reference / Description</th>
<th>Date</th>
<th class="text-end">Total</th>
<th class="text-center">Status</th>
<th class="text-center pe-3">Payment</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.submittedExpenses" t-as="exp" t-key="exp.id">
<tr>
<td class="ps-3">
<div class="fw-bold text-primary" t-esc="exp.sequences"/>
<div class="small text-muted" t-esc="exp.name"/>
</td>
<td t-esc="exp.date"/>
<td class="text-end fw-bold" t-esc="exp.total_amount"/>
<td class="text-center">
<span t-attf-class="badge rounded-pill #{['approved', 'paid', 'posted'].includes(exp.state_raw) ? 'bg-success' : (['submitted', 'reported'].includes(exp.state_raw) ? 'bg-info' : (exp.state_raw === 'refused' ? 'bg-danger' : 'bg-warning'))}" t-esc="exp.state"/>
</td>
<td class="text-center pe-3">
<span t-attf-class="badge rounded-pill #{exp.payment_state_raw === 'paid' ? 'bg-success' : (exp.payment_state_raw === 'in_payment' ? 'bg-info' : (exp.payment_state_raw === 'not_paid' ? 'bg-secondary' : 'bg-warning'))}" t-esc="exp.payment_status"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<div class="text-muted small mt-2 text-start">
<i class="fa fa-info-circle me-1"></i> Showing your last 15 submissions.
</div>
</div>
</div> </div>
<!-- PAYMENT MODE SELECTION --> <!-- PAYMENT MODE SELECTION -->

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Override account move form to hide reverse button for expense payments -->
<record id="view_move_form_inherit_expense_payment" model="ir.ui.view">
<field name="name">account.move.form.inherit.expense.payment</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='%(account.action_view_account_move_reversal)d']" position="attributes">
<attribute name="invisible">move_type != 'entry' or state != 'posted' or payment_state == 'reversed' or is_expense_payment</attribute>
</xpath>
<xpath expr="//sheet" position="inside">
<field name="is_expense_payment" invisible="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_expense_payment_wizard_view_form" model="ir.ui.view">
<field name="name">hr.expense.payment.wizard.view.form</field>
<field name="model">hr.expense.payment.wizard</field>
<field name="arch" type="xml">
<form string="Register Payment">
<sheet>
<group>
<group>
<field name="expense_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
<field name="partner_id" widget="res_partner_many2one" context="{'res_partner_search_mode': 'supplier'}"/>
<field name="journal_id" widget="selection"/>
<field name="payment_method_line_id" widget="selection"/>
</group>
<group>
<field name="payment_date"/>
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
</group>
</group>
</sheet>
<footer>
<button name="action_create_payment" string="Create Payment" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="action_hr_expense_payment_wizard" model="ir.actions.act_window">
<field name="name">Register Payment</field>
<field name="res_model">hr.expense.payment.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@ -5,14 +5,14 @@
<field name="name">hr.expense.realization.view.tree</field> <field name="name">hr.expense.realization.view.tree</field>
<field name="model">hr.expense.realization</field> <field name="model">hr.expense.realization</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<tree string="Expense Realization"> <list string="Expense Realization">
<field name="name"/> <field name="name"/>
<field name="date"/> <field name="date"/>
<field name="employee_id"/> <field name="employee_id"/>
<field name="expense_id"/> <field name="expense_id"/>
<field name="total_amount" sum="Total Amount"/> <field name="total_amount" sum="Total Amount"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'confirmed'" decoration-success="state == 'posted'"/> <field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'confirmed'" decoration-success="state == 'posted'"/>
</tree> </list>
</field> </field>
</record> </record>
@ -90,14 +90,14 @@
<notebook> <notebook>
<page string="Receipts" name="receipt_lines"> <page string="Receipts" name="receipt_lines">
<field name="line_ids" readonly="state == 'posted'"> <field name="line_ids" readonly="state == 'posted'">
<tree editable="bottom"> <list editable="bottom">
<field name="description"/> <field name="description"/>
<field name="amount" sum="Total"/> <field name="amount" sum="Total"/>
<field name="attachment_id" filename="attachment_name" widget="binary"/> <field name="attachment_id" filename="attachment_name" widget="binary"/>
<field name="attachment_name" column_invisible="1"/> <field name="attachment_name" column_invisible="1"/>
<field name="counterpart_account_id" groups="account.group_account_invoice" required="parent.state == 'confirmed'"/> <field name="counterpart_account_id" groups="account.group_account_invoice" required="parent.state == 'confirmed'"/>
<field name="move_id" groups="account.group_account_invoice" widget="many2one_clickable" readonly="1" invisible="not move_id"/> <field name="move_id" groups="account.group_account_invoice" widget="many2one_clickable" readonly="1" invisible="not move_id"/>
</tree> </list>
</field> </field>
</page> </page>
<page string="Notes" name="notes"> <page string="Notes" name="notes">
@ -115,8 +115,8 @@
</record> </record>
<!-- Realization Search View --> <!-- Realization Search View -->
<record id="hr_expense_realization_view_search" model="ir.ui.view"> <record id="hr_expense_realization_search_view" model="ir.ui.view">
<field name="name">hr.expense.realization.view.search</field> <field name="name">hr.expense.realization.search</field>
<field name="model">hr.expense.realization</field> <field name="model">hr.expense.realization</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<search string="Search Realization"> <search string="Search Realization">
@ -126,8 +126,10 @@
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/> <filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/> <filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Posted" name="posted" domain="[('state', '=', 'posted')]"/> <filter string="Posted" name="posted" domain="[('state', '=', 'posted')]"/>
<group expand="0" string="Group By"> <separator/>
<group name="group_by">
<filter string="Employee" name="group_employee" context="{'group_by': 'employee_id'}"/> <filter string="Employee" name="group_employee" context="{'group_by': 'employee_id'}"/>
<filter string="Expense" name="group_expense_id" context="{'group_by': 'expense_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/> <filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/> <filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
</group> </group>
@ -139,8 +141,8 @@
<record id="action_hr_expense_realization" model="ir.actions.act_window"> <record id="action_hr_expense_realization" model="ir.actions.act_window">
<field name="name">Realization Report</field> <field name="name">Realization Report</field>
<field name="res_model">hr.expense.realization</field> <field name="res_model">hr.expense.realization</field>
<field name="view_mode">tree,form</field> <field name="view_mode">list,form</field>
<field name="search_view_id" ref="hr_expense_realization_view_search"/> <field name="search_view_id" ref="hr_expense_realization_search_view"/>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face"> <p class="o_view_nocontent_smiling_face">
Create a new Realization Report Create a new Realization Report
@ -152,23 +154,9 @@
</record> </record>
<!-- Menu Structure Adjustment --> <!-- Menu Structure Adjustment -->
<!-- 1. Create a new parent menu "Expense Reports" -->
<menuitem id="menu_expense_reports_parent"
name="Expense Reports"
parent="hr_expense.menu_hr_expense_root"
sequence="2"/>
<!-- 2. Move the standard "Expense Reports" menu under it and rename it to "Expenses" -->
<menuitem id="hr_expense.menu_hr_expense_report"
name="Expenses"
parent="menu_expense_reports_parent"
sequence="1"/>
<!-- 3. Add the "Realization Report" submenu under it -->
<menuitem id="menu_hr_expense_realization" <menuitem id="menu_hr_expense_realization"
name="Realization Report" name="Realization Report"
parent="menu_expense_reports_parent" parent="hr_expense.menu_hr_expense_root"
action="action_hr_expense_realization" action="action_hr_expense_realization"
sequence="2"/> sequence="3"/>
</odoo> </odoo>

View File

@ -7,31 +7,29 @@
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/> <field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
<field name="priority">1000</field> <field name="priority">1000</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Remove all standard Submit Buttons --> <!-- Update header: Add wait_post to statusbar -->
<xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/> <xpath expr="//field[@name='state'][@statusbar_visible]" position="attributes">
<xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/> <attribute name="statusbar_visible">draft,submitted,approved,posted,wait_post,paid</attribute>
<!-- Remove all standard Attach Receipt widgets -->
<xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
<xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
<!-- Remove Split Expense to re-add with correct logic -->
<xpath expr="//header//button[@name='action_split_wizard']" position="replace"/>
<!-- Re-add ONLY one Create Report button as primary -->
<xpath expr="//header" position="inside">
<button name="action_submit_expenses"
string="Create Report"
type="object"
class="oe_highlight o_expense_submit"
invisible="sheet_id"
data-hotkey="v"/>
<widget name="attach_document"
string="Attach Receipt"
action="attach_document"
highlight="nb_attachment &lt; 1 and payment_mode == 'own_account'"
invisible="sheet_id"/>
</xpath> </xpath>
<xpath expr="//header//button[@name='action_post']" position="attributes">
<attribute name="invisible">state != 'approved' or payment_mode == 'company_account'</attribute>
</xpath>
<xpath expr="//header//button[@name='action_post']" position="after">
<button name="action_open_payment_wizard"
string="Register Payment"
type="object"
data-hotkey="y"
class="oe_highlight o_expense_sheet_post"
invisible="state != 'approved' or payment_mode != 'company_account'"
groups="account.group_account_invoice"/>
</xpath>
<!-- Attach Receipt widget -->
<xpath expr="//header//widget[@name='attach_document']" position="attributes">
<attribute name="highlight">nb_attachment == 0 and payment_mode == 'own_account'</attribute>
</xpath>
<xpath expr="//div[hasclass('oe_title')]" position="before"> <xpath expr="//div[hasclass('oe_title')]" position="before">
<div class="oe_button_box" name="button_box"> <div class="oe_button_box" name="button_box">
<button name="action_view_realizations" <button name="action_view_realizations"
@ -47,7 +45,7 @@
type="object" type="object"
class="oe_stat_button" class="oe_stat_button"
icon="fa-plus-square-o" icon="fa-plus-square-o"
invisible="payment_mode != 'company_account' or state not in ['done', 'reported'] or realization_count != 0"/> invisible="payment_mode != 'company_account' or state not in ['paid', 'posted', 'wait_post'] or realization_count != 0"/>
</div> </div>
</xpath> </xpath>
<xpath expr="//sheet" position="before"> <xpath expr="//sheet" position="before">
@ -67,6 +65,11 @@
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/> <field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/> <field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/>
<field name="receipt_overdue" invisible="1"/> <field name="receipt_overdue" invisible="1"/>
<field name="receipt_status" widget="badge"
decoration-info="receipt_status == 'pending'"
decoration-success="receipt_status == 'received'"
decoration-muted="receipt_status == 'none'"/>
<field name="amount_paid" invisible="payment_mode != 'own_account'"/>
</xpath> </xpath>
</field> </field>
</record> </record>
@ -80,10 +83,19 @@
<xpath expr="//field[@name='name']" position="before"> <xpath expr="//field[@name='name']" position="before">
<field name="sequence_name" optional="show"/> <field name="sequence_name" optional="show"/>
</xpath> </xpath>
<xpath expr="//field[@name='state']" position="attributes">
<attribute name="decoration-warning">state == 'wait_post'</attribute>
</xpath>
<xpath expr="//field[@name='total_amount']" position="after"> <xpath expr="//field[@name='total_amount']" position="after">
<field name="amount_paid" optional="show" sum="Total Payment" string="Payment Total"/>
<field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/> <field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/>
</xpath> </xpath>
<xpath expr="//field[@name='state']" position="after"> <xpath expr="//field[@name='state']" position="after">
<field name="receipt_status" widget="badge"
decoration-info="receipt_status == 'pending'"
decoration-success="receipt_status == 'received'"
decoration-muted="receipt_status == 'none'"
optional="show"/>
<field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/> <field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/>
<field name="receipt_received" optional="show" widget="boolean_toggle"/> <field name="receipt_received" optional="show" widget="boolean_toggle"/>
<field name="receipt_overdue" column_invisible="True"/> <field name="receipt_overdue" column_invisible="True"/>
@ -97,14 +109,16 @@
<field name="model">hr.expense</field> <field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.hr_expense_view_search"/> <field name="inherit_id" ref="hr_expense.hr_expense_view_search"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//filter[@name='no_report']" position="after"> <xpath expr="//filter[@name='my_open_expenses']" position="after">
<separator/> <separator/>
<filter string="Wait Post" name="filter_wait_post" domain="[('state', '=', 'wait_post')]"/>
<filter string="Overdue Receipts" name="filter_receipt_overdue" domain="[('receipt_overdue', '=', True), ('receipt_received', '=', False)]"/> <filter string="Overdue Receipts" name="filter_receipt_overdue" domain="[('receipt_overdue', '=', True), ('receipt_received', '=', False)]"/>
<filter string="Receipt Received" name="filter_receipt_received" domain="[('receipt_received', '=', True)]"/> <filter string="Receipt Received" name="filter_receipt_received" domain="[('receipt_received', '=', True)]"/>
<filter string="Receipt Missing" name="filter_receipt_missing" domain="[('receipt_received', '=', False), ('receipt_due_date', '!=', False)]"/> <filter string="Receipt Missing" name="filter_receipt_missing" domain="[('receipt_received', '=', False), ('receipt_due_date', '!=', False)]"/>
<filter string="Pending Receipts" name="filter_receipt_pending" domain="[('receipt_status', '=', 'pending')]"/>
</xpath> </xpath>
<xpath expr="//group" position="inside"> <xpath expr="//group" position="inside">
<filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_received'}"/> <filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_status'}"/>
<filter string="Receipt Due Date" name="group_receipt_due" context="{'group_by': 'receipt_due_date'}"/> <filter string="Receipt Due Date" name="group_receipt_due" context="{'group_by': 'receipt_due_date'}"/>
</xpath> </xpath>
</field> </field>
@ -113,7 +127,7 @@
<!-- Report Action: Overdue Receipts --> <!-- Report Action: Overdue Receipts -->
<record id="action_hr_expense_overdue_receipts" model="ir.actions.act_window"> <record id="action_hr_expense_overdue_receipts" model="ir.actions.act_window">
<field name="name">Overdue Receipts</field> <field name="name">Overdue Receipts</field>
<field name="res_model">hr.expense</field> <field name="res_model" >hr.expense</field>
<field name="view_mode">list,pivot,form</field> <field name="view_mode">list,pivot,form</field>
<field name="domain">[('receipt_overdue', '=', True), ('receipt_received', '=', False)]</field> <field name="domain">[('receipt_overdue', '=', True), ('receipt_received', '=', False)]</field>
<field name="context">{'search_default_group_receipt_due': 1}</field> <field name="context">{'search_default_group_receipt_due': 1}</field>
@ -131,107 +145,14 @@
parent="hr_expense.menu_hr_expense_reports" parent="hr_expense.menu_hr_expense_reports"
action="action_hr_expense_overdue_receipts" action="action_hr_expense_overdue_receipts"
sequence="20"/> sequence="20"/>
<record id="view_hr_expense_sheet_form_inherit_realization" model="ir.ui.view">
<field name="name">hr.expense.sheet.form.inherit.realization</field>
<field name="model">hr.expense.sheet</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/>
<field name="arch" type="xml">
<!-- Update standard Reset to Draft and Cancel buttons to include 'wait_post' -->
<xpath expr="//header//button[@name='action_reset_expense_sheets']" position="attributes">
<attribute name="invisible">state not in ['approve', 'post', 'done', 'cancel', 'wait_post']</attribute>
</xpath>
<xpath expr="//header//button[@name='action_refuse_expense_sheets']" position="attributes">
<attribute name="invisible">state not in ['submit', 'approve', 'post', 'wait_post']</attribute>
</xpath>
<xpath expr="//field[@name='state']" position="attributes">
<attribute name="statusbar_visible">draft,submit,approve,post,wait_post,done</attribute>
</xpath>
<xpath expr="//header" position="inside">
<button name="action_recompute_state"
string="Recompute Status"
type="object"
groups="base.group_erp_manager"
class="btn btn-secondary"
help="Force refresh the report status based on current payments and receipts."/>
</xpath>
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="before">
<field name="sequence_name" column_invisible="not parent.id"/>
</xpath>
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='total_amount']" position="after">
<field name="realization_total_amount" optional="show" invisible="payment_mode != 'company_account'"/>
</xpath>
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="after">
<field name="payment_mode" column_invisible="True"/>
<field name="realization_count" column_invisible="True"/>
<!-- Button to Add Receipt if none exists -->
<button name="action_create_realization"
string="Add Receipt"
type="object"
icon="fa-plus"
class="text-primary"
title="Add receipts for this expense"
invisible="payment_mode != 'company_account' or parent.state not in ['post', 'wait_post', 'done'] or realization_count != 0"/>
<!-- Button to View Receipts if already exist -->
<button name="action_view_realizations"
string="View Receipts"
type="object"
icon="fa-external-link"
class="text-success"
title="View linked receipts"
invisible="payment_mode != 'company_account' or parent.state not in ['post', 'wait_post', 'done'] or realization_count == 0"/>
</xpath>
</field>
</record>
<!-- Inherit Expense Report Tree View -->
<record id="view_hr_expense_sheet_tree_inherit_receipt" model="ir.ui.view">
<field name="name">hr.expense.sheet.tree.receipt</field>
<field name="model">hr.expense.sheet</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='state']" position="attributes">
<attribute name="decoration-warning">state == 'wait_post'</attribute>
</xpath>
<xpath expr="//field[@name='state']" position="before">
<field name="expense_sequences" optional="show"/>
<field name="amount_paid" optional="show" sum="Total Payment" string="Payment Total"/>
<field name="realization_total_amount" optional="show" sum="Total Realization"/>
<field name="receipt_status" widget="badge"
decoration-info="receipt_status == 'pending'"
decoration-success="receipt_status == 'received'"
decoration-muted="receipt_status == 'none'"
optional="show"/>
</xpath>
</field>
</record>
<!-- Inherit Expense Report Search View -->
<record id="hr_expense_sheet_view_search_inherit_receipt" model="ir.ui.view">
<field name="name">hr.expense.sheet.search.receipt</field>
<field name="model">hr.expense.sheet</field>
<field name="inherit_id" ref="hr_expense.hr_expense_sheet_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='my_reports']" position="after">
<separator/>
<filter string="Wait Post" name="filter_wait_post" domain="[('state', '=', 'wait_post')]"/>
<filter string="Pending Receipts" name="filter_receipt_pending" domain="[('receipt_status', '=', 'pending')]"/>
<filter string="Receipts Received" name="filter_receipt_received" domain="[('receipt_status', '=', 'received')]"/>
</xpath>
<xpath expr="//group[@name='group_filters']" position="inside">
<filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_status'}"/>
</xpath>
</field>
</record>
<!-- Update All Reports Action to include Wait Post in SearchPanel -->
<record id="hr_expense.action_hr_expense_sheet_all" model="ir.actions.act_window">
<field name="context">{ 'searchpanel_default_state': ["draft", "submit", "approve", "post", "wait_post", "done"] }</field>
</record>
<!-- Update My Reports Action to include Wait Post in SearchPanel if applicable --> <!-- Update My Reports Action to include Wait Post in SearchPanel if applicable -->
<record id="hr_expense.action_hr_expense_sheet_my_all" model="ir.actions.act_window"> <record id="hr_expense.hr_expense_actions_my_all" model="ir.actions.act_window">
<field name="context">{ 'searchpanel_default_state': ["draft", "submit", "approve", "post", "wait_post", "done"], 'search_default_my_reports': 1, 'search_default_not_refused_reports': 1 }</field> <field name="context">{ 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"], 'search_default_my_open_expenses': 1 }</field>
</record> </record>
<record id="hr_expense.hr_expense_actions_all" model="ir.actions.act_window">
<field name="context">{ 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"] }</field>
</record>
</odoo> </odoo>

View File

@ -23,7 +23,7 @@
<field name="model">product.template</field> <field name="model">product.template</field>
<field name="inherit_id" ref="account.product_template_form_view"/> <field name="inherit_id" ref="account.product_template_form_view"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//group[@name='payables']" position="inside"> <xpath expr="//field[@name='property_account_expense_id']" position="after">
<field name="property_account_expense_employee_id" invisible="not can_be_expensed"/> <field name="property_account_expense_employee_id" invisible="not can_be_expensed"/>
<field name="property_account_expense_company_id" invisible="not can_be_expensed"/> <field name="property_account_expense_company_id" invisible="not can_be_expensed"/>
<field name="receipt_due_days" invisible="not can_be_expensed"/> <field name="receipt_due_days" invisible="not can_be_expensed"/>

View File

@ -6,20 +6,11 @@
<field name="inherit_id" ref="hr_expense.res_config_settings_view_form"/> <field name="inherit_id" ref="hr_expense.res_config_settings_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//block[@name='expenses_setting_container']" position="after"> <xpath expr="//block[@name='expenses_setting_container']" position="after">
<block title="Expense Kiosk" id="expense_kiosk_settings"> <block title="Expense Kiosk" name="expense_kiosk_settings">
<setting title="Separate URL for Expense Kiosk" help="No login required for employees. Submissions still require PIN."> <setting string="Kiosk URL" help="Separate URL for Expense Kiosk. No login required for employees. Submissions still require PIN.">
<div class="content-group"> <field name="expense_kiosk_url" widget="url" readonly="1"/>
<div class="mt16"> <div class="mt-2">
<label for="expense_kiosk_url" string="Kiosk URL" class="col-lg-3 o_light_label"/> <button name="action_regenerate_expense_kiosk_key" type="object" string="Regenerate Token" class="btn btn-link" icon="fa-refresh" confirm="Are you sure you want to regenerate the kiosk token? All existing kiosk links will stop working."/>
<field name="expense_kiosk_url" widget="url" readonly="1" class="oe_inline"/>
</div>
<div class="mt8">
<button name="action_regenerate_expense_kiosk_key"
type="object"
string="Regenerate Token"
class="btn btn-secondary btn-sm"
confirm="Are you sure you want to regenerate the kiosk token? All existing kiosk links will stop working."/>
</div>
</div> </div>
</setting> </setting>
</block> </block>