feat(portal): real-time search + filter pills on 4 FP list pages

Replaces the tab nav / portal.portal_searchbar on the 4 FP list
pages with the new fp_portal_list_controls macro (filter pills +
search input + sort dropdown) and drops portal_pager in favour of
client-side filtering of up to 500 records:

- Quote Requests (/my/quote_requests):
    filters: All / Active / Converted / Declined
    sorts:   Newest / Reference / Status
    extra search fields: contact_name, contact_email, line.part_number,
                         line.description, line.product_id.default_code

- Work Orders (/my/jobs, cards layout):
    filters: All / Active / Ready to Ship / Complete
    sorts:   Newest / Reference / Status
    extra search fields per card: part_catalog.part_number, part_catalog.name,
                                  sale_order.name, sale_order.client_order_ref,
                                  job.notes

- Certifications (/my/certifications):
    no filters (all rows are terminal CoC jobs)
    sorts:   Newest / Reference
    extra search fields: part name, processes (already in card text)

- Packing Slips / Deliveries (/my/deliveries):
    no filters (all rows are state=done)
    sorts:   Newest / Reference
    adds a visible Origin column (sale order ref) so customers can
    locate a slip by the SO it came from

Each route accepts ?filter_state=... and ?sortby=... query params,
returns up to 500 records, and passes result_total + clipped to the
template so the macro can render a "showing latest 500 of N" notice
when the cap is hit.

Hidden <td class="d-none"> cells inside each row carry extra terms
that aren't displayed but are matched by the JS textContent scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-18 00:06:18 -04:00
parent d9bdbd8e18
commit b27f68b8d5
2 changed files with 251 additions and 135 deletions

View File

@@ -697,72 +697,70 @@ class FpCustomerPortal(CustomerPortal):
)
# ==========================================================================
# QUOTE REQUESTS -- list with tabs
# QUOTE REQUESTS -- list with filter pills + real-time search
# ==========================================================================
@http.route(
['/my/quote_requests', '/my/quote_requests/page/<int:page>'],
['/my/quote_requests'],
type='http',
auth='user',
website=True,
)
def portal_my_quote_requests(self, page=1, sortby=None, filterby=None, **kw):
def portal_my_quote_requests(self, sortby=None, filter_state=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
Quote = request.env['fusion.plating.quote.request']
domain = [('partner_id', 'child_of', commercial.id)]
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'create_date desc'},
'name': {'label': _('Reference'), 'order': 'name desc'},
'state': {'label': _('Status'), 'order': 'state'},
_sort_map = {
'date': 'create_date desc',
'name': 'name asc',
'state': 'state asc',
}
if not sortby:
if sortby not in _sort_map:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
order = _sort_map[sortby]
# Tab filters
searchbar_filters = {
'all': {'label': _('All'), 'domain': []},
'active': {'label': _('Active'), 'domain': [
('state', 'in', ['new', 'under_review', 'quoted']),
]},
'converted': {'label': _('Converted'), 'domain': [
('state', '=', 'accepted'),
]},
'declined': {'label': _('Declined'), 'domain': [
('state', 'in', ['declined', 'expired']),
]},
# Filter pills
_filter_map = {
'all': [],
'active': [('state', 'in', ['new', 'under_review', 'quoted'])],
'converted': [('state', '=', 'accepted')],
'declined': [('state', 'in', ['declined', 'expired'])],
}
if not filterby or filterby not in searchbar_filters:
filterby = 'all'
domain += searchbar_filters[filterby]['domain']
if filter_state not in _filter_map:
filter_state = 'all'
domain += _filter_map[filter_state]
total = Quote.search_count(domain)
pager = portal_pager(
url='/my/quote_requests',
url_args={'sortby': sortby, 'filterby': filterby},
total=total,
page=page,
step=self._items_per_page,
)
quote_requests = Quote.search(
domain,
order=order,
limit=self._items_per_page,
offset=pager['offset'],
)
total_count = Quote.search_count(domain)
cap = 500
clipped = total_count > cap
quote_requests = Quote.search(domain, order=order, limit=cap)
request.session['my_fp_quote_requests_history'] = quote_requests.ids[:100]
values = self._prepare_portal_layout_values()
values.update({
'quote_requests': quote_requests,
'page_name': 'fp_quote_requests',
'pager': pager,
'default_url': '/my/quote_requests',
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
'searchbar_filters': searchbar_filters,
'filterby': filterby,
'filter_state': filter_state,
'filters': [
('all', 'All'),
('active', 'Active'),
('converted', 'Converted'),
('declined', 'Declined'),
],
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
('state', 'Status'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/quote_requests',
'extra_qs': '',
'target': 'tbody.o_fp_qr_filterable',
})
return request.render(
'fusion_plating_portal.portal_my_quote_requests',
@@ -956,15 +954,15 @@ class FpCustomerPortal(CustomerPortal):
return request.redirect('/my/quote_requests/%s' % quote.id)
# ==========================================================================
# JOBS -- list
# JOBS -- list with filter pills + real-time search (cards layout)
# ==========================================================================
@http.route(
['/my/jobs', '/my/jobs/page/<int:page>'],
['/my/jobs'],
type='http',
auth='user',
website=True,
)
def portal_my_jobs(self, page=1, sortby=None, **kw):
def portal_my_jobs(self, sortby=None, filter_state=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# sudo() so the rendered cards can traverse job.x_fc_job_id -> fp.job
@@ -974,39 +972,56 @@ class FpCustomerPortal(CustomerPortal):
Job = request.env['fusion.plating.portal.job'].sudo()
domain = [('partner_id', 'child_of', commercial.id)]
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'received_date desc, id desc'},
'name': {'label': _('Reference'), 'order': 'name desc'},
'state': {'label': _('Status'), 'order': 'state'},
_sort_map = {
'date': 'received_date desc, id desc',
'name': 'name asc',
'state': 'state asc',
}
if not sortby:
if sortby not in _sort_map:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
order = _sort_map[sortby]
total = Job.search_count(domain)
pager = portal_pager(
url='/my/jobs',
url_args={'sortby': sortby},
total=total,
page=page,
step=self._items_per_page,
)
jobs = Job.search(
domain,
order=order,
limit=self._items_per_page,
offset=pager['offset'],
)
# Filter pills
_filter_map = {
'all': [],
'active': [('state', 'in', ['received', 'in_progress', 'quality_check'])],
'ready': [('state', '=', 'ready_to_ship')],
'complete': [('state', 'in', ['shipped', 'complete'])],
}
if filter_state not in _filter_map:
filter_state = 'all'
domain += _filter_map[filter_state]
total_count = Job.search_count(domain)
cap = 500
clipped = total_count > cap
jobs = Job.search(domain, order=order, limit=cap)
request.session['my_fp_jobs_history'] = jobs.ids[:100]
values = self._prepare_portal_layout_values()
values.update({
'jobs': jobs,
'page_name': 'fp_jobs',
'pager': pager,
'default_url': '/my/jobs',
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
'filter_state': filter_state,
'filters': [
('all', 'All'),
('active', 'Active'),
('ready', 'Ready to Ship'),
('complete', 'Complete'),
],
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
('state', 'Status'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/jobs',
'extra_qs': '',
'target': '#fp_jobs_list',
})
return request.render(
'fusion_plating_portal.portal_my_jobs',
@@ -1239,15 +1254,15 @@ class FpCustomerPortal(CustomerPortal):
return request.redirect('/my/account_summary')
# ==========================================================================
# SHIPPING / DELIVERIES -- list
# SHIPPING / DELIVERIES -- list with search + sort
# ==========================================================================
@http.route(
['/my/deliveries', '/my/deliveries/page/<int:page>'],
['/my/deliveries'],
type='http',
auth='user',
website=True,
)
def portal_my_deliveries(self, page=1, sortby=None, **kw):
def portal_my_deliveries(self, sortby=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
Picking = request.env['stock.picking'].sudo()
@@ -1257,37 +1272,37 @@ class FpCustomerPortal(CustomerPortal):
('state', '=', 'done'),
]
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'date_done desc'},
'name': {'label': _('Reference'), 'order': 'name desc'},
_sort_map = {
'date': 'date_done desc',
'name': 'name asc',
}
if not sortby:
if sortby not in _sort_map:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
order = _sort_map[sortby]
total = Picking.search_count(domain)
pager = portal_pager(
url='/my/deliveries',
url_args={'sortby': sortby},
total=total,
page=page,
step=self._items_per_page,
)
deliveries = Picking.search(
domain,
order=order,
limit=self._items_per_page,
offset=pager['offset'],
)
total_count = Picking.search_count(domain)
cap = 500
clipped = total_count > cap
deliveries = Picking.search(domain, order=order, limit=cap)
values = self._prepare_portal_layout_values()
values.update({
'deliveries': deliveries,
'page_name': 'fp_deliveries',
'pager': pager,
'default_url': '/my/deliveries',
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
'filter_state': 'all',
'filters': False,
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/deliveries',
'extra_qs': '',
'target': 'tbody.o_fp_deliveries_filterable',
})
return request.render(
'fusion_plating_portal.portal_my_deliveries',
@@ -1295,15 +1310,15 @@ class FpCustomerPortal(CustomerPortal):
)
# ==========================================================================
# CERTIFICATIONS -- list
# CERTIFICATIONS -- list with search + sort
# ==========================================================================
@http.route(
['/my/certifications', '/my/certifications/page/<int:page>'],
['/my/certifications'],
type='http',
auth='user',
website=True,
)
def portal_my_certifications(self, page=1, sortby=None, **kw):
def portal_my_certifications(self, sortby=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# sudo() so the rendered cards can traverse job.x_fc_job_id -> fp.job
@@ -1316,37 +1331,37 @@ class FpCustomerPortal(CustomerPortal):
('coc_attachment_id', '!=', False),
]
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'actual_ship_date desc, id desc'},
'name': {'label': _('Job Reference'), 'order': 'name desc'},
_sort_map = {
'date': 'actual_ship_date desc, id desc',
'name': 'name asc',
}
if not sortby:
if sortby not in _sort_map:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
order = _sort_map[sortby]
total = Job.search_count(domain)
pager = portal_pager(
url='/my/certifications',
url_args={'sortby': sortby},
total=total,
page=page,
step=self._items_per_page,
)
cert_jobs = Job.search(
domain,
order=order,
limit=self._items_per_page,
offset=pager['offset'],
)
total_count = Job.search_count(domain)
cap = 500
clipped = total_count > cap
cert_jobs = Job.search(domain, order=order, limit=cap)
values = self._prepare_portal_layout_values()
values.update({
'cert_jobs': cert_jobs,
'page_name': 'fp_certifications',
'pager': pager,
'default_url': '/my/certifications',
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
'filter_state': 'all',
'filters': False,
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/certifications',
'extra_qs': '',
'target': 'tbody.o_fp_certs_filterable',
})
return request.render(
'fusion_plating_portal.portal_my_certifications',

View File

@@ -7,7 +7,7 @@
<odoo>
<!-- ================================================================== -->
<!-- QUOTE REQUESTS — list with tabs (Active / Converted / Declined) -->
<!-- QUOTE REQUESTS — list with filter pills + real-time search -->
<!-- ================================================================== -->
<template id="portal_my_quote_requests" name="My Quote Requests">
<t t-call="portal.portal_layout">
@@ -15,15 +15,19 @@
<t t-set="title">Quote Requests</t>
</t>
<!-- Tab navigation -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item" t-foreach="searchbar_filters" t-as="f">
<a t-attf-class="nav-link #{'active' if filterby == f else ''}"
t-attf-href="/my/quote_requests?filterby=#{f}&amp;sortby=#{sortby}">
<t t-out="searchbar_filters[f]['label']"/>
</a>
</li>
</ul>
<!-- Filter pills + search + sort strip -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<div class="d-flex justify-content-end mb-3">
<a href="/my/quote_requests/new" class="o_fp_btn_primary">
@@ -49,7 +53,7 @@
<th class="text-end">Status</th>
</tr>
</thead>
<tbody>
<tbody class="o_fp_qr_filterable">
<tr t-foreach="quote_requests" t-as="qr">
<td>
<a t-att-href="'/my/quote_requests/%s' % qr.id"
@@ -73,6 +77,16 @@
<t t-set="label" t-value="dict(qr._fields['state']._description_selection(qr.env)).get(qr.state)"/>
</t>
</td>
<!-- Hidden search fields: contact, part numbers, descriptions -->
<td class="d-none" aria-hidden="true">
<span t-out="qr.contact_name or ''"/>
<span t-out="qr.contact_email or ''"/>
<t t-foreach="qr.line_ids" t-as="ln">
<span t-out="ln.part_number or ''"/>
<span t-out="ln.description or ''"/>
<span t-if="ln.product_id" t-out="ln.product_id.default_code or ''"/>
</t>
</td>
</tr>
</tbody>
</t>
@@ -417,7 +431,7 @@
</template>
<!-- ================================================================== -->
<!-- JOBS — list with segmented progress bars -->
<!-- JOBS — list with filter pills + real-time search (cards layout) -->
<!-- ================================================================== -->
<template id="portal_my_jobs" name="My Work Orders">
<t t-call="portal.portal_layout">
@@ -425,6 +439,20 @@
<t t-set="title">Work Orders</t>
</t>
<!-- Filter pills + search + sort strip -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<t t-if="not jobs">
<div class="o_fp_card text-center text-muted">
<p class="mb-2">You have no plating jobs yet.</p>
@@ -432,11 +460,32 @@
</div>
</t>
<t t-if="jobs">
<div class="o_fp_dashboard">
<!-- id="fp_jobs_list" is the data-fp-target for the search JS -->
<div class="o_fp_dashboard" id="fp_jobs_list">
<t t-foreach="jobs" t-as="job">
<t t-call="fusion_plating_portal.fp_portal_job_card">
<t t-set="job" t-value="job"/>
</t>
<!-- Wrapper div is the filterable row unit.
Hidden span carries extra search terms that
are not visible in the card UI. -->
<div class="o_fp_job_card_wrap">
<t t-call="fusion_plating_portal.fp_portal_job_card">
<t t-set="job" t-value="job"/>
</t>
<!-- Extra hidden search terms for this card -->
<t t-set="_backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
<t t-set="_so" t-value="_backend_job.sale_order_id if _backend_job and 'sale_order_id' in _backend_job._fields else False"/>
<t t-set="_part" t-value="_backend_job.part_catalog_id if _backend_job and 'part_catalog_id' in _backend_job._fields else False"/>
<span class="d-none" aria-hidden="true">
<t t-if="_part">
<t t-out="_part.part_number or ''"/>
<t t-out="_part.name or ''"/>
</t>
<t t-if="_so">
<t t-out="_so.name or ''"/>
<t t-out="_so.client_order_ref or ''"/>
</t>
<t t-out="job.notes or ''"/>
</span>
</div>
</t>
</div>
</t>
@@ -574,7 +623,7 @@
</template>
<!-- ================================================================== -->
<!-- DELIVERIES / PACKING SLIPS — list -->
<!-- DELIVERIES / PACKING SLIPS — list with search + sort -->
<!-- ================================================================== -->
<template id="portal_my_deliveries" name="My Deliveries">
<t t-call="portal.portal_layout">
@@ -582,6 +631,20 @@
<t t-set="title">Packing Slips / Deliveries</t>
</t>
<!-- Search + sort strip (no filter pills — all rows are delivered) -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<t t-if="not deliveries">
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
<p class="text-muted mb-0">No deliveries found.</p>
@@ -591,13 +654,17 @@
<thead>
<tr class="active">
<th>Reference</th>
<th>Origin</th>
<th>Date</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody>
<tbody class="o_fp_deliveries_filterable">
<tr t-foreach="deliveries" t-as="dlv">
<td t-out="dlv.name"/>
<td>
<span t-out="dlv.origin or ''"/>
</td>
<td>
<span t-if="dlv.date_done"
t-field="dlv.date_done"
@@ -608,6 +675,13 @@
<span class="o_fp_badge_dot"/>Delivered
</span>
</td>
<!-- Hidden: partner ref / customer PO on the origin SO -->
<td class="d-none" aria-hidden="true">
<t t-if="dlv.sale_id">
<span t-out="dlv.sale_id.name or ''"/>
<span t-out="dlv.sale_id.client_order_ref or ''"/>
</t>
</td>
</tr>
</tbody>
</t>
@@ -615,7 +689,7 @@
</template>
<!-- ================================================================== -->
<!-- CERTIFICATIONS — list -->
<!-- CERTIFICATIONS — list with search + sort -->
<!-- ================================================================== -->
<template id="portal_my_certifications" name="My Certifications">
<t t-call="portal.portal_layout">
@@ -623,6 +697,20 @@
<t t-set="title">Certifications &amp; Quality</t>
</t>
<!-- Search + sort strip (no filter pills — all certs are terminal) -->
<t t-call="fusion_plating_portal.fp_portal_list_controls">
<t t-set="filters" t-value="filters"/>
<t t-set="active_filter" t-value="filter_state"/>
<t t-set="sorts" t-value="sorts"/>
<t t-set="active_sort" t-value="sortby"/>
<t t-set="search" t-value="search"/>
<t t-set="url" t-value="url"/>
<t t-set="extra_qs" t-value="extra_qs"/>
<t t-set="target" t-value="target"/>
<t t-set="result_total" t-value="result_total"/>
<t t-set="clipped" t-value="clipped"/>
</t>
<t t-if="not cert_jobs">
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
<p class="text-muted mb-0">No certificates available yet.</p>
@@ -637,7 +725,7 @@
<th class="text-end">Download</th>
</tr>
</thead>
<tbody>
<tbody class="o_fp_certs_filterable">
<tr t-foreach="cert_jobs" t-as="cj">
<td>
<a t-att-href="'/my/jobs/%s' % cj.id" t-out="cj.name"/>
@@ -658,6 +746,19 @@
<i class="fa fa-download"/> CoC
</a>
</td>
<!-- Hidden: part name, customer PO from the backend job -->
<td class="d-none" aria-hidden="true">
<t t-set="_bj" t-value="cj.x_fc_job_id if 'x_fc_job_id' in cj._fields else False"/>
<t t-set="_so" t-value="_bj.sale_order_id if _bj and 'sale_order_id' in _bj._fields else False"/>
<t t-set="_part" t-value="_bj.part_catalog_id if _bj and 'part_catalog_id' in _bj._fields else False"/>
<t t-if="_part">
<span t-out="_part.part_number or ''"/>
<span t-out="_part.name or ''"/>
</t>
<t t-if="_so">
<span t-out="_so.client_order_ref or ''"/>
</t>
</td>
</tr>
</tbody>
</t>