feat: item prices list view (#54853)

* feat: add item prices tab to Item doctype

* feat: item form pricing tab

* fix: remove action button for edit item price

* fix: prevent stale item price rendering after form navigation

* fix: remove stale call to deleted edit_prices_button function

* fix: item price list fixes

* fix: show filtered price list

* fix: show filtered price list
This commit is contained in:
Khushi Rawat
2026-06-05 15:42:18 +05:30
committed by GitHub
parent df03524b19
commit 5873f55cf0
5 changed files with 276 additions and 16 deletions

View File

@@ -255,13 +255,14 @@ frappe.ui.form.on("Item", {
); );
} }
erpnext.item.edit_prices_button(frm);
erpnext.item.toggle_attributes(frm); erpnext.item.toggle_attributes(frm);
if (!frm.doc.is_fixed_asset) { if (!frm.doc.is_fixed_asset) {
erpnext.item.make_dashboard(frm); erpnext.item.make_dashboard(frm);
} }
erpnext.item.render_item_prices(frm);
frm.add_custom_button(__("Duplicate"), function () { frm.add_custom_button(__("Duplicate"), function () {
var new_item = frappe.model.copy_doc(frm.doc); var new_item = frappe.model.copy_doc(frm.doc);
// Duplicate item could have different name, causing "copy paste" error. // Duplicate item could have different name, causing "copy paste" error.
@@ -665,24 +666,60 @@ $.extend(erpnext.item, {
} }
}, },
edit_prices_button: function (frm) { render_item_prices: function (frm) {
frm.add_custom_button( if (frm.doc.__islocal) return;
__("Add / Edit Prices"), const requested_item = frm.doc.name;
function () { const container = frm.fields_dict["prices_html"].$wrapper;
frappe.set_route("List", "Item Price", { item_code: frm.doc.name });
}, container.html(
__("Actions") `<div class="text-muted text-center" style="padding: 20px;">${__("Loading...")}</div>`
); );
frm.add_custom_button( frappe.call({
__("Make Lead Time"), method: "erpnext.stock.doctype.item.item.get_item_prices",
function () { args: { item_code: requested_item },
frm.make_new("Item Lead Time", {
item_code: frm.doc.name, callback: function (r) {
if (requested_item !== frm.doc.name) return;
if (!r.message) return;
const { prices, has_more } = r.message;
const html = frappe.render_template("item_prices", {
prices,
has_more,
item_code: requested_item,
stock_uom: frm.doc.stock_uom,
});
container.html(html);
container.find(".add-price-btn").on("click", () => {
const filters = {};
if (frm.doc.is_sales_item && !frm.doc.is_purchase_item) {
filters.selling = 1;
} else if (frm.doc.is_purchase_item && !frm.doc.is_sales_item) {
filters.buying = 1;
}
frappe.new_doc(
"Item Price",
{ item_code: requested_item, uom: frm.doc.stock_uom },
(dialog) => {
if (Object.keys(filters).length) {
dialog.fields_dict.price_list.get_query = () => ({ filters });
}
}
);
});
container.find(".price-row").on("click", function (e) {
if ($(e.target).is("a")) return;
frappe.set_route("Form", "Item Price", $(this).data("name"));
}); });
}, },
__("Actions") });
);
}, },
weight_to_validate: function (frm) { weight_to_validate: function (frm) {

View File

@@ -16,6 +16,7 @@
"item_name", "item_name",
"item_group", "item_group",
"stock_uom", "stock_uom",
"image",
"column_break0", "column_break0",
"disabled", "disabled",
"is_stock_item", "is_stock_item",
@@ -39,7 +40,6 @@
"over_delivery_receipt_allowance", "over_delivery_receipt_allowance",
"column_break_wugd", "column_break_wugd",
"over_billing_allowance", "over_billing_allowance",
"image",
"section_break_11", "section_break_11",
"brand", "brand",
"description", "description",
@@ -138,6 +138,9 @@
"inspection_required_before_delivery", "inspection_required_before_delivery",
"column_break_pxjh", "column_break_pxjh",
"quality_inspection_template", "quality_inspection_template",
"pricing_tab",
"item_prices_column",
"prices_html",
"dashboard_tab" "dashboard_tab"
], ],
"fields": [ "fields": [
@@ -256,6 +259,7 @@
"description": "Used to create an opening Stock Entry with the Valuation Rate when the item is saved", "description": "Used to create an opening Stock Entry with the Valuation Rate when the item is saved",
"fieldname": "opening_stock", "fieldname": "opening_stock",
"fieldtype": "Float", "fieldtype": "Float",
"hidden": 1,
"label": "Opening Stock" "label": "Opening Stock"
}, },
{ {
@@ -1064,6 +1068,23 @@
{ {
"fieldname": "column_break_kpmi", "fieldname": "column_break_kpmi",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "prices_html",
"fieldtype": "HTML",
"label": "Prices HTML",
"options": "<div id=\\\"item-prices-container\\\"></div>"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "pricing_tab",
"fieldtype": "Tab Break",
"label": "Pricing"
},
{
"fieldname": "item_prices_column",
"fieldtype": "Column Break",
"label": "Item Prices"
} }
], ],
"icon": "fa fa-tag", "icon": "fa fa-tag",

View File

@@ -1544,3 +1544,41 @@ def get_child_warehouses(warehouse):
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
return get_child_warehouses(warehouse) return get_child_warehouses(warehouse)
@frappe.whitelist()
def get_item_prices(item_code: str):
"""Fetch valid item prices for the item prices tab."""
if not frappe.has_permission("Item Price", "read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
today = getdate()
ItemPrice = frappe.qb.DocType("Item Price")
prices = (
frappe.qb.from_(ItemPrice)
.select(
ItemPrice.name,
ItemPrice.price_list,
ItemPrice.price_list_rate,
ItemPrice.currency,
ItemPrice.uom,
ItemPrice.customer,
ItemPrice.supplier,
ItemPrice.buying,
ItemPrice.selling,
ItemPrice.valid_upto,
)
.where(ItemPrice.item_code == item_code)
.where(ItemPrice.docstatus != 2)
.where((ItemPrice.valid_upto.isnull()) | (ItemPrice.valid_upto >= today))
.orderby(ItemPrice.price_list)
.limit(11)
.run(as_dict=True)
)
has_more = len(prices) == 11
return {
"prices": prices[:10],
"has_more": has_more,
}

View File

@@ -0,0 +1,139 @@
<!-- Item Prices Tab -->
<style>
.item-price-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-md);
color: var(--text-color);
}
.item-price-table thead tr {
background: var(--subtle-fg);
}
.item-price-table th {
padding: 8px 10px;
font-weight: 400;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
}
.item-price-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--border-color);
}
.item-price-table tbody tr:last-child td {
border-bottom: none;
}
.item-price-table tbody tr.price-row {
cursor: pointer;
}
.item-price-table tbody tr.price-row:hover {
background: var(--fg-hover-color);
}
.item-price-table .col-no {
width: 42px;
text-align: center;
color: var(--text-muted);
font-size: var(--text-sm);
}
.item-price-table .col-rate {
text-align: right;
font-variant-numeric: tabular-nums;
}
.item-price-table th.col-rate {
text-align: right;
}
.item-price-table .col-muted {
color: var(--text-muted);
}
.item-price-table a {
color: var(--text-color);
}
.item-prices-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
margin-bottom: 16px;
}
</style>
<div style="margin-bottom: 12px;">
<div class="text-extra-muted" style="font-size: var(--text-sm);">{{ __("All active prices for this item across buying and selling price lists.") }}</div>
</div>
{% if (prices && prices.length) { %}
<div style="border: 1px solid var(--border-color); border-radius: var(--border-radius-md); overflow: hidden; margin-bottom: 16px;">
<table class="item-price-table">
<thead>
<tr>
<th class="col-no">{{ __("No.") }}</th>
<th>{{ __("Price List") }}</th>
<th>{{ __("Type") }}</th>
<th>{{ __("Party") }}</th>
<th class="col-rate">{{ __("Rate") }}</th>
<th>{{ __("UOM") }}</th>
<th>{{ __("Valid Upto") }}</th>
</tr>
</thead>
<tbody>
{% for (var i=0; i < prices.length; i++) { var p = prices[i]; %}
<tr class="price-row" data-name="{{ p.name }}">
<td class="col-no">{{ i + 1 }}</td>
<td>{{ p.price_list }}</td>
<td>
{% if (p.buying && p.selling) { %}
{{ __("Buy & Sell") }}
{% } else if (p.buying) { %}
{{ __("Buying") }}
{% } else if (p.selling) { %}
{{ __("Selling") }}
{% } %}
</td>
<td>
{% if (p.customer) { %}
<a href="/app/customer/{{ encodeURIComponent(p.customer) }}" onclick="event.stopPropagation()">{{ p.customer }}</a>
{% } else if (p.supplier) { %}
<a href="/app/supplier/{{ encodeURIComponent(p.supplier) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>
{% } %}
</td>
<td class="col-rate">{{ format_currency(p.price_list_rate, p.currency) }}</td>
<td class="col-muted">{{ p.uom || stock_uom }}</td>
<td class="col-muted">{{ p.valid_upto ? frappe.datetime.str_to_user(p.valid_upto) : "" }}</td>
</tr>
{% } %}
</tbody>
</table>
</div>
<div class="item-prices-footer">
<div>
{% if (has_more) { %}
<a href="/app/item-price?item_code={{ encodeURIComponent(item_code) }}" class="btn btn-xs btn-default">
{{ __("View All Prices") }}
</a>
{% } %}
</div>
<button class="btn btn-xs btn-default add-price-btn">
{{ __("+ Add Price") }}
</button>
</div>
{% } else { %}
<div style="text-align: center; padding: 40px 20px; color: var(--text-muted); border: 1px dashed var(--border-color); border-radius: var(--border-radius-md); margin-bottom: 14px;">
<p style="margin-bottom: 12px;">{{ __("No active item prices found.") }}</p>
<button class="btn btn-sm btn-default add-price-btn">{{ __("+ Add Price") }}</button>
</div>
{% } %}

View File

@@ -10,6 +10,15 @@ frappe.ui.form.on("Item Price", {
}, },
}; };
}); });
frm._price_list_filters = {};
frm.set_query("price_list", () => ({ filters: frm._price_list_filters }));
},
refresh(frm) {
if (frm.doc.item_code) {
frm.trigger("item_code");
}
}, },
onload(frm) { onload(frm) {
@@ -37,4 +46,20 @@ frappe.ui.form.on("Item Price", {
}; };
}); });
}, },
item_code(frm) {
frm._price_list_filters = {};
if (frm.doc.item_code) {
frappe.db
.get_value("Item", frm.doc.item_code, ["is_sales_item", "is_purchase_item"])
.then((r) => {
if (!r.message) return;
if (r.message.is_sales_item && !r.message.is_purchase_item) {
frm._price_list_filters.selling = 1;
} else if (r.message.is_purchase_item && !r.message.is_sales_item) {
frm._price_list_filters.buying = 1;
}
});
}
},
}); });