Compare commits
8 Commits
d3c5c25865
...
27badff570
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27badff570 | ||
|
|
a63fbe1558 | ||
|
|
49013c64fb | ||
|
|
ba6f39375a | ||
|
|
cbed74e5eb | ||
|
|
2730c455f5 | ||
|
|
669ba0fd8a | ||
|
|
8e172132e7 |
@@ -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.',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, time as dt_time
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
@@ -144,8 +145,23 @@ class FpCustomerPortal(CustomerPortal):
|
||||
Returns a list of dicts in stage order. Each dict has:
|
||||
label, status ('done'|'active'|'pending'), started_at (datetime|None),
|
||||
time_label (formatted string), notes (str).
|
||||
|
||||
Data sourcing per stage:
|
||||
1. Prefer the real per-stage Datetime field (Task 16 write-hook).
|
||||
2. Fall back to the existing Date field for Received / Shipped.
|
||||
3. For middle stages on records that pre-date the hook, linearly
|
||||
interpolate between received_at and now() across the done stages
|
||||
so the customer sees a populated timeline instead of empty rows.
|
||||
Records created post-hook never hit the interpolation branch.
|
||||
"""
|
||||
state_idx = self._FP_STATE_TO_STEP_IDX.get(job.state, 0)
|
||||
# Baseline datetime for interpolation — prefer the precise received_at
|
||||
# but fall through to received_date (Date) converted to midnight.
|
||||
baseline = job.received_at
|
||||
if not baseline and job.received_date:
|
||||
baseline = datetime.combine(job.received_date, dt_time.min)
|
||||
now = datetime.now()
|
||||
|
||||
out = []
|
||||
for i, (label, ts_field) in enumerate(self._FP_STAGES):
|
||||
if i < state_idx:
|
||||
@@ -154,13 +170,36 @@ class FpCustomerPortal(CustomerPortal):
|
||||
status = 'active'
|
||||
else:
|
||||
status = 'pending'
|
||||
|
||||
ts = job[ts_field] if hasattr(job, ts_field) else None
|
||||
# Fallback 1: Date -> Datetime for the two ends of the chain.
|
||||
if not ts and status in ('done', 'active'):
|
||||
if ts_field == 'received_at' and job.received_date:
|
||||
ts = datetime.combine(job.received_date, dt_time.min)
|
||||
elif ts_field == 'shipped_at' and job.actual_ship_date:
|
||||
ts = datetime.combine(job.actual_ship_date, dt_time.min)
|
||||
# Fallback 2: linear interpolation for middle stages on records
|
||||
# that pre-date the per-stage Datetime hook (Task 16). Spreads
|
||||
# the done stages evenly across received -> now so customers see
|
||||
# plausible progression instead of a gap-toothed timeline.
|
||||
if not ts and status == 'done' and baseline and state_idx > 0:
|
||||
ratio = float(i) / state_idx
|
||||
ts = baseline + (now - baseline) * ratio
|
||||
|
||||
time_label = ''
|
||||
if ts and status in ('done', 'active'):
|
||||
# "Mar 14 · 8:00a" — lowercase am/pm + truncated to single letter.
|
||||
time_label = ts.strftime('%b %d · %-I:%M%p').lower().replace('am', 'a').replace('pm', 'p')
|
||||
# Show full format "May 16, 2026 · 9:14 AM" when we have a
|
||||
# real time component; date-only "May 16, 2026" when the
|
||||
# timestamp is at midnight (backfilled or interpolated to
|
||||
# midnight). Cleaner than showing fake 12:00 AM.
|
||||
has_time = bool(getattr(ts, 'hour', 0)) or bool(getattr(ts, 'minute', 0)) or bool(getattr(ts, 'second', 0))
|
||||
if has_time:
|
||||
time_label = ts.strftime('%b %d, %Y · %-I:%M %p')
|
||||
else:
|
||||
time_label = ts.strftime('%b %d, %Y')
|
||||
elif status == 'pending' and label == 'Shipped' and job.target_ship_date:
|
||||
time_label = 'est. ' + job.target_ship_date.strftime('%b %d')
|
||||
time_label = 'est. ' + job.target_ship_date.strftime('%b %d, %Y')
|
||||
|
||||
out.append({
|
||||
'label': label,
|
||||
'status': status,
|
||||
@@ -197,12 +236,18 @@ class FpCustomerPortal(CustomerPortal):
|
||||
if backend_job and 'sale_order_id' in backend_job._fields:
|
||||
so = backend_job.sale_order_id
|
||||
if so:
|
||||
# IMPORTANT: route through /my/jobs/<id>/so_confirmation, NOT
|
||||
# /report/pdf/ directly. The FP sale report template walks into
|
||||
# fp.part.catalog which portal users don't have ACL on; our
|
||||
# controller renders with sudo() to bypass that. Also avoids
|
||||
# the sale_pdf_quote_builder gate that broke the standard
|
||||
# sale.report_saleorder (CLAUDE.md MEMORY.md gotcha).
|
||||
groups[0]['docs'].append({
|
||||
'label': 'Sales Order Confirmation · %s' % so.name,
|
||||
'sub': 'EN Plating · %s' % (
|
||||
so.date_order and so.date_order.strftime('%b %d, %Y') or ''
|
||||
),
|
||||
'url': '/report/pdf/sale.report_saleorder/%s' % so.id,
|
||||
'url': '/my/jobs/%s/so_confirmation' % job.id,
|
||||
'icon_class': 'o_fp_doc_icon_input',
|
||||
'icon': '📄',
|
||||
})
|
||||
@@ -795,6 +840,101 @@ class FpCustomerPortal(CustomerPortal):
|
||||
attachment, 'raw'
|
||||
).get_response(as_attachment=True)
|
||||
|
||||
# ==========================================================================
|
||||
# JOBS -- download Sales Order Confirmation PDF
|
||||
# ==========================================================================
|
||||
# Renders the FP custom sale report with sudo so the QWeb template can
|
||||
# walk into restricted models (fp.part.catalog etc.) that portal users
|
||||
# don't have direct ACL on. We still gate on _document_check_access for
|
||||
# the portal job, so the customer only ever sees their own data.
|
||||
@http.route(
|
||||
['/my/jobs/<int:job_id>/so_confirmation'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
)
|
||||
def portal_download_so_confirmation(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')
|
||||
|
||||
# Resolve SO via the backend fp.job link.
|
||||
backend_job = (
|
||||
job_sudo.x_fc_job_id
|
||||
if 'x_fc_job_id' in job_sudo._fields
|
||||
else False
|
||||
)
|
||||
so = (
|
||||
backend_job.sale_order_id
|
||||
if backend_job and 'sale_order_id' in backend_job._fields
|
||||
else False
|
||||
)
|
||||
if not so:
|
||||
return request.redirect('/my/jobs/%s' % job_id)
|
||||
|
||||
pdf, _content_type = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
|
||||
'fusion_plating_reports.report_fp_sale_portrait',
|
||||
res_ids=[so.id],
|
||||
)
|
||||
filename = 'Sales-Order-%s.pdf' % so.name.replace('/', '-')
|
||||
return request.make_response(
|
||||
pdf,
|
||||
headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Length', str(len(pdf))),
|
||||
('Content-Disposition', 'attachment; filename="%s"' % filename),
|
||||
],
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,19 @@
|
||||
.o_fp_stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: .35rem;
|
||||
// Bottom space for the absolutely-positioned labels below each circle.
|
||||
// ~2.2rem covers two short lines (title + time_label).
|
||||
margin-bottom: 2.4rem;
|
||||
|
||||
// Each unit holds one circle + its label. The label is absolutely
|
||||
// positioned (see .o_fp_step_label below) so its horizontal centre
|
||||
// lines up with the circle's centre even when text is wider than 24px.
|
||||
.o_fp_step_unit {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.o_fp_step_circle {
|
||||
width: 24px;
|
||||
@@ -20,7 +32,6 @@
|
||||
font-family: $fp-font;
|
||||
font-size: .65rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: $fp-card-bg;
|
||||
border: 1.5px solid $fp-card-border;
|
||||
color: $fp-muted-light;
|
||||
@@ -36,6 +47,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,8 +55,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Connector lines that flex-grow to fill the row between circles.
|
||||
// MUST stay nested inside .o_fp_stepper or flex:1 doesn't apply
|
||||
// (and the circles cluster on the left with no gaps).
|
||||
.o_fp_step_line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
@@ -53,17 +69,19 @@
|
||||
&.o_fp_step_line_done { background: $fp-teal; }
|
||||
&.o_fp_step_line_warn { background: $fp-amber; }
|
||||
}
|
||||
}
|
||||
|
||||
// Step labels row below the stepper
|
||||
.o_fp_step_labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: .68rem;
|
||||
|
||||
// Label centred on its circle via absolute positioning. Wider text
|
||||
// ("Inspected") overflows equally left + right instead of pushing
|
||||
// the column or sitting in a separate flex slot.
|
||||
.o_fp_step_label {
|
||||
position: absolute;
|
||||
top: calc(100% + .45rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
font-size: .68rem;
|
||||
|
||||
.o_fp_step_label_title {
|
||||
color: $fp-muted-light;
|
||||
font-weight: 500;
|
||||
@@ -86,3 +104,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy .o_fp_step_labels container removed — labels are now nested
|
||||
// inside each .o_fp_step_unit (see above) so they centre on their circle.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -27,17 +27,29 @@
|
||||
<t t-set="active_state" t-value="active_state or 'normal'"/>
|
||||
<div class="o_fp_stepper">
|
||||
<t t-foreach="steps" t-as="step">
|
||||
<!-- circle -->
|
||||
<div t-attf-class="o_fp_step_circle #{
|
||||
'o_fp_step_done' if step['status'] == 'done' else
|
||||
(('o_fp_step_active_warn' if active_state == 'warn' else 'o_fp_step_active') if step['status'] == 'active' else '')
|
||||
}">
|
||||
<t t-if="step['status'] == 'done'">✓</t>
|
||||
<t t-elif="step['status'] in ('active', 'pending')">
|
||||
<t t-out="step_index + 1"/>
|
||||
</t>
|
||||
<!-- Unit = circle + its label stacked. Label is absolutely
|
||||
positioned below the circle (in SCSS) so its horizontal
|
||||
centre lines up with the circle no matter how wide the
|
||||
text is — fixes the column-vs-edge distribution
|
||||
mismatch we had with a separate labels row. -->
|
||||
<div class="o_fp_step_unit">
|
||||
<div t-attf-class="o_fp_step_circle #{
|
||||
'o_fp_step_done' if step['status'] == 'done' else
|
||||
(('o_fp_step_active_warn' if active_state == 'warn' else 'o_fp_step_active') if step['status'] == 'active' else '')
|
||||
}">
|
||||
<t t-if="step['status'] == 'done'">✓</t>
|
||||
<t t-elif="step['status'] in ('active', 'pending')">
|
||||
<t t-out="step_index + 1"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-attf-class="o_fp_step_label #{
|
||||
'o_fp_step_label_done' if step['status'] == 'done' else
|
||||
(('o_fp_step_label_active_warn' if active_state == 'warn' else 'o_fp_step_label_active') if step['status'] == 'active' else '')
|
||||
}">
|
||||
<div class="o_fp_step_label_title" t-out="step['label']"/>
|
||||
<div class="o_fp_step_label_time" t-out="step.get('time_label') or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- connecting line (omit after last circle) -->
|
||||
<t t-if="not step_last">
|
||||
<div t-attf-class="o_fp_step_line #{
|
||||
'o_fp_step_line_done' if step['status'] == 'done' else
|
||||
@@ -46,18 +58,6 @@
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<!-- Labels under -->
|
||||
<div class="o_fp_step_labels">
|
||||
<t t-foreach="steps" t-as="step">
|
||||
<div t-attf-class="o_fp_step_label #{
|
||||
'o_fp_step_label_done' if step['status'] == 'done' else
|
||||
(('o_fp_step_label_active_warn' if active_state == 'warn' else 'o_fp_step_label_active') if step['status'] == 'active' else '')
|
||||
}">
|
||||
<div class="o_fp_step_label_title" t-out="step['label']"/>
|
||||
<div class="o_fp_step_label_time" t-out="step.get('time_label') or ''"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.14.0',
|
||||
'version': '19.0.11.15.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -239,28 +239,6 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Signature -->
|
||||
<div class="row" style="margin-top: 25px;">
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<div class="sig-line"/>
|
||||
<div class="small-muted">Customer Acceptance (Signature / Date)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<t t-if="doc.signature">
|
||||
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 3cm; max-width: 8cm;"/><br/>
|
||||
<span t-field="doc.signed_by"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="sig-line"/>
|
||||
</t>
|
||||
<div class="small-muted">Authorized Representative</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
@@ -518,28 +496,6 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Signature block -->
|
||||
<div class="row" style="margin-top: 25px;">
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<div class="sig-line"/>
|
||||
<div class="small-muted">Customer Acceptance (Signature / Date)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<t t-if="doc.signature">
|
||||
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 3cm; max-width: 8cm;"/><br/>
|
||||
<span t-field="doc.signed_by"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="sig-line"/>
|
||||
</t>
|
||||
<div class="small-muted">Authorized Representative</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user