feat(portal): pulse animation, repeat-order button, 5-panel dashboard
1. Pulse animation on the active step indicator:
- New @keyframes fp-pulse-teal / fp-pulse-amber in stepper.scss
- Applied to .o_fp_step_active / _warn and .o_fp_timeline_active
.o_fp_timeline_dot so dashboard stepper + detail-page timeline
breathe in sync. 1.8s ease-in-out, ring grows 4px -> 9px and
fades 20% -> 6% opacity. Two color variants so QC (warn) keeps
its amber meaning.
- prefers-reduced-motion: reduce kills the animation for users
who opted out.
2. Repeat Order button on /my/jobs/<id> detail page:
- New POST /my/jobs/<id>/repeat route that creates a draft
fusion.plating.quote.request seeded with the user's contact +
the job's quantity, posts a chatter link back to the original
job, redirects to the new RFQ for review/submit.
- Button placed in the detail footer next to 'Back to all jobs',
CSRF-protected via the form's csrf_token hidden field.
3. Dashboard expanded from 3 secondary panels to 5 (Recent Quote
Requests + Recent Purchase Orders added) so every previously-
designed customer page is reachable from /my/home.
- Auto-fit grid: 3+2 / 2+2+1 / single column depending on width.
- Every panel header gets a 'View all ->' link to its list page
(Quote Requests / POs / Certs / Deliveries / Invoices).
- Empty-state for Quote Requests gets an inline 'Get a quote ->'
CTA so first-time customers know where to start.
Version bump: 19.0.3.4.0 -> 19.0.3.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.3.4.0',
|
||||
'version': '19.0.3.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||
'CoC downloads, invoice access.',
|
||||
|
||||
@@ -891,6 +891,50 @@ class FpCustomerPortal(CustomerPortal):
|
||||
],
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# JOBS -- repeat order (clone into a new draft quote_request)
|
||||
# ==========================================================================
|
||||
# Customer-initiated "order again". Creates a draft fusion.plating.quote.
|
||||
# request pre-filled with the user's contact info + job's quantity, then
|
||||
# redirects to the new RFQ so the customer can adjust + submit. EN
|
||||
# Plating then prices it via the normal quote workflow.
|
||||
#
|
||||
# POST-only so a stray browser prefetch can't accidentally spawn RFQs.
|
||||
@http.route(
|
||||
['/my/jobs/<int:job_id>/repeat'],
|
||||
type='http',
|
||||
auth='user',
|
||||
methods=['POST'],
|
||||
website=True,
|
||||
csrf=True,
|
||||
)
|
||||
def portal_repeat_order(self, job_id, access_token=None, **kw):
|
||||
try:
|
||||
job_sudo = self._document_check_access(
|
||||
'fusion.plating.portal.job',
|
||||
job_id,
|
||||
access_token,
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
user = request.env.user
|
||||
Quote = request.env['fusion.plating.quote.request'].sudo()
|
||||
new_quote = Quote.create({
|
||||
'partner_id': user.partner_id.id,
|
||||
'contact_name': user.name,
|
||||
'contact_email': user.email or '',
|
||||
'contact_phone': user.partner_id.phone or '',
|
||||
'quantity': job_sudo.quantity or 1,
|
||||
'state': 'new',
|
||||
# name auto-generated by sequence
|
||||
})
|
||||
# Cross-link via chatter so EN Plating's estimator sees the origin.
|
||||
new_quote.message_post(
|
||||
body=_('Repeat order requested from portal job %s') % job_sudo.name,
|
||||
)
|
||||
return request.redirect('/my/quote_requests/%s' % new_quote.id)
|
||||
|
||||
# ==========================================================================
|
||||
# PURCHASE ORDERS -- list
|
||||
# ==========================================================================
|
||||
|
||||
@@ -148,13 +148,11 @@
|
||||
|
||||
.o_fp_secondary_panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
// Auto-fit so 5 panels arrange nicely as 3+2 / 2+2+1 / 1 column at
|
||||
// various widths instead of overflowing or cramping.
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: $fp-space-3;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.o_fp_panel {
|
||||
@extend .o_fp_card_compact;
|
||||
|
||||
@@ -178,6 +176,14 @@
|
||||
justify-content: center;
|
||||
font-size: .78rem;
|
||||
}
|
||||
.o_fp_panel_view_all {
|
||||
margin-left: auto;
|
||||
font-size: .7rem;
|
||||
color: $fp-teal;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
&:hover { color: $fp-teal-dark; }
|
||||
}
|
||||
}
|
||||
.o_fp_panel_row {
|
||||
font-size: .72rem;
|
||||
@@ -185,5 +191,12 @@
|
||||
margin-top: .2rem;
|
||||
&:first-of-type { margin-top: 0; }
|
||||
}
|
||||
.o_fp_panel_inline_cta {
|
||||
margin-left: .35rem;
|
||||
color: $fp-teal;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
&:hover { color: $fp-teal-dark; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
color: $fp-teal;
|
||||
border: 2.5px solid $fp-teal;
|
||||
box-shadow: $fp-glow-ring-teal;
|
||||
animation: fp-pulse-teal 1.8s ease-in-out infinite;
|
||||
}
|
||||
.o_fp_step_active_warn {
|
||||
// Used when the active step is in QC (amber)
|
||||
@@ -43,6 +44,30 @@
|
||||
color: $fp-amber-text;
|
||||
border: 2.5px solid $fp-amber;
|
||||
box-shadow: $fp-glow-ring-amber;
|
||||
animation: fp-pulse-amber 1.8s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Pulsing glow for the active step indicator. Kept subtle - the ring
|
||||
// breathes in width + fades; the inner dot stays still. Two color
|
||||
// variants (teal for normal flow, amber for QC) so the warn state
|
||||
// retains its meaning. Defined here, used in both fp_portal_stepper.scss
|
||||
// and fp_portal_timeline.scss.
|
||||
@keyframes fp-pulse-teal {
|
||||
0%, 100% { box-shadow: 0 0 0 4px rgba(46, 175, 147, 0.20); }
|
||||
50% { box-shadow: 0 0 0 9px rgba(46, 175, 147, 0.06); }
|
||||
}
|
||||
@keyframes fp-pulse-amber {
|
||||
0%, 100% { box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.20); }
|
||||
50% { box-shadow: 0 0 0 9px rgba(245, 158, 11, 0.06); }
|
||||
}
|
||||
|
||||
// Accessibility: kill the animation for users who've opted out of motion.
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.o_fp_step_active,
|
||||
.o_fp_step_active_warn,
|
||||
.o_fp_timeline_active .o_fp_timeline_dot {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.o_fp_step_line {
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
background: $fp-card-bg;
|
||||
border: 2.5px solid $fp-teal;
|
||||
box-shadow: $fp-glow-ring-teal;
|
||||
// Pulsing glow defined in fp_portal_stepper.scss (@keyframes
|
||||
// fp-pulse-teal) - reused here so the active timeline dot
|
||||
// breathes in sync with the stepper circle on /my/home.
|
||||
animation: fp-pulse-teal 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.o_fp_timeline_title {
|
||||
|
||||
@@ -128,9 +128,52 @@
|
||||
|
||||
<!-- Secondary panels -->
|
||||
<div class="o_fp_secondary_panels">
|
||||
<!-- Quote Requests -->
|
||||
<div class="o_fp_panel">
|
||||
<div class="o_fp_panel_title">
|
||||
<span class="o_fp_panel_icon">📄</span> Recent Quote Requests
|
||||
<a href="/my/quote_requests" class="o_fp_panel_view_all">View all →</a>
|
||||
</div>
|
||||
<t t-if="recent_quotes">
|
||||
<t t-foreach="recent_quotes[:3]" t-as="qr">
|
||||
<div class="o_fp_panel_row">
|
||||
<a t-att-href="'/my/quote_requests/%s' % qr.id" class="text-decoration-none" t-out="qr.name"/>
|
||||
<t t-if="qr.create_date"> · <span t-field="qr.create_date" t-options='{"widget": "date"}'/></t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_panel_row text-muted">
|
||||
No quotes yet.
|
||||
<a href="/my/configurator" class="o_fp_panel_inline_cta">Get a quote →</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Orders -->
|
||||
<div class="o_fp_panel">
|
||||
<div class="o_fp_panel_title">
|
||||
<span class="o_fp_panel_icon">🛒</span> Recent Purchase Orders
|
||||
<a href="/my/purchase_orders" class="o_fp_panel_view_all">View all →</a>
|
||||
</div>
|
||||
<t t-if="recent_pos">
|
||||
<t t-foreach="recent_pos[:3]" t-as="po">
|
||||
<div class="o_fp_panel_row">
|
||||
<span t-out="po.name"/>
|
||||
<t t-if="po.amount_total"> · <span t-field="po.amount_total" t-options='{"widget": "monetary", "display_currency": po.currency_id}'/></t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_panel_row text-muted">No purchase orders yet.</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Certifications -->
|
||||
<div class="o_fp_panel">
|
||||
<div class="o_fp_panel_title">
|
||||
<span class="o_fp_panel_icon">📑</span> Recent Certifications
|
||||
<a href="/my/certifications" class="o_fp_panel_view_all">View all →</a>
|
||||
</div>
|
||||
<t t-if="recent_certs">
|
||||
<t t-foreach="recent_certs[:3]" t-as="cert">
|
||||
@@ -146,9 +189,12 @@
|
||||
<div class="o_fp_panel_row text-muted">No certifications yet.</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Packing Slips / Deliveries -->
|
||||
<div class="o_fp_panel">
|
||||
<div class="o_fp_panel_title">
|
||||
<span class="o_fp_panel_icon">📦</span> Recent Packing Slips
|
||||
<a href="/my/deliveries" class="o_fp_panel_view_all">View all →</a>
|
||||
</div>
|
||||
<t t-if="recent_deliveries">
|
||||
<t t-foreach="recent_deliveries[:3]" t-as="d">
|
||||
@@ -162,9 +208,12 @@
|
||||
<div class="o_fp_panel_row text-muted">No deliveries yet.</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Invoices -->
|
||||
<div class="o_fp_panel">
|
||||
<div class="o_fp_panel_title">
|
||||
<span class="o_fp_panel_icon">💰</span> Recent Invoices
|
||||
<a href="/my/fp_invoices" class="o_fp_panel_view_all">View all →</a>
|
||||
</div>
|
||||
<t t-if="recent_invoices">
|
||||
<t t-foreach="recent_invoices[:3]" t-as="inv">
|
||||
|
||||
@@ -566,7 +566,16 @@
|
||||
<a t-if="job.invoice_ref" href="#" t-out="'Invoice ' + job.invoice_ref"/>
|
||||
<a t-else="" class="disabled">Invoice (pending)</a>
|
||||
</div>
|
||||
<a href="/my/jobs" class="o_fp_btn_secondary">← Back to all jobs</a>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<!-- POST-only form so the action is intentional -->
|
||||
<form t-attf-action="/my/jobs/#{job.id}/repeat" method="post" class="m-0">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="o_fp_btn_primary">
|
||||
<i class="fa fa-repeat"/> Repeat Order
|
||||
</button>
|
||||
</form>
|
||||
<a href="/my/jobs" class="o_fp_btn_secondary">← Back to all jobs</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user