feat: inline-editable prices in product mapping UI

Click any price cell (WC Standard, WC Sale, Odoo Price) to edit inline.
Enter or click away saves and syncs to the source. Escape cancels.
Validation: sale price cannot exceed standard price.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-01 11:23:23 -04:00
parent 8354e82dc4
commit c5b519f8f4
4 changed files with 173 additions and 6 deletions

View File

@@ -135,6 +135,26 @@ class WooProductMap(models.Model):
'Standard price set to $%.2f' % price,
)
def action_set_sale_price(self, price):
"""Set the WC sale price directly."""
self.ensure_one()
if not self.instance_id:
return
client = self.instance_id._get_client()
# Sale price cannot exceed regular price
if self.woo_regular_price and price > self.woo_regular_price + 0.01:
raise UserError(
'Sale price ($%.2f) cannot exceed the standard price ($%.2f).'
% (price, self.woo_regular_price)
)
update_data = {'sale_price': str(price) if price > 0 else ''}
client.update_product(self.woo_product_id, update_data)
self.woo_sale_price = price
self.instance_id._log_sync(
'product', 'odoo_to_woo', self.woo_product_name, 'success',
'Sale price set to $%.2f' % price,
)
# ------------------------------------------------------------------
# Image Sync (Task 22)
# ------------------------------------------------------------------

View File

@@ -615,3 +615,26 @@ html[style*="color-scheme: dark"] {
width: 60px;
white-space: nowrap;
}
/* ----------------------------------------------------------
Editable price cells
---------------------------------------------------------- */
.woo-editable-cell {
cursor: pointer;
position: relative;
}
.woo-editable-cell:hover {
background: var(--woo-bg-hover) !important;
}
.woo-edit-input {
width: 90px;
padding: 2px 6px;
border: 1px solid var(--woo-accent);
border-radius: 4px;
font-size: 0.85rem;
text-align: right;
background: var(--woo-input-bg);
color: var(--woo-text-primary);
outline: none;
box-shadow: 0 0 0 2px var(--woo-accent-glow);
}

View File

@@ -43,6 +43,10 @@ export class ProductMapping extends Component {
mappedPage: 1,
mappedTotal: 0,
// Inline price editing
editingCell: null, // { mapId: int, field: 'woo_regular'|'woo_sale'|'odoo_price' }
editValue: '',
// Unmatched tab
odooProducts: [],
wooProducts: [],
@@ -522,6 +526,96 @@ export class ProductMapping extends Component {
}
}
// -------------------------------------------------------------------------
// Inline price editing
// -------------------------------------------------------------------------
startEdit(mapId, field, currentValue) {
this.state.editingCell = { mapId, field };
this.state.editValue = currentValue !== null && currentValue !== undefined ? String(currentValue) : '';
// Focus the input after OWL re-renders
setTimeout(() => {
const input = document.querySelector('.woo-edit-input');
if (input) { input.focus(); input.select(); }
}, 50);
}
cancelEdit() {
this.state.editingCell = null;
this.state.editValue = '';
}
onEditInput(ev) {
this.state.editValue = ev.target.value;
}
async onEditKeydown(ev) {
if (ev.key === 'Enter') {
await this.saveEdit();
} else if (ev.key === 'Escape') {
this.cancelEdit();
}
}
async onEditBlur() {
await this.saveEdit();
}
isEditing(mapId, field) {
return this.state.editingCell &&
this.state.editingCell.mapId === mapId &&
this.state.editingCell.field === field;
}
async saveEdit() {
const cell = this.state.editingCell;
if (!cell) return;
const value = parseFloat(this.state.editValue);
if (isNaN(value) || value < 0) {
this.notification.add("Invalid price value.", { type: "danger" });
this.cancelEdit();
return;
}
// Cancel first so blur doesn't fire a second save after Enter
this.cancelEdit();
try {
if (cell.field === 'woo_regular') {
await rpc("/web/dataset/call_kw", {
model: "woo.product.map",
method: "action_set_regular_price",
args: [[cell.mapId], value],
kwargs: {},
});
} else if (cell.field === 'woo_sale') {
await rpc("/web/dataset/call_kw", {
model: "woo.product.map",
method: "action_set_sale_price",
args: [[cell.mapId], value],
kwargs: {},
});
} else if (cell.field === 'odoo_price') {
const product = this.state.mappedProducts.find(p => p.id === cell.mapId);
if (product && product.odoo_product_id) {
await rpc("/web/dataset/call_kw", {
model: "product.product",
method: "write",
args: [[product.odoo_product_id], { list_price: value }],
kwargs: {},
});
}
}
this.notification.add("Price updated.", { type: "success" });
} catch (err) {
console.error("[ProductMapping] saveEdit error:", err);
this.notification.add(err.message || "Failed to update price.", { type: "danger" });
}
await this._loadMapped();
}
// -------------------------------------------------------------------------
// Individual price sync
// -------------------------------------------------------------------------

View File

@@ -168,12 +168,32 @@
</td>
<td><span class="woo-code"><t t-esc="p.woo_sku"/></span></td>
<td><t t-esc="p.odoo_product_name"/></td>
<td class="text-end" t-esc="this.formatPrice(p.woo_regular_price)"/>
<td class="text-end">
<t t-if="p.woo_sale_price">
<span class="woo-sale-price" t-esc="this.formatPrice(p.woo_sale_price)"/>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'woo_regular', p.woo_regular_price)">
<t t-if="this.isEditing(p.id, 'woo_regular')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="" t-esc="this.formatPrice(p.woo_regular_price)"/>
</td>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'woo_sale', p.woo_sale_price)">
<t t-if="this.isEditing(p.id, 'woo_sale')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="">
<t t-if="p.woo_sale_price">
<span class="woo-sale-price" t-esc="this.formatPrice(p.woo_sale_price)"/>
</t>
<t t-else=""><span class="woo-text-muted"></span></t>
</t>
<t t-else=""><span class="woo-text-muted"></span></t>
</td>
<td class="text-center woo-price-sync-col">
<button class="woo-btn-icon" title="Push WC price to Odoo"
@@ -185,7 +205,17 @@
<i class="fa fa-arrow-left"/>
</button>
</td>
<td class="text-end" t-esc="this.formatPrice(p.odoo_price)"/>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'odoo_price', p.odoo_price)">
<t t-if="this.isEditing(p.id, 'odoo_price')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="" t-esc="this.formatPrice(p.odoo_price)"/>
</td>
<td><t t-esc="p.instance_name"/></td>
<td>
<input type="checkbox"