feat: add Pending status for delivery/technician tasks

- New 'pending' status allows tasks to be created without a schedule,
  acting as a queue for unscheduled work that gets assigned later
- Pending group appears in the Delivery Map sidebar with amber color
- Other modules can create tasks in pending state for scheduling
- scheduled_date no longer required (null for pending tasks)
- New Pending Tasks menu item under Field Service
- Pending filter added to search view

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-02-24 04:21:05 -05:00
parent 84c009416e
commit 0e1aebe60b
26 changed files with 2735 additions and 9 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,374 @@
/** @odoo-module **/
import { _t } from '@web/core/l10n/translation';
import { patch } from '@web/core/utils/patch';
import { rpc } from '@web/core/network/rpc';
import { PaymentForm } from '@payment/interactions/payment_form';
patch(PaymentForm.prototype, {
setup() {
super.setup();
this.poyntFormData = {};
},
// #=== DOM MANIPULATION ===#
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'poynt') {
await super._prepareInlineForm(...arguments);
return;
}
if (flow === 'token') {
return;
}
this._setPaymentFlow('direct');
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const poyntContainer = inlineForm.querySelector('[name="o_poynt_payment_container"]');
if (!poyntContainer) {
return;
}
const rawValues = poyntContainer.dataset['poyntInlineFormValues'];
if (rawValues) {
this.poyntFormData = JSON.parse(rawValues);
}
this._setupCardFormatting(poyntContainer);
this._setupTerminalToggle(poyntContainer);
},
_setupCardFormatting(container) {
const cardInput = container.querySelector('#poynt_card_number');
if (cardInput) {
cardInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
let formatted = '';
for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) {
formatted += ' ';
}
formatted += value[i];
}
e.target.value = formatted;
});
}
const expiryInput = container.querySelector('#poynt_expiry');
if (expiryInput) {
expiryInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
if (value.length >= 2) {
value = value.substring(0, 2) + '/' + value.substring(2, 4);
}
e.target.value = value;
});
}
},
_setupTerminalToggle(container) {
const terminalCheckbox = container.querySelector('#poynt_use_terminal');
const terminalSelect = container.querySelector('#poynt_terminal_select_wrapper');
const cardFields = container.querySelectorAll(
'#poynt_card_number, #poynt_expiry, #poynt_cvv, #poynt_cardholder'
);
if (!terminalCheckbox) {
return;
}
terminalCheckbox.addEventListener('change', () => {
if (terminalCheckbox.checked) {
if (terminalSelect) {
terminalSelect.style.display = 'block';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'none';
f.removeAttribute('required');
});
this._loadTerminals(container);
} else {
if (terminalSelect) {
terminalSelect.style.display = 'none';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'block';
if (f.id !== 'poynt_cardholder') {
f.setAttribute('required', 'required');
}
});
}
});
},
async _loadTerminals(container) {
const selectEl = container.querySelector('#poynt_terminal_select');
if (!selectEl || selectEl.options.length > 1) {
return;
}
try {
const terminals = await rpc('/payment/poynt/terminals', {
provider_id: this.poyntFormData.provider_id,
});
selectEl.innerHTML = '';
if (terminals && terminals.length > 0) {
terminals.forEach(t => {
const option = document.createElement('option');
option.value = t.id;
option.textContent = `${t.name} (${t.status})`;
selectEl.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('No terminals available');
selectEl.appendChild(option);
}
} catch {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('Failed to load terminals');
selectEl.appendChild(option);
}
},
// #=== PAYMENT FLOW ===#
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'poynt' || flow === 'token') {
await super._initiatePaymentFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#poynt_use_terminal');
if (useTerminal && useTerminal.checked) {
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
if (!terminalId) {
this._displayErrorDialog(
_t("Terminal Required"),
_t("Please select a terminal device."),
);
this._enableButton();
return;
}
} else {
const validationError = this._validateCardInputs(inlineForm);
if (validationError) {
this._displayErrorDialog(
_t("Invalid Card Details"),
validationError,
);
this._enableButton();
return;
}
}
await super._initiatePaymentFlow(...arguments);
},
_validateCardInputs(inlineForm) {
const cardNumber = inlineForm.querySelector('#poynt_card_number');
const expiry = inlineForm.querySelector('#poynt_expiry');
const cvv = inlineForm.querySelector('#poynt_cvv');
const cardDigits = cardNumber.value.replace(/\D/g, '');
if (cardDigits.length < 13 || cardDigits.length > 19) {
return _t("Please enter a valid card number.");
}
const expiryValue = expiry.value;
if (!/^\d{2}\/\d{2}$/.test(expiryValue)) {
return _t("Please enter a valid expiry date (MM/YY).");
}
const [month, year] = expiryValue.split('/').map(Number);
if (month < 1 || month > 12) {
return _t("Invalid expiry month.");
}
const now = new Date();
const expiryDate = new Date(2000 + year, month);
if (expiryDate <= now) {
return _t("Card has expired.");
}
const cvvValue = cvv.value.replace(/\D/g, '');
if (cvvValue.length < 3 || cvvValue.length > 4) {
return _t("Please enter a valid CVV.");
}
return null;
},
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
if (providerCode !== 'poynt') {
await super._processDirectFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#poynt_use_terminal');
if (useTerminal && useTerminal.checked) {
await this._processTerminalPayment(processingValues, inlineForm);
} else {
await this._processCardPayment(processingValues, inlineForm);
}
},
async _processCardPayment(processingValues, inlineForm) {
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
const expiry = inlineForm.querySelector('#poynt_expiry').value;
const cvv = inlineForm.querySelector('#poynt_cvv').value;
const cardholder = inlineForm.querySelector('#poynt_cardholder').value;
const [expMonth, expYear] = expiry.split('/').map(Number);
try {
const result = await rpc('/payment/poynt/process_card', {
reference: processingValues.reference,
poynt_order_id: processingValues.poynt_order_id,
card_number: cardNumber,
exp_month: expMonth,
exp_year: 2000 + expYear,
cvv: cvv,
cardholder_name: cardholder,
});
if (result.error) {
this._displayErrorDialog(
_t("Payment Failed"),
result.error,
);
this._enableButton();
return;
}
window.location.href = processingValues.return_url;
} catch (error) {
this._displayErrorDialog(
_t("Payment Processing Error"),
error.message || _t("An unexpected error occurred."),
);
this._enableButton();
}
},
async _processTerminalPayment(processingValues, inlineForm) {
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
try {
const result = await rpc('/payment/poynt/send_to_terminal', {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
poynt_order_id: processingValues.poynt_order_id,
});
if (result.error) {
this._displayErrorDialog(
_t("Terminal Payment Failed"),
result.error,
);
this._enableButton();
return;
}
this._showTerminalWaitingScreen(processingValues, terminalId);
} catch (error) {
this._displayErrorDialog(
_t("Terminal Error"),
error.message || _t("Failed to send payment to terminal."),
);
this._enableButton();
}
},
_showTerminalWaitingScreen(processingValues, terminalId) {
const container = document.querySelector('.o_poynt_payment_form');
if (container) {
container.innerHTML = `
<div class="text-center p-4">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5>${_t("Waiting for terminal payment...")}</h5>
<p class="text-muted">
${_t("Please complete the payment on the terminal device.")}
</p>
<p class="text-muted small" id="poynt_terminal_status">
${_t("Checking status...")}
</p>
</div>
`;
}
this._pollTerminalStatus(processingValues, terminalId);
},
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
const maxAttempts = 60;
const pollInterval = 3000;
if (attempt >= maxAttempts) {
this._displayErrorDialog(
_t("Timeout"),
_t("Terminal payment timed out. Please check the device."),
);
this._enableButton();
return;
}
try {
const result = await rpc('/payment/poynt/terminal_status', {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
});
const statusEl = document.getElementById('poynt_terminal_status');
if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') {
if (statusEl) {
statusEl.textContent = _t("Payment completed! Redirecting...");
}
window.location.href = processingValues.return_url;
return;
}
if (result.status === 'DECLINED' || result.status === 'FAILED') {
this._displayErrorDialog(
_t("Payment Declined"),
_t("The payment was declined at the terminal."),
);
this._enableButton();
return;
}
if (statusEl) {
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
}
setTimeout(
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
pollInterval,
);
} catch {
setTimeout(
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
pollInterval,
);
}
},
});

View File

@@ -0,0 +1,136 @@
/** @odoo-module **/
import { _t } from '@web/core/l10n/translation';
import { rpc } from '@web/core/network/rpc';
import { Component, useState } from '@odoo/owl';
export class TerminalPaymentWidget extends Component {
static template = 'fusion_poynt.TerminalPaymentWidget';
static props = {
providerId: { type: Number },
amount: { type: Number },
currency: { type: String },
reference: { type: String },
orderId: { type: String, optional: true },
onComplete: { type: Function, optional: true },
onError: { type: Function, optional: true },
};
setup() {
this.state = useState({
terminals: [],
selectedTerminalId: null,
loading: false,
polling: false,
status: '',
message: '',
});
this._loadTerminals();
}
async _loadTerminals() {
this.state.loading = true;
try {
const result = await rpc('/payment/poynt/terminals', {
provider_id: this.props.providerId,
});
this.state.terminals = result || [];
if (this.state.terminals.length > 0) {
this.state.selectedTerminalId = this.state.terminals[0].id;
}
} catch {
this.state.message = _t('Failed to load terminal devices.');
} finally {
this.state.loading = false;
}
}
onTerminalChange(ev) {
this.state.selectedTerminalId = parseInt(ev.target.value);
}
async onSendToTerminal() {
if (!this.state.selectedTerminalId) {
this.state.message = _t('Please select a terminal.');
return;
}
this.state.loading = true;
this.state.message = '';
try {
const result = await rpc('/payment/poynt/send_to_terminal', {
reference: this.props.reference,
terminal_id: this.state.selectedTerminalId,
poynt_order_id: this.props.orderId || '',
});
if (result.error) {
this.state.message = result.error;
this.state.loading = false;
if (this.props.onError) {
this.props.onError(result.error);
}
return;
}
this.state.polling = true;
this.state.status = _t('Waiting for payment on terminal...');
this._pollStatus(0);
} catch (error) {
this.state.message = error.message || _t('Failed to send payment to terminal.');
this.state.loading = false;
if (this.props.onError) {
this.props.onError(this.state.message);
}
}
}
async _pollStatus(attempt) {
const maxAttempts = 60;
const pollInterval = 3000;
if (attempt >= maxAttempts) {
this.state.polling = false;
this.state.loading = false;
this.state.message = _t('Payment timed out. Please check the terminal.');
if (this.props.onError) {
this.props.onError(this.state.message);
}
return;
}
try {
const result = await rpc('/payment/poynt/terminal_status', {
reference: this.props.reference,
terminal_id: this.state.selectedTerminalId,
});
if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') {
this.state.polling = false;
this.state.loading = false;
this.state.status = _t('Payment completed!');
if (this.props.onComplete) {
this.props.onComplete(result);
}
return;
}
if (result.status === 'DECLINED' || result.status === 'FAILED') {
this.state.polling = false;
this.state.loading = false;
this.state.message = _t('Payment was declined.');
if (this.props.onError) {
this.props.onError(this.state.message);
}
return;
}
this.state.status = _t('Status: ') + (result.status || _t('Pending'));
} catch {
this.state.status = _t('Checking...');
}
setTimeout(() => this._pollStatus(attempt + 1), pollInterval);
}
}