diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 72481b0..d5ec88c 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -60,8 +60,13 @@ "assets": { "web.assets_backend": [ "spp_change_request_v2/static/src/components/**/*", + "spp_change_request_v2/static/src/css/cr_search_results.css", "spp_change_request_v2/static/src/js/create_change_request.js", + "spp_change_request_v2/static/src/js/search_delay_field.js", + "spp_change_request_v2/static/src/js/cr_search_results_field.js", "spp_change_request_v2/static/src/xml/create_change_request_template.xml", + "spp_change_request_v2/static/src/xml/search_delay_field.xml", + "spp_change_request_v2/static/src/xml/cr_search_results_field.xml", ], }, "installable": True, diff --git a/spp_change_request_v2/models/change_request_detail_base.py b/spp_change_request_v2/models/change_request_detail_base.py index e331109..8fd7e61 100644 --- a/spp_change_request_v2/models/change_request_detail_base.py +++ b/spp_change_request_v2/models/change_request_detail_base.py @@ -1,4 +1,5 @@ -from odoo import fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError class SPPCRDetailBase(models.AbstractModel): @@ -11,6 +12,14 @@ class SPPCRDetailBase(models.AbstractModel): _name = "spp.cr.detail.base" _description = "Change Request Detail Base" + @api.depends("change_request_id.name") + def _compute_display_name(self): + for rec in self: + if rec.change_request_id and rec.change_request_id.name: + rec.display_name = rec.change_request_id.name + else: + super(SPPCRDetailBase, rec)._compute_display_name() + change_request_id = fields.Many2one( "spp.change.request", string="Change Request", @@ -33,6 +42,23 @@ class SPPCRDetailBase(models.AbstractModel): related="change_request_id.is_applied", ) + def action_proceed_to_cr(self): + """Navigate to the parent Change Request form if there are proposed changes.""" + self.ensure_one() + cr = self.change_request_id + if not cr.has_proposed_changes: + raise UserError( + _("No proposed changes detected. Please make changes before proceeding.") + ) + return { + "type": "ir.actions.act_window", + "name": cr.name, + "res_model": "spp.change.request", + "res_id": cr.id, + "view_mode": "form", + "target": "current", + } + def action_submit_for_approval(self): """Submit the parent CR for approval.""" self.ensure_one() diff --git a/spp_change_request_v2/models/res_partner.py b/spp_change_request_v2/models/res_partner.py index efe9b4f..fe5fab2 100644 --- a/spp_change_request_v2/models/res_partner.py +++ b/spp_change_request_v2/models/res_partner.py @@ -1,7 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Extend res.partner for better registrant display in CR wizard.""" -from odoo import api, models +from odoo import api, fields, models class ResPartner(models.Model): @@ -9,6 +9,24 @@ class ResPartner(models.Model): _inherit = "res.partner" + reg_id_display = fields.Char( + string="Registrant ID", + compute="_compute_reg_id_display", + ) + + @api.depends("reg_ids.value", "reg_ids.id_type_id") + def _compute_reg_id_display(self): + for rec in self: + if rec.reg_ids: + parts = [] + for rid in rec.reg_ids: + if rid.value: + label = rid.id_type_as_str or "ID" + parts.append(f"{label} ({rid.value})") + rec.reg_id_display = ", ".join(parts) if parts else "" + else: + rec.reg_id_display = "" + def _compute_display_name(self): """Add registrant ID to display name when in CR wizard context.""" super()._compute_display_name() diff --git a/spp_change_request_v2/static/src/css/cr_search_results.css b/spp_change_request_v2/static/src/css/cr_search_results.css new file mode 100644 index 0000000..166021a --- /dev/null +++ b/spp_change_request_v2/static/src/css/cr_search_results.css @@ -0,0 +1,7 @@ +/* Force the search results widget to take full width in the form */ +.o_field_cr_search_results, +.o_field_widget:has(.o_field_cr_search_results) { + width: 100% !important; + max-width: 100% !important; + display: block !important; +} diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js new file mode 100644 index 0000000..526c5a9 --- /dev/null +++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js @@ -0,0 +1,62 @@ +/** @odoo-module **/ + +import {Component, onMounted, onPatched, useRef} from "@odoo/owl"; +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +/** + * Custom widget that renders HTML search results and handles row clicks. + * When a row with class "o_cr_search_result" is clicked, it writes the + * partner ID to the _selected_partner_id bridge field, which triggers + * a server-side onchange to set registrant_id. + */ +export class CrSearchResultsField extends Component { + static template = "spp_change_request_v2.CrSearchResultsField"; + static props = {...standardFieldProps}; + + setup() { + this.containerRef = useRef("container"); + onMounted(() => this._attachClickHandler()); + onPatched(() => this._attachClickHandler()); + } + + get htmlContent() { + return this.props.record.data[this.props.name] || ""; + } + + _attachClickHandler() { + const el = this.containerRef.el; + if (!el) return; + // Row selection + el.querySelectorAll(".o_cr_search_result").forEach((row) => { + row.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const partnerId = parseInt(row.dataset.partnerId); + if (partnerId) { + this.props.record.update({_selected_partner_id: partnerId}); + } + }; + }); + // Pagination + el.querySelectorAll(".o_cr_page_prev, .o_cr_page_next").forEach((link) => { + link.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const page = parseInt(link.dataset.page); + if (!isNaN(page) && page >= 0) { + this.props.record.update({_search_page: page}); + } + }; + }); + } +} + +export const crSearchResultsField = { + component: CrSearchResultsField, + displayName: _t("CR Search Results"), + supportedTypes: ["html"], +}; + +registry.category("fields").add("cr_search_results", crSearchResultsField); diff --git a/spp_change_request_v2/static/src/js/create_change_request.js b/spp_change_request_v2/static/src/js/create_change_request.js index fca558c..1eecd24 100644 --- a/spp_change_request_v2/static/src/js/create_change_request.js +++ b/spp_change_request_v2/static/src/js/create_change_request.js @@ -84,5 +84,6 @@ patch(FormController.prototype, { if (this.props.resModel === "spp.change.request") { this.hideFormCreateButton = true; } + // Row click handling for CR create wizard is now in cr_search_results_field.js }, }); diff --git a/spp_change_request_v2/static/src/js/search_delay_field.js b/spp_change_request_v2/static/src/js/search_delay_field.js new file mode 100644 index 0000000..c43a210 --- /dev/null +++ b/spp_change_request_v2/static/src/js/search_delay_field.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import {Component, useEffect, useRef, onWillUnmount} from "@odoo/owl"; +import {_t} from "@web/core/l10n/translation"; +import {useDebounced} from "@web/core/utils/timing"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +/** + * Char field that triggers onchange after a typing delay (500ms). + * Used for search fields where we want live results without waiting for blur. + */ +export class SearchDelayField extends Component { + static template = "spp_change_request_v2.SearchDelayField"; + static props = { + ...standardFieldProps, + placeholder: {type: String, optional: true}, + }; + + setup() { + this.inputRef = useRef("input"); + + this.debouncedCommit = useDebounced((value) => { + this.props.record.update({[this.props.name]: value}); + }, 500); + + // Keep input in sync when record updates externally (e.g. onchange clears it) + useEffect( + () => { + const el = this.inputRef.el; + if (el) { + const recordValue = this.props.record.data[this.props.name] || ""; + if (el !== document.activeElement || !recordValue) { + el.value = recordValue; + } + } + }, + () => [this.props.record.data[this.props.name]] + ); + } + + get value() { + return this.props.record.data[this.props.name] || ""; + } + + onInput(ev) { + this.debouncedCommit(ev.target.value); + } +} + +export const searchDelayField = { + component: SearchDelayField, + displayName: _t("Search with Delay"), + supportedTypes: ["char"], + extractProps: ({placeholder}) => ({placeholder}), +}; + +registry.category("fields").add("search_delay", searchDelayField); diff --git a/spp_change_request_v2/static/src/xml/cr_search_results_field.xml b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml new file mode 100644 index 0000000..d22a127 --- /dev/null +++ b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/spp_change_request_v2/static/src/xml/search_delay_field.xml b/spp_change_request_v2/static/src/xml/search_delay_field.xml new file mode 100644 index 0000000..fd21961 --- /dev/null +++ b/spp_change_request_v2/static/src/xml/search_delay_field.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/spp_change_request_v2/views/change_request_views.xml b/spp_change_request_v2/views/change_request_views.xml index 8dd5067..e97c07b 100644 --- a/spp_change_request_v2/views/change_request_views.xml +++ b/spp_change_request_v2/views/change_request_views.xml @@ -184,15 +184,6 @@
-
diff --git a/spp_change_request_v2/views/create_wizard_views.xml b/spp_change_request_v2/views/create_wizard_views.xml index 3cf16a8..f52e477 100644 --- a/spp_change_request_v2/views/create_wizard_views.xml +++ b/spp_change_request_v2/views/create_wizard_views.xml @@ -30,25 +30,34 @@ - + - + + + - + + - + - -
+ +
+ +
+ + +
+
diff --git a/spp_change_request_v2/views/detail_add_member_views.xml b/spp_change_request_v2/views/detail_add_member_views.xml index d64c989..9d0e362 100644 --- a/spp_change_request_v2/views/detail_add_member_views.xml +++ b/spp_change_request_v2/views/detail_add_member_views.xml @@ -8,15 +8,15 @@
""").format(reason) + # ══════════════════════════════════════════════════════════════════════════ + # SEARCH ACTIONS + # ══════════════════════════════════════════════════════════════════════════ + + @api.onchange("_selected_partner_id") + def _onchange_selected_partner(self): + """Convert the bridge integer to a Many2one registrant_id.""" + if self._selected_partner_id: + self.registrant_id = self.env["res.partner"].browse( + self._selected_partner_id + ) + + _SEARCH_PAGE_SIZE = 10 + + @api.onchange("search_text") + def _onchange_search_text(self): + """Reset page and run search when text changes.""" + self.search_results_html = False + self.registrant_id = False + self._search_page = 0 + + if not self.search_text or len(self.search_text) < 2: + return + + self._render_search_results() + + @api.onchange("_search_page") + def _onchange_search_page(self): + """Re-render results when page changes.""" + if self.search_text and len(self.search_text) >= 2: + self._render_search_results() + + def _get_search_domain(self): + """Build the search domain based on search text and target type.""" + domain = [("is_registrant", "=", True)] + if self.request_type_id and self.request_type_id.target_type: + target = self.request_type_id.target_type + if target == "individual": + domain.append(("is_group", "=", False)) + elif target == "group": + domain.append(("is_group", "=", True)) + return domain + [ + "|", + ("name", "ilike", self.search_text), + ("reg_ids.value", "ilike", self.search_text), + ] + + def _render_search_results(self): + """Search and render paginated HTML results.""" + search_domain = self._get_search_domain() + total = self.env["res.partner"].search_count(search_domain) + + if not total: + self.search_results_html = Markup( + "

No registrants found.

" + ) + return + + page = self._search_page or 0 + page_size = self._SEARCH_PAGE_SIZE + max_page = (total - 1) // page_size + page = min(page, max_page) + + offset = page * page_size + partners = self.env["res.partner"].search( + search_domain, limit=page_size, offset=offset + ) + + rows = [] + for p in partners: + # Build ALL IDs in "TypeName (value)" format, show max 2 + id_parts = [] + if p.reg_ids: + for rid in p.reg_ids: + if rid.value: + label = rid.id_type_as_str or "ID" + id_parts.append(f"{label} ({rid.value})") + if not id_parts: + id_html = Markup("") + id_title = "" + elif len(id_parts) <= 2: + id_html = escape(", ".join(id_parts)) + id_title = "" + else: + visible = escape(", ".join(id_parts[:2])) + extra = len(id_parts) - 2 + id_html = Markup( + '{} ' + "+{} " + ).format(visible, extra) + id_title = ", ".join(id_parts) + ptype = ( + ' Group' + if p.is_group + else ' Individual' + ) + rows.append( + Markup( + '' + "{}" + '{}' + "{}" + ).format( + p.id, + escape(p.name or ""), + escape(p.name or ""), + escape(id_title), + id_html, + Markup(ptype), + ) + ) + + table = Markup( + '' + "" + "{}
NameIDType
" + ).format(Markup("").join(rows)) + + # Pagination header + start = offset + 1 + end = min(offset + page_size, total) + prev_cls = "text-muted" if page == 0 else "o_cr_page_prev" + next_cls = "text-muted" if page >= max_page else "o_cr_page_next" + pagination = Markup( + '
' + '{}-{} of {}' + "
" + '← Previous' + 'Next →' + "
" + ).format(start, end, total, prev_cls, page - 1, next_cls, page + 1) + + self.search_results_html = pagination + table + + def action_clear_registrant(self): + """Clear selected registrant, re-run search, and reopen wizard.""" + self.ensure_one() + self._selected_partner_id = False + self.registrant_id = False + # Re-run search with existing search_text to repopulate results + self._onchange_search_text() + return { + "type": "ir.actions.act_window", + "name": _("New Change Request"), + "res_model": "spp.cr.create.wizard", + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + # ══════════════════════════════════════════════════════════════════════════ # MAIN ACTIONS # ══════════════════════════════════════════════════════════════════════════ @@ -277,7 +438,8 @@ def action_cancel(self): @api.onchange("request_type_id") def _onchange_request_type(self): - """Clear registrant if it doesn't match the new target type.""" + """Clear registrant and search if type changes.""" + self.search_text = False if self.request_type_id and self.registrant_id: target = self.request_type_id.target_type is_group = self.registrant_id.is_group