feat: implement HR expense kiosk interface for employee self-service expense reporting and realization
This commit is contained in:
parent
4128ac60e6
commit
82b36c3ef5
53
README.md
53
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
|
## 🚀 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.
|
|
||||||
|
|
||||||
## Configuration
|
### 1. Account Splitting
|
||||||
1. Go to **Expenses > Configuration > Expense Categories**.
|
- **Dynamic Account Selection**: Automatically routes expenses to different GL accounts based on whether they were paid by the **Employee** (Reimburse) or the **Company** (Kasbon).
|
||||||
2. Open an expense category.
|
- **Configuration**: Set distinct `Expense Account (Employee)` and `Expense Account (Company)` directly on the Expense Category form.
|
||||||
3. In the **Accounting** section, set the specific accounts for Employee and Company modes.
|
|
||||||
|
### 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/<token>`
|
||||||
|
- **Models**: `hr.expense`, `hr.expense.realization`, `hr.expense.realization.line`
|
||||||
|
- **JS Framework**: Odoo 17 OWL (Odoo Web Library)
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
from . import models
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|||||||
@ -11,7 +11,28 @@
|
|||||||
'views/product_views.xml',
|
'views/product_views.xml',
|
||||||
'views/hr_expense_views.xml',
|
'views/hr_expense_views.xml',
|
||||||
'views/hr_expense_realization_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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
|
|||||||
1
controllers/__init__.py
Normal file
1
controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import hr_expense_kiosk_controller
|
||||||
162
controllers/hr_expense_kiosk_controller.py
Normal file
162
controllers/hr_expense_kiosk_controller.py
Normal file
@ -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/<string:token>', 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/<string:token>', 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/<string:token>', 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/<string:token>', 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/<string:token>', 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/<string:token>', 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)}
|
||||||
@ -3,3 +3,5 @@ from . import hr_expense
|
|||||||
from . import hr_expense_sheet
|
from . import hr_expense_sheet
|
||||||
from . import account_move_line
|
from . import account_move_line
|
||||||
from . import hr_expense_realization
|
from . import hr_expense_realization
|
||||||
|
from . import res_company
|
||||||
|
from . import res_config_settings
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError, ValidationError
|
||||||
from odoo.tools import float_round
|
from odoo.tools import float_round
|
||||||
|
|
||||||
class HrExpense(models.Model):
|
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.
|
# 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)
|
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):
|
||||||
|
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()
|
||||||
|
|||||||
@ -120,6 +120,19 @@ class HrExpenseRealization(models.Model):
|
|||||||
'move_id': moves[0].id if moves else False
|
'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):
|
class HrExpenseRealizationLine(models.Model):
|
||||||
_name = 'hr.expense.realization.line'
|
_name = 'hr.expense.realization.line'
|
||||||
_description = 'Expense Realization Line'
|
_description = 'Expense Realization Line'
|
||||||
|
|||||||
28
models/res_company.py
Normal file
28
models/res_company.py
Normal file
@ -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
|
||||||
10
models/res_config_settings.py
Normal file
10
models/res_config_settings.py
Normal file
@ -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()
|
||||||
265
static/src/kiosk/kiosk_app.js
Normal file
265
static/src/kiosk/kiosk_app.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
254
static/src/kiosk/kiosk_app.xml
Normal file
254
static/src/kiosk/kiosk_app.xml
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="hr_expense_account_split.KioskApp">
|
||||||
|
<div class="o_expense_kiosk_container d-flex flex-column h-100 bg-light">
|
||||||
|
<header class="bg-primary text-white p-3 d-flex justify-content-between align-items-center shadow-sm">
|
||||||
|
<h2 class="m-0">Expense Kiosk</h2>
|
||||||
|
<button t-if="state.screen !== 'employee_selection'" class="btn btn-light" t-on-click="backToSelection">
|
||||||
|
<i class="fa fa-home me-2"/>Home
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 p-4 overflow-auto">
|
||||||
|
<!-- SUCCESS SCREEN -->
|
||||||
|
<div t-if="state.screen === 'success'" class="text-center p-5 animate-fade-in">
|
||||||
|
<div class="display-1 text-success mb-3"><i class="fa fa-check-circle"/></div>
|
||||||
|
<h3>Successfully Submitted!</h3>
|
||||||
|
<p class="text-muted">Returning to home in 3 seconds...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EMPLOYEE SELECTION -->
|
||||||
|
<div t-if="state.screen === 'employee_selection'" class="animate-fade-in">
|
||||||
|
<div class="mb-4 d-flex justify-content-center">
|
||||||
|
<div class="input-group w-50 shadow-sm">
|
||||||
|
<span class="input-group-text"><i class="fa fa-search"/></span>
|
||||||
|
<input type="text" class="form-control form-control-lg" placeholder="Search your name..." t-model="state.searchQuery"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cols-2 row-cols-md-4 g-4">
|
||||||
|
<t t-foreach="filteredEmployees" t-as="emp" t-key="emp.id">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 text-center shadow-sm border-0 cursor-pointer hover-shadow" t-on-click="() => this.selectEmployee(emp)">
|
||||||
|
<div class="p-4 bg-white rounded-top">
|
||||||
|
<img t-att-src="emp.avatar_url" class="rounded-circle mb-3 border border-3 border-light" style="width: 100px; height: 100px; object-fit: cover;"/>
|
||||||
|
<h5 class="card-title text-truncate" t-esc="emp.name"/>
|
||||||
|
<small class="text-muted" t-esc="emp.job_name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PIN PAD -->
|
||||||
|
<div t-if="state.screen === 'pin_pad'" class="d-flex flex-column align-items-center animate-fade-in">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<img t-att-src="state.selectedEmployee.avatar_url" class="rounded-circle mb-3" style="width: 120px; height: 120px; object-fit: cover;"/>
|
||||||
|
<h3>Welcome, <t t-esc="state.selectedEmployee.name"/></h3>
|
||||||
|
<p class="text-muted">Enter your PIN to continue</p>
|
||||||
|
</div>
|
||||||
|
<div class="pin-display display-4 mb-4 d-flex gap-2">
|
||||||
|
<t t-foreach="[0,1,2,3]" t-as="i" t-key="i">
|
||||||
|
<span t-att-class="state.enteredPin.length > i ? 'text-primary' : 'text-muted'">●</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="pin-pad row row-cols-3 g-3" style="max-width: 300px;">
|
||||||
|
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="i" t-key="i">
|
||||||
|
<div class="col"><button class="btn btn-outline-secondary w-100 py-4 fs-3 shadow-none" t-on-click="() => this.pressKey(i)" t-esc="i"/></div>
|
||||||
|
</t>
|
||||||
|
<div class="col"><button class="btn btn-outline-secondary w-100 py-4 fs-3 shadow-none" t-on-click="clearPin"><i class="fa fa-times text-danger"/></button></div>
|
||||||
|
<div class="col"><button class="btn btn-outline-secondary w-100 py-4 fs-3 shadow-none" t-on-click="() => this.pressKey(0)">0</button></div>
|
||||||
|
<div class="col"><button class="btn btn-outline-secondary w-100 py-4 fs-3 shadow-none disabled"><i class="fa fa-check text-success"/></button></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-link mt-4 text-muted" t-on-click="backToSelection">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACTION SELECTION -->
|
||||||
|
<div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in h-100 justify-content-center">
|
||||||
|
<h3 class="mb-5">What would you like to do?</h3>
|
||||||
|
<div class="d-flex gap-5">
|
||||||
|
<div class="action-card card text-center p-5 shadow border-0 cursor-pointer rounded-4 hover-lift" t-on-click="() => this.selectAction('realization')" style="width: 250px;">
|
||||||
|
<div class="display-3 text-success mb-3"><i class="fa fa-file-image-o"/></div>
|
||||||
|
<h5>Upload Receipt</h5>
|
||||||
|
<p class="text-muted small">Realize an existing expense report</p>
|
||||||
|
<span t-if="state.pendingRealizations.length > 0" class="badge rounded-pill bg-danger position-absolute top-0 start-100 translate-middle" t-esc="state.pendingRealizations.length"/>
|
||||||
|
</div>
|
||||||
|
<div class="action-card card text-center p-5 shadow border-0 cursor-pointer rounded-4 hover-lift" t-on-click="() => this.selectAction('new_expense')" style="width: 250px;">
|
||||||
|
<div class="display-3 text-primary mb-3"><i class="fa fa-plus-circle"/></div>
|
||||||
|
<h5>New Expense</h5>
|
||||||
|
<p class="text-muted small">Submit a new reimbursement request</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAYMENT MODE SELECTION -->
|
||||||
|
<div t-if="state.screen === 'payment_mode_selection'" class="d-flex flex-column align-items-center animate-fade-in h-100 justify-content-center">
|
||||||
|
<h3 class="mb-5">How is this expense paid?</h3>
|
||||||
|
<div class="d-flex gap-5">
|
||||||
|
<div class="action-card card text-center p-5 shadow border-0 cursor-pointer rounded-4 hover-lift" t-on-click="() => this.selectPaymentMode('company_account')" style="width: 250px;">
|
||||||
|
<div class="display-3 text-info mb-3"><i class="fa fa-money"/></div>
|
||||||
|
<h5>Kasbon</h5>
|
||||||
|
<p class="text-muted small">Paid by Company</p>
|
||||||
|
</div>
|
||||||
|
<div class="action-card card text-center p-5 shadow border-0 cursor-pointer rounded-4 hover-lift" t-on-click="() => this.selectPaymentMode('own_account')" style="width: 250px;">
|
||||||
|
<div class="display-3 text-warning mb-3"><i class="fa fa-credit-card"/></div>
|
||||||
|
<h5>Reimburse</h5>
|
||||||
|
<p class="text-muted small">Paid by Employee</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-link mt-4 text-muted" t-on-click="() => this.state.screen = 'action_selection'">Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CATEGORY SELECTION -->
|
||||||
|
<div t-if="state.screen === 'category_selection'" class="animate-fade-in">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h3>Select Expense Category</h3>
|
||||||
|
<p class="text-muted">What type of expense is this?</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 d-flex justify-content-center">
|
||||||
|
<div class="input-group w-50 shadow-sm">
|
||||||
|
<span class="input-group-text"><i class="fa fa-search"/></span>
|
||||||
|
<input type="text" class="form-control" placeholder="Search category..." t-model="state.categorySearchQuery"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cols-2 row-cols-md-4 g-4 overflow-auto px-2" style="max-height: 60vh;">
|
||||||
|
<t t-foreach="filteredCategories" t-as="cat" t-key="cat.id">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 text-center shadow-sm border-0 cursor-pointer hover-shadow" t-on-click="() => this.selectCategory(cat)">
|
||||||
|
<div class="p-3">
|
||||||
|
<img t-att-src="cat.image_url" class="mb-2" style="width: 64px; height: 64px; object-fit: contain;"/>
|
||||||
|
<div class="fw-bold small text-truncate" t-esc="cat.display_name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<button class="btn btn-link text-muted" t-on-click="() => this.state.screen = 'payment_mode_selection'">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FORM -->
|
||||||
|
<div t-if="state.screen === 'form'" class="animate-fade-in" style="max-width: 600px; margin: 0 auto;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h3 class="m-0">
|
||||||
|
<t t-if="state.selectedAction === 'realization'">Realize Receipt</t>
|
||||||
|
<t t-else="">Create New Expense</t>
|
||||||
|
</h3>
|
||||||
|
<span t-if="state.selectedCategory" class="badge bg-primary fs-6 py-2 px-3">
|
||||||
|
<i class="fa fa-tag me-1"/> <t t-esc="state.selectedCategory.display_name"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.selectedAction === 'realization'" class="mb-4">
|
||||||
|
<label class="form-label fw-bold">Select Expense to Realize</label>
|
||||||
|
<div class="list-group shadow-sm">
|
||||||
|
<t t-foreach="state.pendingRealizations" t-as="exp" t-key="exp.id">
|
||||||
|
<button type="button"
|
||||||
|
t-attf-class="list-group-item list-group-item-action d-flex justify-content-between align-items-center #{state.selectedExpense and state.selectedExpense.id === exp.id ? 'active' : ''}"
|
||||||
|
t-on-click="() => this.selectExpense(exp)">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold" t-esc="exp.name"/>
|
||||||
|
<small t-esc="exp.date"/>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-light text-dark shadow-sm">
|
||||||
|
<t t-esc="exp.total_amount"/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-foreach="state.formData.lines" t-as="line" t-key="line_index" class="card shadow-sm border-0 p-4 mb-4 position-relative animate-fade-in">
|
||||||
|
<button t-if="state.formData.lines.length > 1" class="btn btn-sm btn-outline-danger position-absolute top-0 end-0 m-2 border-0" t-on-click="() => this.removeLine(line_index)">
|
||||||
|
<i class="fa fa-times-circle fs-5"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Amount <small t-if="state.formData.lines.length > 1" class="text-muted">(Receipt #<t t-esc="line_index + 1"/>)</small></label>
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<span class="input-group-text">Rp</span>
|
||||||
|
<input type="number" class="form-control text-center py-3" t-model.number="line.amount" placeholder="0.00"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Description</label>
|
||||||
|
<input type="text" class="form-control" t-model="line.description" placeholder="e.g. Lunch with Client"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<label class="form-label d-block text-start fw-bold">
|
||||||
|
Upload Receipt Image
|
||||||
|
<t t-if="state.selectedAction === 'new_expense'">
|
||||||
|
<span t-if="state.selectedPaymentMode === 'own_account'" class="text-danger ms-1">*</span>
|
||||||
|
<span t-else="" class="text-muted fw-normal ms-1">(Optional)</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="text-danger ms-1">*</span>
|
||||||
|
</t>
|
||||||
|
</label>
|
||||||
|
<div class="receipt-preview-container position-relative mb-3 d-flex justify-content-center border-dashed" style="height: 200px; border: 2px dashed #dee2e6; background-color: white !important;">
|
||||||
|
<img t-if="line.image" t-att-src="line.image" class="h-100 rounded" style="object-fit: contain;"/>
|
||||||
|
<div t-else="" class="d-flex flex-column justify-content-center text-muted">
|
||||||
|
<i class="fa fa-camera display-4 mb-2"/>
|
||||||
|
<span>Take a photo or upload file</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" class="opacity-0 position-absolute top-0 start-0 w-100 h-100 cursor-pointer" t-on-change="(ev) => this.onLineFileChange(line_index, ev)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ADD ANOTHER RECEIPT BUTTON -->
|
||||||
|
<div t-if="state.selectedAction === 'realization'" class="text-center mb-4">
|
||||||
|
<button class="btn btn-outline-primary shadow-sm px-4" t-on-click="addLine">
|
||||||
|
<i class="fa fa-plus-circle me-2"/>Add Another Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOTAL SUMMARY -->
|
||||||
|
<div t-if="state.formData.lines.length > 1" class="card shadow-sm border-0 p-3 mb-4 bg-info bg-opacity-10">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold text-info">Total Realization:</span>
|
||||||
|
<span class="fs-4 fw-bold text-info">
|
||||||
|
Rp <t t-esc="state.formData.lines.reduce((s, l) => s + (l.amount || 0), 0).toLocaleString()"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-lg flex-grow-1" t-on-click="() => this.state.screen = state.selectedAction === 'new_expense' ? 'category_selection' : 'action_selection'">Back</button>
|
||||||
|
<button class="btn btn-primary btn-lg flex-grow-1 py-3 shadow" t-on-click="submitForm">
|
||||||
|
<t t-if="state.selectedAction === 'realization'">Submit Realization</t>
|
||||||
|
<t t-else="">Submit Expense</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-white p-2 text-center text-muted border-top small">
|
||||||
|
Odoo 17 Expense Kiosk — Kipas Lima Delapan
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-shadow:hover { box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
||||||
|
.hover-lift:hover { transform: translateY(-5px); transition: transform .2s; }
|
||||||
|
.cursor-pointer { cursor: pointer; }
|
||||||
|
.o_expense_kiosk_container {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
color: #212529 !important;
|
||||||
|
}
|
||||||
|
.card { background-color: white !important; color: #212529 !important; }
|
||||||
|
.list-group-item { background-color: white !important; color: #212529 !important; }
|
||||||
|
.list-group-item.active { background-color: var(--primary) !important; color: white !important; border-color: var(--primary) !important; }
|
||||||
|
.pin-pad button { background-color: white !important; color: #212529 !important; }
|
||||||
|
.pin-pad button:hover { background-color: #e9ecef !important; }
|
||||||
|
.input-group-text, .form-control { background-color: white !important; color: #212529 !important; }
|
||||||
|
.animate-fade-in { animation: fadeIn 0.3s ease-in-out; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.pin-display { font-family: monospace; letter-spacing: 15px; }
|
||||||
|
.list-group-item.active { background-color: var(--primary); border-color: var(--primary); }
|
||||||
|
</style>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
30
views/hr_expense_kiosk_templates.xml
Normal file
30
views/hr_expense_kiosk_templates.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="hr_expense_kiosk_mode_template" name="Expense Kiosk Mode">
|
||||||
|
<t t-call="web.layout">
|
||||||
|
<t t-set="head">
|
||||||
|
<title>Expense Kiosk</title>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/web/static/img/favicon.ico"/>
|
||||||
|
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes"/>
|
||||||
|
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||||
|
<t t-call-assets="hr_expense_account_split.assets_public_kiosk" t-js="false"/>
|
||||||
|
<t t-call-assets="hr_expense_account_split.assets_public_kiosk" t-css="false"/>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
odoo.__session_info__ = <t t-out="json.dumps(session_info)"/>;
|
||||||
|
odoo.kiosk_token = "<t t-out="kiosk_token"/>";
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body.o_kiosk_mode { background-color: #f8f9fa !important; }
|
||||||
|
</style>
|
||||||
|
</t>
|
||||||
|
<body class="o_web_client o_kiosk_mode">
|
||||||
|
<main class="o_main"></main>
|
||||||
|
</body>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
@ -26,14 +26,11 @@
|
|||||||
class="oe_highlight o_expense_submit"
|
class="oe_highlight o_expense_submit"
|
||||||
invisible="sheet_id"
|
invisible="sheet_id"
|
||||||
data-hotkey="v"/>
|
data-hotkey="v"/>
|
||||||
<!-- Re-add Attach Receipt but hide it on new records -->
|
|
||||||
<widget name="attach_document"
|
<widget name="attach_document"
|
||||||
string="Attach Receipt"
|
string="Attach Receipt"
|
||||||
action="attach_document"
|
action="attach_document"
|
||||||
highlight="nb_attachment < 1"
|
highlight="nb_attachment < 1 and payment_mode == 'own_account'"
|
||||||
invisible="not id or sheet_id"/>
|
invisible="sheet_id"/>
|
||||||
<!-- Re-add Split Expense with correct logic -->
|
|
||||||
<button name="action_split_wizard" string="Split Expense" type="object" invisible="not id or sheet_id or product_has_cost"/>
|
|
||||||
</xpath>
|
</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">
|
||||||
|
|||||||
29
views/res_config_settings_views.xml
Normal file
29
views/res_config_settings_views.xml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form_inherit_expense_kiosk" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.expense.kiosk</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="hr_expense.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//block[@name='expenses_setting_container']" position="after">
|
||||||
|
<block title="Expense Kiosk" id="expense_kiosk_settings">
|
||||||
|
<setting title="Separate URL for Expense Kiosk" help="No login required for employees. Submissions still require PIN.">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt16">
|
||||||
|
<label for="expense_kiosk_url" string="Kiosk URL" class="col-lg-3 o_light_label"/>
|
||||||
|
<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>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Loading…
Reference in New Issue
Block a user