Compare commits
29 Commits
27badff570
...
25f568f225
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f568f225 | ||
|
|
4e54ecc32f | ||
|
|
ab7ff3eea5 | ||
|
|
f8fc6be370 | ||
|
|
b27f68b8d5 | ||
|
|
d9bdbd8e18 | ||
|
|
281941c7ee | ||
|
|
7eb9dd02a7 | ||
|
|
3a520564a7 | ||
|
|
6f2bea9773 | ||
|
|
e50631c46a | ||
|
|
76c68e0311 | ||
|
|
04862e8a28 | ||
|
|
cdc47554ed | ||
|
|
77b84ac11b | ||
|
|
b92a396934 | ||
|
|
8225061dfa | ||
|
|
fe4cceeffa | ||
|
|
a99f9aa5ee | ||
|
|
ca60500c07 | ||
|
|
d17cadabf0 | ||
|
|
df74d702af | ||
|
|
ada22a583f | ||
|
|
009562913c | ||
|
|
0593b70354 | ||
|
|
26fe41e7d4 | ||
|
|
2802fcf738 | ||
|
|
153b980e2b | ||
|
|
6cad69cb86 |
@@ -3,9 +3,13 @@
|
|||||||
## Project
|
## Project
|
||||||
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
|
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
|
||||||
|
|
||||||
## Recent Session Handoff — 2026-05-17
|
## Recent Session Handoff — 2026-05-17 (Portal Redesign + Sub-A IA approved)
|
||||||
|
|
||||||
> **For the next Claude session.** All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
|
> **For the next Claude session.** Portal dashboard + jobs + detail + configurator are LIVE on entech at `fusion_plating_portal 19.0.3.7.0`. Sub-A (Portal IA + Sidebar) is brainstormed, spec'd, planned, NOT yet executed — pick up there. Full handoff (live state, decisions, gotchas, how-to-deploy, what's deferred): **[`docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md`](docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md)**. Approved plan ready to execute: **[`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md)** (11 tasks, 4 phases). Don't re-brainstorm; just execute.
|
||||||
|
|
||||||
|
### Previous handoff (pre-portal redesign — superseded but kept for context)
|
||||||
|
|
||||||
|
> All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
|
||||||
|
|
||||||
### Shipped this session
|
### Shipped this session
|
||||||
|
|
||||||
@@ -170,6 +174,12 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
|||||||
14. **Sticker template — leave the CSS units alone**: `report_fp_wo_sticker_inner` is calibrated for **px units at paperformat dpi=300** on entech's wkhtmltopdf. Do NOT "modernise" it by converting px→mm or by bumping paperformat dpi — both have been tried (2026-05-16) and both collapsed the layout (tiny logo, tiny QR, body grid shorter than the body band, font sizes visually smaller despite using pt). The math suggests the conversions should be equivalent, but wkhtmltopdf's px↔mm↔dpi mapping doesn't follow the obvious model on this image. Trust the working geometry, change only what you came to change. **Barcode size cap**: Odoo core raises `ValueError("Barcode too large")` when `width * height > 1_200_000` OR `max(width, height) > 10000` (see `base/models/ir_actions_report.py::barcode`). Largest safe square is ~1095×1095 — we use 1000×1000 to stay clear of the ceiling. **Em-dash mojibake**: wkhtmltopdf's default font on entech mojibakes em-dash (—), en-dash (–), smart quotes, and ellipsis into `â€"` etc. — strip them defensively for any free-text field that bleeds into the sticker (thickness, notes, line.name). The strip pattern is `.replace(u'—', '-').replace(u'–', '-')...` in `report_fp_wo_sticker_inner`.
|
14. **Sticker template — leave the CSS units alone**: `report_fp_wo_sticker_inner` is calibrated for **px units at paperformat dpi=300** on entech's wkhtmltopdf. Do NOT "modernise" it by converting px→mm or by bumping paperformat dpi — both have been tried (2026-05-16) and both collapsed the layout (tiny logo, tiny QR, body grid shorter than the body band, font sizes visually smaller despite using pt). The math suggests the conversions should be equivalent, but wkhtmltopdf's px↔mm↔dpi mapping doesn't follow the obvious model on this image. Trust the working geometry, change only what you came to change. **Barcode size cap**: Odoo core raises `ValueError("Barcode too large")` when `width * height > 1_200_000` OR `max(width, height) > 10000` (see `base/models/ir_actions_report.py::barcode`). Largest safe square is ~1095×1095 — we use 1000×1000 to stay clear of the ceiling. **Em-dash mojibake**: wkhtmltopdf's default font on entech mojibakes em-dash (—), en-dash (–), smart quotes, and ellipsis into `â€"` etc. — strip them defensively for any free-text field that bleeds into the sticker (thickness, notes, line.name). The strip pattern is `.replace(u'—', '-').replace(u'–', '-')...` in `report_fp_wo_sticker_inner`.
|
||||||
15. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
|
15. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
|
||||||
16. **HTTP controller route override = method name must match parent**: To override a route on an inherited controller (e.g. `portal.CustomerPortal.home()` at `/my/home`), the override method MUST share the parent's method name. Declaring a new method name with the same `@http.route()` URL does NOT override — Odoo registers BOTH handlers as siblings and the parent typically wins, silently. Pattern: `class FpCustomerPortal(CustomerPortal): @http.route() def home(self, **kw): ...`. Bit us 2026-05-17 in `fusion_plating_portal/controllers/portal.py` — `portal_my_home_dashboard()` failed to override stock `home()`; symptom was the rich FP dashboard never rendering at `/my/home` even though the template was active in DB.
|
16. **HTTP controller route override = method name must match parent**: To override a route on an inherited controller (e.g. `portal.CustomerPortal.home()` at `/my/home`), the override method MUST share the parent's method name. Declaring a new method name with the same `@http.route()` URL does NOT override — Odoo registers BOTH handlers as siblings and the parent typically wins, silently. Pattern: `class FpCustomerPortal(CustomerPortal): @http.route() def home(self, **kw): ...`. Bit us 2026-05-17 in `fusion_plating_portal/controllers/portal.py` — `portal_my_home_dashboard()` failed to override stock `home()`; symptom was the rich FP dashboard never rendering at `/my/home` even though the template was active in DB.
|
||||||
|
17. **Test scaffolding — creating account.move in tests**: Two custom gates block direct invoice creation in tests:
|
||||||
|
- `fusion_plating_jobs` blocks all `out_invoice`/`out_refund`/`out_receipt` creates unless `context.get('fp_from_so_invoice')` is set or `invoice_origin` matches an SO name. Bypass: `self.env['account.move'].with_context(fp_from_so_invoice=True).create(...)`.
|
||||||
|
- `fusion_plating_invoicing` blocks `action_post()` when `invoice_payment_term_id` is unset. Bypass: pass `'invoice_payment_term_id': self.env.ref('account.account_payment_term_immediate').id` in the create vals.
|
||||||
|
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
|
||||||
|
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
|
||||||
|
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Portal Redesign — Session Handoff (2026-05-17)
|
||||||
|
|
||||||
|
> **Read this first.** This session ran long; the next session picks up here. Everything below is intentionally short. Authoritative details live in the linked spec / plan files.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Customer-portal redesign across two long sessions. Dashboard + jobs + detail page + configurator are LIVE on entech. The next step (sidebar nav + page audit + Account Summary view) has an APPROVED PLAN ready to execute — do not re-brainstorm, just execute.
|
||||||
|
|
||||||
|
**Immediately actionable:** execute [`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](../plans/2026-05-17-portal-ia-sidebar-plan.md) via `superpowers:subagent-driven-development` or `superpowers:executing-plans`. User was offered both at handoff time and chose subagent-driven (preferred). 11 tasks across 4 phases.
|
||||||
|
|
||||||
|
## Live state on entech (2026-05-17)
|
||||||
|
|
||||||
|
| Module | Version live | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating_portal` | `19.0.3.7.0` | Dashboard, job cards, configurator, detail page, doc downloads, repeat order, animations — all shipped |
|
||||||
|
| `fusion_plating_jobs` | `19.0.10.8.0` + write-hook + create-init | fp.job → fp.portal.job state-sync hook on write, initial state derive on create |
|
||||||
|
| `fusion_plating_reports` | `19.0.11.15.0` | Customer Acceptance / Authorized Representative signature blocks removed from `report_fp_sale_portrait/landscape` |
|
||||||
|
| All 5 portal unit tests green | | `--test-tags=fp_portal` |
|
||||||
|
|
||||||
|
Branch: `main`. Local repo is many commits ahead of `origin/main`; user has not been asked to push (per system-prompt safety default). Run `git log --oneline origin/main..HEAD` at session start to see what's outstanding.
|
||||||
|
|
||||||
|
## What shipped this session (high-level)
|
||||||
|
|
||||||
|
1. **Dashboard rebuild** — `/my/home` → jobs-forward layout (KPI tiles → Active Work Orders hero → 5 secondary panels). Welcome line summarises status in plain words. EN Plating teal brand palette with gradient CTAs.
|
||||||
|
2. **Job card upgrade** — shared `fp_portal_job_card` macro (used by `/my/home` + `/my/jobs`). Wrap div + inner anchor + sibling actions footer (4 doc download chips + Repeat Order POST form). Part info + ship-to address pulled inline. Pulse animation on the active step circle + matching detail-page timeline dot.
|
||||||
|
3. **Detail page** — V2 stepper + V3 timestamps + 5-group document panel (From You / Specifications / Work Order / Quality / Shipping). Sales Order Confirmation, Work Order Detail, CoC, Packing Slip all sudo-render from the FP custom reports. Hero shows part + ship-to.
|
||||||
|
4. **Configurator fixes** — `/my/configurator/coating` 500 fixed (`fp.coating.config` → `fusion.plating.process.type`). Manual measurements hidden in step 1. Split single-file upload into Drawing (PDF) + 3D Model.
|
||||||
|
5. **Sale report cleanup** — Customer Acceptance / Authorized Representative signature block removed.
|
||||||
|
6. **Misc** — `/my` route added, button sizing normalised, hover-underline suppressed globally, sidebar of legacy stuff redirected, dashboard expanded to 5 panels (Quote Requests + Purchase Orders added).
|
||||||
|
|
||||||
|
24+ commits this session, all on `main`. Browse `git log --oneline -30` for the full sweep.
|
||||||
|
|
||||||
|
## What's queued for execution
|
||||||
|
|
||||||
|
**Sub-A (Portal IA + Sidebar):** plan ready, not yet executed. Brainstorm decisions baked in:
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar shape | **B** — Dashboard top, then grouped Activity / Documents / Account sections |
|
||||||
|
| Account Summary tabs | 3 (Invoices / Credit Memos / Statements) + Open Balance pill in header |
|
||||||
|
| Statements V1 | Placeholder card ("Coming soon") — real statement generation deferred |
|
||||||
|
| Legacy URL redirects | `/my/fp_invoices` → `/my/account_summary`; `/my/purchase_orders` → `/my/orders` (Odoo default); `/my/quote_requests/new` GET → `/my/configurator/new` |
|
||||||
|
| Future Users / Search slots | Omit from V1 (no "coming soon" placeholders); add when sub-B/sub-C ship |
|
||||||
|
|
||||||
|
Spec: [`docs/superpowers/specs/2026-05-17-portal-ia-sidebar-design.md`](../specs/2026-05-17-portal-ia-sidebar-design.md)
|
||||||
|
|
||||||
|
## What's deferred (do NOT re-litigate in next session)
|
||||||
|
|
||||||
|
These were explicitly scoped OUT during brainstorming. Open new brainstorm sessions for each when their turn comes:
|
||||||
|
|
||||||
|
- **Sub-B Multi-user account management** — invite teammates, role per user, per-action ACLs. Will add a Users item under the Account section of the sidebar.
|
||||||
|
- **Sub-C Portal search** — global search across jobs / quotes / invoices / certs. Search input slot above Dashboard in the sidebar.
|
||||||
|
- **Saved drafts (RFQ)** — user mentioned wanting drafts during configurator. Three scoping options proposed (minimal/medium/big); awaiting user direction. Not part of sub-A.
|
||||||
|
- **Real Statements generation** — account.followup integration OR cron-precomputed monthly PDFs. Decide during sub-A Phase 3 implementation or defer to its own follow-up.
|
||||||
|
- **Top Recurring Parts / Favorites / SerialNumber Lookup** — competitor-style features; deferred until customer demand confirmed.
|
||||||
|
- **RMA customer portal** — sub-12 RMA backend exists; portal exposure is its own sub-project.
|
||||||
|
|
||||||
|
## Gotchas that bit us this session
|
||||||
|
|
||||||
|
Future Claude will hit these too unless documented. Most are already inline in CLAUDE.md or MEMORY.md. Worth a re-skim before touching the portal:
|
||||||
|
|
||||||
|
1. **`fp.coating.config` is retired** (Sub-11 cleanup). Use `fusion.plating.process.type` as the customer-facing coating taxonomy. Multiple `*.py` files still reference the dead model in COMMENTS — don't pattern-match from those.
|
||||||
|
2. **Portal users can't read `fp.job` directly.** Controllers that return `fp.portal.job` records to a template MUST `sudo()` the search if the template traverses `job.x_fc_job_id`. Same pattern is already used for `sale.order`, `account.move`, `stock.picking`. Domain still filters to commercial partner tree.
|
||||||
|
3. **`sale_pdf_quote_builder` gates on `report_name == 'sale.report_saleorder'`** (already in MEMORY.md). For customer-facing SO PDFs on the portal, render the FP custom `fusion_plating_reports.report_fp_sale_portrait` instead, and use a dedicated portal route that sudo-renders so the QWeb template can walk into `fp.part.catalog` etc.
|
||||||
|
4. **Forms inside anchors is invalid HTML.** When making a whole card clickable AND embedding a Repeat-Order form inside, use a wrap div + inner anchor (main click target) + sibling actions footer (form lives here). Don't nest `<form>` inside `<a>`.
|
||||||
|
5. **Groups list indexing drift.** `_fp_group_documents` builds the docs panel by appending to `groups[N]`. If you reorder the initial list or insert a new group mid-helper, every `groups[N]` reference shifts. The code has an inline warning comment now; respect it.
|
||||||
|
6. **Per-stage timestamps are NULL on records created before the write hook deployed.** `_fp_get_stage_timeline` has a Date-fallback chain (received_date → received_at; actual_ship_date → shipped_at) plus linear interpolation for middle stages. Records created post-hook get real datetimes from the `fp.job.write()` mirror.
|
||||||
|
7. **Stepper SCSS — `.o_fp_step_line` MUST stay nested inside `.o_fp_stepper`** (inline comment in the SCSS warns about this). When `flex:1` isn't applied because the rule slipped outside the parent, circles cluster on the left of the row.
|
||||||
|
8. **Stepper labels align via absolute positioning per-unit** (not as a separate flex container). Wider labels like "Inspected" overflow equally to both sides of their circle's centre. Don't revert to the dual-container approach.
|
||||||
|
9. **`fp.portal.job` state-sync map** uses `_FP_JOB_STATE_TO_PORTAL_STATE` in `fusion_plating_jobs/models/fp_job.py`. `on_hold` and `cancelled` deliberately NOT mirrored to the customer-facing state. Manager decision what to surface.
|
||||||
|
|
||||||
|
## How to deploy (entech LXC 111 on pve-worker5)
|
||||||
|
|
||||||
|
Same recipe used 20+ times this session. Per file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat K:/Github/Odoo-Modules/fusion_plating/<module>/<path> | \
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<module>/<path>'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then upgrade module + run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
||||||
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
||||||
|
-u fusion_plating_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && \
|
||||||
|
systemctl start odoo'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Bust asset cache for SCSS/JS changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
|
||||||
|
\"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
|
||||||
|
```
|
||||||
|
|
||||||
|
Service status / version check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl is-active odoo'"
|
||||||
|
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
|
||||||
|
\"psql -d admin -t -c \\\"SELECT latest_version FROM ir_module_module \
|
||||||
|
WHERE name='fusion_plating_portal';\\\"\""
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to start the next session
|
||||||
|
|
||||||
|
1. Open Claude Code in `K:\Github\Odoo-Modules\fusion-plating` (or `K:\Github\Odoo-Modules\fusion_plating` — both work, the dash dir has only 3 modules but it's the active working dir for the user's terminal).
|
||||||
|
2. First message: "Resume the portal sub-A IA work — execute the approved plan from this session."
|
||||||
|
3. New session should:
|
||||||
|
- Read `CLAUDE.md` (auto-loaded) — the "Recent Session Handoff" section at the top points back to this file
|
||||||
|
- Read this handoff doc
|
||||||
|
- Read the plan: `docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`
|
||||||
|
- Invoke `superpowers:subagent-driven-development` (or `executing-plans` for inline mode)
|
||||||
|
- Execute the 11 tasks across 4 phases
|
||||||
|
4. Optional but useful: re-run the existing test suite first to confirm starting from green: `--test-tags=fp_portal --stop-after-init`.
|
||||||
|
|
||||||
|
## Brainstorm artifacts
|
||||||
|
|
||||||
|
Visual companion mockups for this session live in `.superpowers/brainstorm/*/content/` (gitignored). Useful for visual comparison if needed:
|
||||||
|
- `design-direction.html` — Modern SaaS / Corporate / Industrial picker
|
||||||
|
- `saas-refinements.html` — V1/V2/V3 card variants
|
||||||
|
- `dashboard-layout.html` — 6-card grid vs jobs-forward
|
||||||
|
- `job-detail.html`, `branded-job-detail.html` — detail page mockups
|
||||||
|
- `branded-dashboard.html` — final brand-applied dashboard
|
||||||
|
- `sidebar-structure.html` — flat vs grouped vs hybrid (chose grouped)
|
||||||
|
|
||||||
|
Brainstorm server idles out after 30 min. Restart command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"C:/Users/gur_p/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh" \
|
||||||
|
--project-dir "K:/Github/Odoo-Modules/fusion_plating"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Run in background; URL appears in `.superpowers/brainstorm/*/state/server-info`.)
|
||||||
|
|
||||||
|
## Critical files modified this session
|
||||||
|
|
||||||
|
If the next session needs to read context fast:
|
||||||
|
- `fusion_plating_portal/controllers/portal.py` — most changes here
|
||||||
|
- `fusion_plating_portal/controllers/portal_configurator.py` — coating model swap + dual upload
|
||||||
|
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — jobs-forward layout
|
||||||
|
- `fusion_plating_portal/views/fp_portal_templates.xml` — jobs list + detail rewrites
|
||||||
|
- `fusion_plating_portal/views/fp_portal_macros.xml` — `fp_portal_job_card`, `fp_portal_stepper`, `fp_portal_status_badge`, `fp_portal_doc_chip`, `fp_portal_doc_group`
|
||||||
|
- `fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss` — brand tokens
|
||||||
|
- `fusion_plating_portal/static/src/scss/fp_portal_*.scss` — 7 partials (buttons, badges, cards, stepper, timeline, dashboard, legacy catch-all)
|
||||||
|
- `fusion_plating_portal/models/fp_portal_job.py` — per-stage Datetime fields + write/create snapshot hooks
|
||||||
|
- `fusion_plating_jobs/models/fp_job.py` — fp.job → fp.portal.job state-sync hook
|
||||||
|
- `fusion_plating_portal/tests/test_portal_dashboard.py` — 5 tests, all green
|
||||||
|
|
||||||
|
## What user feedback is still outstanding
|
||||||
|
|
||||||
|
Nothing concrete waiting on user. Last thing the user did was approve the plan and say "create a handsoff script so i start a new session" — i.e., they want to pause here. Next session resumes execution.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
|||||||
|
# Customer Portal — Information Architecture + Sidebar Nav
|
||||||
|
|
||||||
|
**Module**: `fusion_plating_portal` (touches `portal.portal_layout` inherit)
|
||||||
|
**Date**: 2026-05-17
|
||||||
|
**Status**: Design locked, awaiting implementation plan
|
||||||
|
**Surface**: every `/my/*` page on `https://enplating.com`
|
||||||
|
**Sub-project**: A (of A/B/C); B = multi-user, C = portal search — deferred to separate brainstorms.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The post-2026-05-17 portal redesign gave us a credible dashboard + jobs-detail page, but the navigation between pages is still "scroll the standard Odoo portal cards and hope you find the right entry point." Eight distinct customer surfaces (`/my/home`, `/my/jobs`, `/my/quote_requests`, `/my/configurator`, `/my/purchase_orders`, `/my/fp_invoices`, `/my/deliveries`, `/my/certifications`) and there's no persistent way to move between them. The customer's competitor screenshot (Mobility Specialties Inc / Drive Medical) shows the right pattern: a sticky left sidebar that lists every section, current page highlighted, secondary "Company Account" group at the bottom.
|
||||||
|
|
||||||
|
This spec restructures the portal around that sidebar pattern, audits the existing pages (replace thin custom pages with Odoo defaults where the default is better), and adds one missing page — a consolidated **Account Summary** with tabbed Invoices / Credit Memos / Statements + an Open Balance pill — that the existing thin `/my/fp_invoices` page doesn't deliver.
|
||||||
|
|
||||||
|
## User stories
|
||||||
|
|
||||||
|
1. **As a returning customer**, I want a persistent sidebar showing every section so I can jump between Quote Requests and Work Orders without going through the dashboard.
|
||||||
|
2. **As an accounting clerk**, when I open the portal I want a single Account Summary page with Open Balance + filterable invoices + credit memos + downloadable monthly statements — without hunting through three separate menu items.
|
||||||
|
3. **As any customer**, I want the active page visually marked so I always know where I am.
|
||||||
|
4. **As a mobile user**, the sidebar should collapse to a hamburger so the page content gets the screen.
|
||||||
|
|
||||||
|
## Locked design decisions (from brainstorming 2026-05-17)
|
||||||
|
|
||||||
|
| Decision | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Decomposition | A first (IA), B (multi-user) + C (search) deferred to separate brainstorms | Sidebar + pages are the foundation; building search before pages exist or a Users tab before the sidebar shape is locked would be rework. |
|
||||||
|
| Sidebar shape | Option B — Dashboard at top, then 3 grouped sections (Activity / Documents / Account) | 10 items needs grouping to scan; matches how the redesigned dashboard already groups (KPI tiles → jobs hero → secondary panels). |
|
||||||
|
| Account Summary tabs | 3 tabs: Invoices · Credit Memos · Statements, plus an "Open Balance: $X" pill in the page header | Mirrors competitor; one summary number front-of-mind, three drilldowns. |
|
||||||
|
| Future placeholders | NEITHER "Users (soon)" nor a search input shown in the sidebar today | Empty placeholders add visual noise; ship them when sub-B / sub-C land. |
|
||||||
|
| Sidebar persistence | Sticky on scroll; visible on every `/my/*` page (including Odoo defaults via `portal.portal_layout` inherit); sub-pages keep their parent highlighted | Industry standard. Consistency means the customer never loses their place. |
|
||||||
|
| Mobile collapse | Below 768px the sidebar collapses to a hamburger button in the page header; opens as a slide-in drawer | Standard portal pattern, no content rearrangement needed. |
|
||||||
|
| Single quote-creation path | `/my/quote_requests/new` redirects to `/my/configurator/new` | Two paths to the same outcome confuses customers; the configurator is the more complete flow. |
|
||||||
|
| Sign Out placement | Bottom of sidebar, separated by a hairline border | Matches competitor; gets sign-out off the page chrome. |
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**IN SCOPE — pages restructured / new:**
|
||||||
|
|
||||||
|
- `/my/home` — keep dashboard, gets sidebar
|
||||||
|
- `/my/jobs` — keep list, gets sidebar
|
||||||
|
- `/my/jobs/<id>` — keep detail, gets sidebar (highlight parent)
|
||||||
|
- `/my/quote_requests` — keep list, gets sidebar
|
||||||
|
- `/my/quote_requests/<id>` — keep detail, gets sidebar
|
||||||
|
- `/my/quote_requests/new` — **REDIRECT** to `/my/configurator/new`
|
||||||
|
- `/my/configurator` — keep landing, gets sidebar
|
||||||
|
- `/my/configurator/new`, `.../coating`, `.../estimate` — keep wizard, gets sidebar
|
||||||
|
- `/my/purchase_orders` — **REDIRECT** to Odoo default `/my/orders`; controller + template deleted
|
||||||
|
- `/my/fp_invoices` — **REDIRECT** to new `/my/account_summary`; controller + template deleted
|
||||||
|
- `/my/account_summary` — **NEW** tabbed page (this spec)
|
||||||
|
- `/my/deliveries` — keep, gets sidebar
|
||||||
|
- `/my/certifications` — keep, gets sidebar
|
||||||
|
- `/my/account` — Odoo default, gets sidebar
|
||||||
|
- `/my/orders/<id>` — Odoo default, gets sidebar
|
||||||
|
|
||||||
|
**IN SCOPE — chrome:**
|
||||||
|
|
||||||
|
- New `fp_portal_shell` template that inherits `portal.portal_layout` and wraps every `o_portal` page body with a sticky 240px sidebar on the left.
|
||||||
|
- Sidebar SCSS partial (`fp_portal_sidebar.scss`) — brand-teal active state, mint gradient highlight, hairline section dividers.
|
||||||
|
- Mobile breakpoint: hamburger toggle + slide-in drawer below 768px.
|
||||||
|
- All Odoo default portal pages (`/my/account`, `/my/orders`, `/my/orders/<id>`, `/my/invoices/<id>`, etc.) get the sidebar via the `portal.portal_layout` inherit — zero per-page edits.
|
||||||
|
|
||||||
|
**OUT OF SCOPE — deferred to other sub-projects:**
|
||||||
|
|
||||||
|
- Multi-user account management (sub-project B): Users tab in sidebar, invitation flow, per-action ACLs.
|
||||||
|
- Portal search (sub-project C): global search input above Dashboard, search-result page.
|
||||||
|
- Saved drafts (separate brainstorm — needs its own scoping).
|
||||||
|
- Top Recurring Parts / Favorites / SerialNumber Lookup (defer until customer demand confirmed).
|
||||||
|
- RMA customer portal (sub-project after RMA backend ships).
|
||||||
|
|
||||||
|
**OUT OF SCOPE — explicit non-goals:**
|
||||||
|
|
||||||
|
- Top-bar navigation, breadcrumbs redesign, footer changes — none of these are part of A.
|
||||||
|
- Restyling Odoo default `/my/account` or `/my/orders/<id>` page BODIES. We give them the sidebar via the layout inherit, but their content stays Odoo-standard.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Sidebar shell
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion_plating_portal/views/fp_portal_shell.xml
|
||||||
|
└── inherits portal.portal_layout
|
||||||
|
└── injects .o_fp_portal_shell wrapper that contains:
|
||||||
|
├── <aside class="o_fp_portal_sidebar"> (sticky, 240px)
|
||||||
|
│ └── partner header + 4 sections + sign-out
|
||||||
|
└── <main class="o_fp_portal_main"> (existing portal body)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per Odoo's `portal.portal_layout` extension pattern, we inherit and use `<xpath expr="//div[@id='wrap']" position="replace">` (or `position="inside"` on the right anchor — TBD during implementation) to wrap the existing layout. The sidebar is a single shared template (`fp_portal_sidebar`) rendered above the existing portal page body.
|
||||||
|
|
||||||
|
Active-state marker: each sidebar `<a>` reads the current `page_name` from the template context (already set by every FP route — `fp_dashboard`, `fp_jobs`, etc.) and applies `o_fp_sidebar_active` when matched. Falls back to URL prefix match for Odoo default pages (`/my/orders` → Purchase Orders highlighted, `/my/account` → Profile highlighted).
|
||||||
|
|
||||||
|
### Sidebar items (final list)
|
||||||
|
|
||||||
|
```
|
||||||
|
ACME AEROSPACE <-- partner.commercial_partner_id.name
|
||||||
|
─────────────────────────────────────────
|
||||||
|
🏠 Dashboard /my/home
|
||||||
|
ACTIVITY
|
||||||
|
📄 Quote Requests /my/quote_requests
|
||||||
|
+ Get a Quote /my/configurator
|
||||||
|
🛒 Purchase Orders /my/orders (Odoo)
|
||||||
|
⚙️ Work Orders /my/jobs
|
||||||
|
DOCUMENTS
|
||||||
|
📑 Certifications /my/certifications
|
||||||
|
📦 Packing Slips /my/deliveries
|
||||||
|
💰 Account Summary /my/account_summary (NEW)
|
||||||
|
ACCOUNT
|
||||||
|
👤 Profile /my/account (Odoo)
|
||||||
|
─────────────────────────────────────────
|
||||||
|
↪ Sign Out /web/session/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
Section headers (`ACTIVITY` / `DOCUMENTS` / `ACCOUNT`) are display-only, not links. The whole list is rendered from a single Python data structure in the template context (passed by a small helper on `FpCustomerPortal`), so adding the future Users / Drafts / Search items is a one-line addition.
|
||||||
|
|
||||||
|
### Account Summary page
|
||||||
|
|
||||||
|
**URL**: `/my/account_summary`
|
||||||
|
**Controller method**: `portal_account_summary(self, **kw)` on `FpCustomerPortal`
|
||||||
|
**Template**: `portal_my_account_summary` in `fp_portal_account_summary.xml` (new file)
|
||||||
|
|
||||||
|
**Page structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Account Summary ] Open Balance: $4,820.00
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
[ Invoices ] [ Credit Memos ] [ Statements ]
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Showing: Open · Closed · All [Search PO or #__________ ] [Sort ▾]
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# | Status | Posted On | PO # | Due Date | Balance | View PDF
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
0035180274 | ● Open | May 13, 2026 | 53469 | Jun 12, 2026 | C$305.73 | View PDF
|
||||||
|
...
|
||||||
|
◀ Prev 1 2 3 4 5 Next ▶
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data sources (per tab):**
|
||||||
|
|
||||||
|
| Tab | Model + domain | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Invoices | `account.move` where `move_type='out_invoice'`, `partner_id child_of commercial`, `state='posted'` | Today's `/my/fp_invoices` already does this; relocated here. |
|
||||||
|
| Credit Memos | `account.move` where `move_type='out_refund'`, `partner_id child_of commercial`, `state='posted'` | Surfaces RMA credits when sub-12 RMA flow runs. Tab shows empty state with "No credits yet" when partner has none. |
|
||||||
|
| Statements | Generated PDF per month via `account.followup` or a custom QWeb cron — **decided during implementation; preferred = use account.followup report directly per-customer with date filter** | Tab UI: month picker + Download button. |
|
||||||
|
|
||||||
|
**Open Balance pill** = sum of `amount_residual` across all open `out_invoice` records (regardless of tab). Computed in the controller, shown in the page header.
|
||||||
|
|
||||||
|
**Search box** = case-insensitive substring match on `name` (invoice number) OR `ref` (customer PO). Server-side filter, not JS.
|
||||||
|
|
||||||
|
**Sort options:** Newest → Oldest (default), Oldest → Newest, Largest balance, Smallest balance.
|
||||||
|
|
||||||
|
**Filter pills:** `Open` (residual > 0) / `Closed` (residual = 0) / `All`.
|
||||||
|
|
||||||
|
**Pagination:** 10 per page, server-side via `portal_pager`.
|
||||||
|
|
||||||
|
Invoice detail = existing Odoo `/my/invoices/<id>` page (no rewrite); the table's "View PDF" link goes to `/my/invoices/<id>?report_type=pdf&download=true` per Odoo's standard portal pattern.
|
||||||
|
|
||||||
|
### Mobile behavior
|
||||||
|
|
||||||
|
```scss
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.o_fp_portal_sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
position: fixed; top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
.o_fp_portal_sidebar.o_fp_open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.o_fp_portal_hamburger { display: inline-flex; }
|
||||||
|
}
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.o_fp_portal_hamburger { display: none; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hamburger button lives in the page header (above the main content). Click toggles `o_fp_open` on the sidebar via 5-line vanilla JS (no framework). Backdrop click closes the drawer.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
**NEW:**
|
||||||
|
|
||||||
|
- `fusion_plating_portal/views/fp_portal_shell.xml` — `portal.portal_layout` inherit + sidebar markup
|
||||||
|
- `fusion_plating_portal/views/fp_portal_account_summary.xml` — `portal_my_account_summary` template
|
||||||
|
- `fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss` — sidebar styling (sticky, active state, sections, mobile drawer)
|
||||||
|
- `fusion_plating_portal/static/src/js/fp_portal_sidebar.js` — hamburger toggle (vanilla JS, no OWL)
|
||||||
|
|
||||||
|
**MODIFY:**
|
||||||
|
|
||||||
|
- `fusion_plating_portal/controllers/portal.py`
|
||||||
|
- NEW route `portal_account_summary` at `/my/account_summary`
|
||||||
|
- DELETE route `portal_my_fp_invoices` (the thin invoice list at `/my/fp_invoices`)
|
||||||
|
- REPLACE route `portal_my_purchase_orders` body with `return request.redirect('/my/orders')`
|
||||||
|
- REPLACE the GET handler for `portal_my_quote_request_new` with `return request.redirect('/my/configurator/new')` (or delete entirely if the configurator already exposes the equivalent form)
|
||||||
|
- NEW helper `_fp_sidebar_items(self)` returning the sidebar data structure (consumed by `fp_portal_sidebar` template via inherited `_prepare_portal_layout_values`)
|
||||||
|
- Extend `_prepare_portal_layout_values()` to inject `fp_sidebar_items` + `fp_partner_display_name` into every portal page's context so the sidebar renders correctly on Odoo default pages too.
|
||||||
|
- `fusion_plating_portal/views/fp_portal_templates.xml` — delete `portal_my_fp_invoices` template body (route is gone). Remaining templates (jobs list, jobs detail, deliveries, certifications) get the sidebar **for free** via the `portal.portal_layout` inherit; no per-template edits.
|
||||||
|
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — dashboard template gets the sidebar via the layout inherit; no edits needed.
|
||||||
|
- `fusion_plating_portal/__manifest__.py` — version bump + register the new XML/SCSS/JS files. Add `fp_portal_shell.xml` near the TOP of the `data` list (loaded before any template that uses sidebar variables).
|
||||||
|
|
||||||
|
**DELETE (or stub):**
|
||||||
|
|
||||||
|
- The `portal_my_fp_invoices` template body and the `portal_my_purchase_orders` template body. Routes redirected, templates unused. Keep route stubs so existing bookmarks 302 cleanly instead of 404.
|
||||||
|
|
||||||
|
## Migration / backward compatibility
|
||||||
|
|
||||||
|
| Old URL | New behavior |
|
||||||
|
|---|---|
|
||||||
|
| `/my/fp_invoices` | 302 → `/my/account_summary` |
|
||||||
|
| `/my/purchase_orders` | 302 → `/my/orders` |
|
||||||
|
| `/my/quote_requests/new` | 302 → `/my/configurator/new` |
|
||||||
|
|
||||||
|
No DB migration. No template namespace changes that break inherits. The page audit removes routes from the controller and templates from the data list; Odoo's module-upgrade cycle handles the ORM-side cleanup.
|
||||||
|
|
||||||
|
## Open items to verify during implementation
|
||||||
|
|
||||||
|
1. **`portal.portal_layout` extension pattern** — confirm the cleanest xpath for injecting the sidebar wrapper without breaking Odoo's existing portal CSS (`#wrap`, `.o_portal`). Likely `position="before"` on the main content slot. If unclear, fall back to inheriting at the `website.layout` level and writing a wholly new shell template.
|
||||||
|
2. **Statements tab data source** — decide between (a) inline render of `account.followup` report per requested month, vs (b) precomputed monthly statement PDFs stored as attachments. Latter is simpler for V1; cron generates last-month statement on the 1st.
|
||||||
|
3. **Mobile hamburger placement** — header anchor: a small button at the top-left of the main content area (above the page title) on mobile only. Confirm during Phase 4 visual pass.
|
||||||
|
4. **Page-name → active-item mapping** — most FP routes set a clean `page_name` (e.g., `fp_jobs`, `fp_dashboard`). Odoo defaults don't; we'll match by URL prefix (`/my/orders` → `purchase_orders` item). One-helper `_fp_resolve_active_sidebar_item(url, page_name)` keeps the mapping in one place.
|
||||||
|
5. **Account Summary Statements scope** — confirm whether monthly statements are something EN Plating currently generates, or if this is a new artifact we need to define a template for. If the latter, that's a separate small spec.
|
||||||
|
|
||||||
|
## What ships in a "done" state
|
||||||
|
|
||||||
|
- Every `/my/*` page (FP + Odoo default) shows the new sidebar.
|
||||||
|
- Active page is visually marked.
|
||||||
|
- Sidebar collapses to hamburger drawer below 768px.
|
||||||
|
- `/my/account_summary` exists with 3 tabs, Open Balance pill, search + filter pills + sort + pagination.
|
||||||
|
- 3 legacy URLs (`/my/fp_invoices`, `/my/purchase_orders`, `/my/quote_requests/new`) 302-redirect to their new homes.
|
||||||
|
- Unit tests cover the new account_summary controller (3 tabs return the right counts, filter/search produce the right subset, Open Balance sums residuals correctly).
|
||||||
|
- Module version bumped, deployed to entech, all 5 existing portal tests still green plus 3+ new tests for Account Summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Sub-projects B (multi-user) and C (portal search) are tracked separately — they'll consume the sidebar slot conventions (insertion under ACCOUNT for Users, above DASHBOARD for the search input) defined here.*
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Customer Portal',
|
'name': 'Fusion Plating — Customer Portal',
|
||||||
'version': '19.0.3.5.0',
|
'version': '19.0.4.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||||
'CoC downloads, invoice access.',
|
'CoC downloads, invoice access.',
|
||||||
@@ -55,10 +55,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'security/fp_portal_security.xml',
|
'security/fp_portal_security.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_sequence_data.xml',
|
'data/fp_sequence_data.xml',
|
||||||
|
'views/fp_portal_shell.xml',
|
||||||
'views/fp_portal_macros.xml',
|
'views/fp_portal_macros.xml',
|
||||||
'views/fp_quote_request_views.xml',
|
'views/fp_quote_request_views.xml',
|
||||||
'views/fp_portal_dashboard.xml',
|
'views/fp_portal_dashboard.xml',
|
||||||
'views/fp_portal_templates.xml',
|
'views/fp_portal_templates.xml',
|
||||||
|
'views/fp_portal_account_summary.xml', # NEW — Task 10
|
||||||
'views/fp_portal_configurator_templates.xml',
|
'views/fp_portal_configurator_templates.xml',
|
||||||
'views/fp_portal_breadcrumbs.xml',
|
'views/fp_portal_breadcrumbs.xml',
|
||||||
'views/fp_sale_order_portal.xml',
|
'views/fp_sale_order_portal.xml',
|
||||||
@@ -76,9 +78,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'fusion_plating_portal/static/src/scss/fp_portal_stepper.scss',
|
'fusion_plating_portal/static/src/scss/fp_portal_stepper.scss',
|
||||||
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
|
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
|
||||||
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss',
|
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss',
|
||||||
|
'fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss', # NEW — Task 5
|
||||||
# Catch-all legacy rules (last)
|
# Catch-all legacy rules (last)
|
||||||
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
|
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
|
||||||
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
|
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
|
||||||
|
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW — Task 5
|
||||||
|
'fusion_plating_portal/static/src/js/fp_portal_account_summary.js', # NEW — Task 10 fix
|
||||||
|
'fusion_plating_portal/static/src/js/fp_portal_list_search.js', # list search + sort
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'demo': [
|
'demo': [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,10 +33,12 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
[('partner_id', 'child_of', partner.commercial_partner_id.id)],
|
[('partner_id', 'child_of', partner.commercial_partner_id.id)],
|
||||||
order='create_date desc', limit=10,
|
order='create_date desc', limit=10,
|
||||||
)
|
)
|
||||||
return request.render('fusion_plating_portal.portal_configurator_landing', {
|
values = self._prepare_portal_layout_values()
|
||||||
|
values.update({
|
||||||
'page_name': 'fp_configurator',
|
'page_name': 'fp_configurator',
|
||||||
'quotes': quotes,
|
'quotes': quotes,
|
||||||
})
|
})
|
||||||
|
return request.render('fusion_plating_portal.portal_configurator_landing', values)
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Step 1 — Upload part or enter manual measurements
|
# Step 1 — Upload part or enter manual measurements
|
||||||
@@ -53,42 +55,58 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
'part_name': kw.get('part_name', ''),
|
'part_name': kw.get('part_name', ''),
|
||||||
'part_number': kw.get('part_number', ''),
|
'part_number': kw.get('part_number', ''),
|
||||||
'substrate_material': kw.get('substrate_material', 'steel'),
|
'substrate_material': kw.get('substrate_material', 'steel'),
|
||||||
'geometry_source': kw.get('geometry_source', 'manual'),
|
'geometry_source': kw.get('geometry_source', 'upload'),
|
||||||
|
# Manual measurements are HIDDEN in the template per customer
|
||||||
|
# feedback 2026-05-17 — kept as hidden 0s so the rest of the
|
||||||
|
# flow doesn't error on missing keys. Backend computes them
|
||||||
|
# if/when needed.
|
||||||
'surface_area': float(kw.get('surface_area', 0) or 0),
|
'surface_area': float(kw.get('surface_area', 0) or 0),
|
||||||
'dimensions_length': float(kw.get('dimensions_length', 0) or 0),
|
'dimensions_length': float(kw.get('dimensions_length', 0) or 0),
|
||||||
'dimensions_width': float(kw.get('dimensions_width', 0) or 0),
|
'dimensions_width': float(kw.get('dimensions_width', 0) or 0),
|
||||||
'dimensions_height': float(kw.get('dimensions_height', 0) or 0),
|
'dimensions_height': float(kw.get('dimensions_height', 0) or 0),
|
||||||
|
# Multi-upload: customer may submit drawing + 3D + others.
|
||||||
|
# Collect IDs in a list so they all flow through to the RFQ.
|
||||||
|
'attachment_ids': [],
|
||||||
|
'attachment_names': [],
|
||||||
}
|
}
|
||||||
# Handle file upload
|
|
||||||
file_upload = kw.get('part_file')
|
|
||||||
if file_upload and hasattr(file_upload, 'read'):
|
|
||||||
file_data = file_upload.read()
|
|
||||||
if file_data:
|
|
||||||
attachment = request.env['ir.attachment'].sudo().create({
|
|
||||||
'name': file_upload.filename,
|
|
||||||
'datas': base64.b64encode(file_data),
|
|
||||||
'res_model': 'fusion.plating.quote.request',
|
|
||||||
'type': 'binary',
|
|
||||||
})
|
|
||||||
session_data['attachment_id'] = attachment.id
|
|
||||||
session_data['attachment_name'] = file_upload.filename
|
|
||||||
fname = file_upload.filename.lower()
|
|
||||||
if fname.endswith(('.stl', '.stp', '.step', '.iges', '.igs')):
|
|
||||||
session_data['geometry_source'] = '3d_model'
|
|
||||||
else:
|
|
||||||
session_data['geometry_source'] = 'pdf_drawing'
|
|
||||||
|
|
||||||
# Try to calculate surface area for STL files
|
def _save_upload(file_upload, label):
|
||||||
if fname.endswith('.stl'):
|
"""Persist a single uploaded file and append to session lists.
|
||||||
try:
|
Returns the attachment record or None."""
|
||||||
import io
|
if not (file_upload and hasattr(file_upload, 'read')):
|
||||||
import trimesh
|
return None
|
||||||
mesh = trimesh.load(io.BytesIO(file_data), file_type='stl')
|
file_data = file_upload.read()
|
||||||
# Convert mm^2 to sq in (1 sq in = 645.16 mm^2)
|
if not file_data:
|
||||||
session_data['surface_area'] = round(mesh.area / 645.16, 4)
|
return None
|
||||||
session_data['auto_calculated'] = True
|
attachment = request.env['ir.attachment'].sudo().create({
|
||||||
except Exception:
|
'name': file_upload.filename,
|
||||||
_logger.info('Could not auto-calculate STL surface area (trimesh not available).')
|
'datas': base64.b64encode(file_data),
|
||||||
|
'res_model': 'fusion.plating.quote.request',
|
||||||
|
'type': 'binary',
|
||||||
|
})
|
||||||
|
session_data['attachment_ids'].append(attachment.id)
|
||||||
|
session_data['attachment_names'].append(
|
||||||
|
'%s (%s)' % (file_upload.filename, label),
|
||||||
|
)
|
||||||
|
# STL surface-area auto-calc (silent — backend value only,
|
||||||
|
# not surfaced in UI per the hidden-measurements decision).
|
||||||
|
fname = file_upload.filename.lower()
|
||||||
|
if fname.endswith('.stl'):
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
import trimesh
|
||||||
|
mesh = trimesh.load(io.BytesIO(file_data), file_type='stl')
|
||||||
|
# mm^2 -> sq in (1 sq in = 645.16 mm^2)
|
||||||
|
session_data['surface_area'] = round(mesh.area / 645.16, 4)
|
||||||
|
session_data['auto_calculated'] = True
|
||||||
|
except Exception:
|
||||||
|
_logger.info('STL surface-area auto-calc skipped (trimesh missing or bad mesh).')
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
_save_upload(kw.get('part_drawing'), 'drawing')
|
||||||
|
_save_upload(kw.get('part_3d_model'), '3D model')
|
||||||
|
# Back-compat: legacy single-file input still accepted.
|
||||||
|
_save_upload(kw.get('part_file'), 'attachment')
|
||||||
|
|
||||||
request.session['fp_configurator'] = session_data
|
request.session['fp_configurator'] = session_data
|
||||||
return request.redirect('/my/configurator/coating')
|
return request.redirect('/my/configurator/coating')
|
||||||
@@ -102,10 +120,12 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
('titanium', 'Titanium'),
|
('titanium', 'Titanium'),
|
||||||
('other', 'Other'),
|
('other', 'Other'),
|
||||||
]
|
]
|
||||||
return request.render('fusion_plating_portal.portal_configurator_step1', {
|
values = self._prepare_portal_layout_values()
|
||||||
|
values.update({
|
||||||
'page_name': 'fp_configurator',
|
'page_name': 'fp_configurator',
|
||||||
'materials': materials,
|
'materials': materials,
|
||||||
})
|
})
|
||||||
|
return request.render('fusion_plating_portal.portal_configurator_step1', values)
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Step 2 — Select coating configuration
|
# Step 2 — Select coating configuration
|
||||||
@@ -128,14 +148,19 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
request.session['fp_configurator'] = session_data
|
request.session['fp_configurator'] = session_data
|
||||||
return request.redirect('/my/configurator/estimate')
|
return request.redirect('/my/configurator/estimate')
|
||||||
|
|
||||||
coatings = request.env['fp.coating.config'].sudo().search(
|
# fp.coating.config retired post-Sub-11. Use process.type as the
|
||||||
[('active', '=', True)], order='sequence',
|
# customer-facing coating picker — its records (Hard Chrome, EN
|
||||||
|
# Low-Phos, etc.) are exactly what a customer would select from.
|
||||||
|
coatings = request.env['fusion.plating.process.type'].sudo().search(
|
||||||
|
[('active', '=', True)], order='sequence, name',
|
||||||
)
|
)
|
||||||
return request.render('fusion_plating_portal.portal_configurator_step2', {
|
values = self._prepare_portal_layout_values()
|
||||||
|
values.update({
|
||||||
'page_name': 'fp_configurator',
|
'page_name': 'fp_configurator',
|
||||||
'coatings': coatings,
|
'coatings': coatings,
|
||||||
'session_data': session_data,
|
'session_data': session_data,
|
||||||
})
|
})
|
||||||
|
return request.render('fusion_plating_portal.portal_configurator_step2', values)
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Step 3 — Estimate & submit
|
# Step 3 — Estimate & submit
|
||||||
@@ -147,21 +172,29 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
if not session_data or not session_data.get('coating_config_id'):
|
if not session_data or not session_data.get('coating_config_id'):
|
||||||
return request.redirect('/my/configurator/new')
|
return request.redirect('/my/configurator/new')
|
||||||
|
|
||||||
coating = request.env['fp.coating.config'].sudo().browse(
|
coating = request.env['fusion.plating.process.type'].sudo().browse(
|
||||||
session_data['coating_config_id'],
|
session_data['coating_config_id'],
|
||||||
)
|
)
|
||||||
if not coating.exists():
|
if not coating.exists():
|
||||||
return request.redirect('/my/configurator/coating')
|
return request.redirect('/my/configurator/coating')
|
||||||
|
|
||||||
# Calculate estimated price from pricing rules
|
# Estimate price from pricing rules. Best-effort: returns
|
||||||
estimated_price = self._estimate_price(session_data, coating)
|
# {'available': False} when rules don't match or process.type
|
||||||
|
# lacks the data the rules expect (post-coating-retire transition).
|
||||||
|
try:
|
||||||
|
estimated_price = self._estimate_price(session_data, coating)
|
||||||
|
except Exception:
|
||||||
|
_logger.info('Skipping price estimate — pricing helper unavailable.', exc_info=True)
|
||||||
|
estimated_price = {'min': 0, 'max': 0, 'available': False}
|
||||||
|
|
||||||
return request.render('fusion_plating_portal.portal_configurator_step3', {
|
values = self._prepare_portal_layout_values()
|
||||||
|
values.update({
|
||||||
'page_name': 'fp_configurator',
|
'page_name': 'fp_configurator',
|
||||||
'session_data': session_data,
|
'session_data': session_data,
|
||||||
'coating': coating,
|
'coating': coating,
|
||||||
'estimated_price': estimated_price,
|
'estimated_price': estimated_price,
|
||||||
})
|
})
|
||||||
|
return request.render('fusion_plating_portal.portal_configurator_step3', values)
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Submit — create quote request
|
# Submit — create quote request
|
||||||
@@ -177,7 +210,7 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
return request.redirect('/my/configurator/new')
|
return request.redirect('/my/configurator/new')
|
||||||
|
|
||||||
partner = request.env.user.partner_id
|
partner = request.env.user.partner_id
|
||||||
coating = request.env['fp.coating.config'].sudo().browse(
|
coating = request.env['fusion.plating.process.type'].sudo().browse(
|
||||||
session_data['coating_config_id'],
|
session_data['coating_config_id'],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,69 +246,75 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
'special_instructions': kw.get('special_instructions', ''),
|
'special_instructions': kw.get('special_instructions', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Link coating process type
|
# Link the selected process type (coating IS the process type now
|
||||||
if coating.exists() and coating.process_type_id:
|
# that fp.coating.config is retired).
|
||||||
vals['process_type_ids'] = [(4, coating.process_type_id.id)]
|
if coating.exists():
|
||||||
|
vals['process_type_ids'] = [(4, coating.id)]
|
||||||
|
|
||||||
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
|
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
|
||||||
|
|
||||||
# Attach uploaded file to the quote request
|
# Re-key uploaded attachments onto the new quote request so they
|
||||||
attachment_id = session_data.get('attachment_id')
|
# appear on its chatter. Multi-upload — customer may have sent
|
||||||
if attachment_id:
|
# both a drawing and a 3D model (or more).
|
||||||
attachment = request.env['ir.attachment'].sudo().browse(attachment_id)
|
att_ids = session_data.get('attachment_ids') or []
|
||||||
|
if not att_ids and session_data.get('attachment_id'):
|
||||||
|
# Back-compat for old session shape (single attachment_id key).
|
||||||
|
att_ids = [session_data['attachment_id']]
|
||||||
|
for att_id in att_ids:
|
||||||
|
attachment = request.env['ir.attachment'].sudo().browse(att_id)
|
||||||
if attachment.exists():
|
if attachment.exists():
|
||||||
attachment.write({
|
attachment.write({
|
||||||
'res_model': 'fusion.plating.quote.request',
|
'res_model': 'fusion.plating.quote.request',
|
||||||
'res_id': quote.id,
|
'res_id': quote.id,
|
||||||
})
|
})
|
||||||
quote.drawing_attachment_ids = [(4, attachment.id)]
|
if 'drawing_attachment_ids' in quote._fields:
|
||||||
|
quote.drawing_attachment_ids = [(4, attachment.id)]
|
||||||
|
|
||||||
# Clear session
|
# Clear session
|
||||||
request.session.pop('fp_configurator', None)
|
request.session.pop('fp_configurator', None)
|
||||||
|
|
||||||
return request.render('fusion_plating_portal.portal_configurator_success', {
|
values = self._prepare_portal_layout_values()
|
||||||
|
values.update({
|
||||||
'page_name': 'fp_configurator',
|
'page_name': 'fp_configurator',
|
||||||
'quote': quote,
|
'quote': quote,
|
||||||
})
|
})
|
||||||
|
return request.render('fusion_plating_portal.portal_configurator_success', values)
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Pricing helper
|
# Pricing helper
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
def _estimate_price(self, session_data, coating):
|
def _estimate_price(self, session_data, coating):
|
||||||
"""Calculate estimated price range from pricing rules.
|
"""Best-effort price estimate. Returns {'min', 'max', 'available'}.
|
||||||
|
|
||||||
Returns a dict with ``min``, ``max``, and ``available`` keys.
|
Post-coating-retire (Sub-11) the rule schema still references
|
||||||
The range is deliberately wide (+/- 15-25%) because final quotes
|
fp.coating.config; ``coating`` here is now a process.type record.
|
||||||
account for masking complexity, rack configuration, etc.
|
That means rules with a ``coating_config_id`` set won't match
|
||||||
|
and we silently fall through to {'available': False} — which
|
||||||
|
the template renders as 'Quote will be priced by EN Plating'.
|
||||||
|
Estimate is non-essential; final price comes from EN's review.
|
||||||
"""
|
"""
|
||||||
rules = request.env['fp.pricing.rule'].sudo().search(
|
Rule = request.env.get('fp.pricing.rule')
|
||||||
[('active', '=', True)], order='sequence',
|
if Rule is None:
|
||||||
)
|
return {'min': 0, 'max': 0, 'available': False}
|
||||||
|
rules = Rule.sudo().search([('active', '=', True)], order='sequence')
|
||||||
area = float(session_data.get('surface_area', 0))
|
area = float(session_data.get('surface_area', 0))
|
||||||
qty = int(session_data.get('quantity', 1))
|
qty = int(session_data.get('quantity', 1))
|
||||||
substrate = session_data.get('substrate_material', '')
|
substrate = session_data.get('substrate_material', '')
|
||||||
cert_level = coating.certification_level if coating else 'commercial'
|
|
||||||
|
|
||||||
if not area or not rules:
|
if not area or not rules:
|
||||||
return {'min': 0, 'max': 0, 'available': False}
|
return {'min': 0, 'max': 0, 'available': False}
|
||||||
|
|
||||||
# Find best matching rule (same scoring as fp.quote.configurator)
|
|
||||||
best = None
|
best = None
|
||||||
best_score = -1
|
best_score = -1
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
score = 0
|
score = 0
|
||||||
if rule.coating_config_id:
|
# Skip any rule keyed to coating_config — model is gone.
|
||||||
if rule.coating_config_id.id != coating.id:
|
if 'coating_config_id' in rule._fields and rule.coating_config_id:
|
||||||
continue
|
continue
|
||||||
score += 4
|
if getattr(rule, 'substrate_material', None):
|
||||||
if rule.substrate_material:
|
|
||||||
if rule.substrate_material != substrate:
|
if rule.substrate_material != substrate:
|
||||||
continue
|
continue
|
||||||
score += 2
|
score += 2
|
||||||
if rule.certification_level:
|
|
||||||
if rule.certification_level != cert_level:
|
|
||||||
continue
|
|
||||||
score += 1
|
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best = rule
|
best = rule
|
||||||
@@ -283,7 +322,6 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
if not best:
|
if not best:
|
||||||
return {'min': 0, 'max': 0, 'available': False}
|
return {'min': 0, 'max': 0, 'available': False}
|
||||||
|
|
||||||
# Calculate base price
|
|
||||||
if best.pricing_method == 'per_sqin':
|
if best.pricing_method == 'per_sqin':
|
||||||
unit = area * best.base_rate
|
unit = area * best.base_rate
|
||||||
elif best.pricing_method == 'per_sqft':
|
elif best.pricing_method == 'per_sqft':
|
||||||
@@ -293,17 +331,10 @@ class FpPortalConfigurator(CustomerPortal):
|
|||||||
else:
|
else:
|
||||||
unit = best.base_rate
|
unit = best.base_rate
|
||||||
|
|
||||||
# Apply thickness factor (use min thickness from coating)
|
base_total = unit * qty + (best.setup_fee or 0)
|
||||||
thickness = coating.thickness_min or 1.0
|
|
||||||
unit *= thickness * best.thickness_factor
|
|
||||||
|
|
||||||
base_total = unit * qty + best.setup_fee
|
|
||||||
|
|
||||||
# Apply minimum charge
|
|
||||||
if best.minimum_charge and base_total < best.minimum_charge:
|
if best.minimum_charge and base_total < best.minimum_charge:
|
||||||
base_total = best.minimum_charge
|
base_total = best.minimum_charge
|
||||||
|
|
||||||
# Return a range (85% to 125%) to account for complexity, masking, etc.
|
|
||||||
return {
|
return {
|
||||||
'min': round(base_total * 0.85, 2),
|
'min': round(base_total * 0.85, 2),
|
||||||
'max': round(base_total * 1.25, 2),
|
'max': round(base_total * 1.25, 2),
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Fusion Plating — Portal Account Summary
|
||||||
|
* Wires the sort dropdown change event to navigate to the option's value
|
||||||
|
* (which is a fully-formed /my/account_summary URL). Replaces an inline
|
||||||
|
* `onchange` attribute on the <select> so the template stays CSP-clean.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
document.querySelectorAll(".o_fp_account_summary select.o_fp_sort_select").forEach(function (sel) {
|
||||||
|
sel.addEventListener("change", function () {
|
||||||
|
if (sel.value) {
|
||||||
|
window.location.href = sel.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Fusion Plating — portal list search + filter UI helper.
|
||||||
|
*
|
||||||
|
* Provides client-side, real-time multi-keyword filtering for any portal
|
||||||
|
* list page that opts in via the markup contract below. Pure vanilla JS,
|
||||||
|
* no framework, no debounce (client-side filter is <1ms even on 500 rows).
|
||||||
|
*
|
||||||
|
* Markup contract:
|
||||||
|
* - One <input class="o_fp_list_search" data-fp-target="<container-selector>"/>
|
||||||
|
* - One container matching <container-selector> with attribute data-fp-filterable
|
||||||
|
* whose direct children are the filterable rows (e.g. <tr> in a tbody,
|
||||||
|
* or <div class="o_fp_job_card_wrap"> children of #fp_jobs_list).
|
||||||
|
* - Optional: <span class="o_fp_list_search_count"/> updates with N visible
|
||||||
|
* of M total when a filter is active. Empty (no text) when search is empty.
|
||||||
|
*
|
||||||
|
* Each row's textContent is matched against the user's keywords using a
|
||||||
|
* lowercase AND across whitespace-split tokens. Extra non-visible search
|
||||||
|
* terms can be added per row as <span class="d-none" data-fp-search="..."/>.
|
||||||
|
*
|
||||||
|
* Sort dropdown: any <select class="o_fp_sort_select"> on the page navigates
|
||||||
|
* to the selected option's value URL on change. This file wires ALL such
|
||||||
|
* selects on any page, so the scope is not limited to .o_fp_account_summary.
|
||||||
|
* fp_portal_account_summary.js limits itself to .o_fp_account_summary scope
|
||||||
|
* to avoid double-firing on the Account Summary page. These two files are
|
||||||
|
* safe to coexist as long as Account Summary wraps its select inside the
|
||||||
|
* .o_fp_account_summary container (which it does).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function initSearch() {
|
||||||
|
document.querySelectorAll("input.o_fp_list_search").forEach(function (input) {
|
||||||
|
var targetSelector = input.getAttribute("data-fp-target");
|
||||||
|
if (!targetSelector) { return; }
|
||||||
|
var container = document.querySelector(targetSelector);
|
||||||
|
if (!container) { return; }
|
||||||
|
|
||||||
|
var countEl = document.querySelector(".o_fp_list_search_count");
|
||||||
|
var totalRows = container.children.length;
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var raw = (input.value || "").trim().toLowerCase();
|
||||||
|
var tokens = raw.split(/\s+/).filter(Boolean);
|
||||||
|
var visible = 0;
|
||||||
|
Array.prototype.forEach.call(container.children, function (row) {
|
||||||
|
var text = (row.textContent || "").toLowerCase();
|
||||||
|
var match = tokens.length === 0 || tokens.every(function (t) {
|
||||||
|
return text.indexOf(t) !== -1;
|
||||||
|
});
|
||||||
|
row.style.display = match ? "" : "none";
|
||||||
|
if (match) { visible++; }
|
||||||
|
});
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = tokens.length === 0
|
||||||
|
? ""
|
||||||
|
: visible + " of " + totalRows + " matching";
|
||||||
|
// Toggle visibility
|
||||||
|
countEl.classList.toggle("d-none", tokens.length === 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", applyFilter);
|
||||||
|
// Run once on load in case the input has a prefilled value
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortSelects() {
|
||||||
|
// Wire ALL .o_fp_sort_select dropdowns that are NOT inside
|
||||||
|
// .o_fp_account_summary (that page has its own handler in
|
||||||
|
// fp_portal_account_summary.js to avoid double-firing).
|
||||||
|
document.querySelectorAll(".o_fp_sort_select").forEach(function (sel) {
|
||||||
|
if (sel.closest(".o_fp_account_summary")) { return; }
|
||||||
|
sel.addEventListener("change", function () {
|
||||||
|
if (sel.value) {
|
||||||
|
window.location.href = sel.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initSearch();
|
||||||
|
initSortSelects();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Fusion Plating — Portal sidebar hamburger toggle.
|
||||||
|
* Vanilla JS — no OWL / no jQuery. Loaded on every /my/* page.
|
||||||
|
* Below 768px the sidebar is translateX(-100%); toggling
|
||||||
|
* .o_fp_open on both sidebar + backdrop shows/hides it.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var sidebar = document.querySelector(".o_fp_portal_sidebar");
|
||||||
|
var hamburger = document.querySelector(".o_fp_portal_hamburger");
|
||||||
|
var backdrop = document.querySelector(".o_fp_portal_backdrop");
|
||||||
|
if (!sidebar || !hamburger || !backdrop) {
|
||||||
|
return; // sidebar not on this page (logged-out, error pages, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen(force) {
|
||||||
|
var willOpen = (typeof force === "boolean")
|
||||||
|
? force
|
||||||
|
: !sidebar.classList.contains("o_fp_open");
|
||||||
|
sidebar.classList.toggle("o_fp_open", willOpen);
|
||||||
|
backdrop.classList.toggle("o_fp_open", willOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
hamburger.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleOpen();
|
||||||
|
});
|
||||||
|
backdrop.addEventListener("click", function () {
|
||||||
|
toggleOpen(false);
|
||||||
|
});
|
||||||
|
// Close when navigating to a sidebar link on mobile
|
||||||
|
sidebar.querySelectorAll("a.o_fp_sidebar_item").forEach(function (a) {
|
||||||
|
a.addEventListener("click", function () {
|
||||||
|
if (window.innerWidth < 769) {
|
||||||
|
toggleOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize safety: closing the drawer when the user crosses the desktop
|
||||||
|
// breakpoint prevents the backdrop's display:block from leaking onto
|
||||||
|
// the desktop layout. SCSS scopes the sidebar's drawer transform to
|
||||||
|
// @media (max-width: 768px), but the backdrop's display rule isn't
|
||||||
|
// media-scoped (intentionally — the JS owns that lifecycle).
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
if (window.innerWidth > 768 && sidebar.classList.contains("o_fp_open")) {
|
||||||
|
toggleOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -86,3 +86,35 @@
|
|||||||
// Size modifiers — match Bootstrap btn-sm / btn-lg sizing
|
// Size modifiers — match Bootstrap btn-sm / btn-lg sizing
|
||||||
.o_fp_btn_sm { padding: .25rem .5rem; font-size: .875rem; }
|
.o_fp_btn_sm { padding: .25rem .5rem; font-size: .875rem; }
|
||||||
.o_fp_btn_lg { padding: .5rem 1rem; font-size: 1.25rem; }
|
.o_fp_btn_lg { padding: .5rem 1rem; font-size: 1.25rem; }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Globally suppress browser-default underline-on-hover for portal
|
||||||
|
// surfaces. Bootstrap's Reboot puts `a:hover { text-decoration: underline }`
|
||||||
|
// on every anchor; for our flat-aesthetic chips / cards / pill buttons that
|
||||||
|
// reads as a buggy visual artifact. Hover signal lives in color + shadow
|
||||||
|
// instead. Specificity is high enough to win without !important.
|
||||||
|
// ============================================================================
|
||||||
|
.o_fp_btn,
|
||||||
|
.o_fp_btn_primary,
|
||||||
|
.o_fp_btn_secondary,
|
||||||
|
.o_fp_btn_ghost,
|
||||||
|
.o_fp_btn_danger,
|
||||||
|
.o_fp_btn_mint,
|
||||||
|
.o_fp_dashboard a,
|
||||||
|
.o_fp_job_detail a,
|
||||||
|
.o_fp_job_card,
|
||||||
|
.o_fp_doc_chip,
|
||||||
|
.o_fp_doc_row,
|
||||||
|
.o_fp_panel_view_all,
|
||||||
|
.o_fp_panel_inline_cta,
|
||||||
|
.o_fp_kpi_hint,
|
||||||
|
.o_fp_view_all a,
|
||||||
|
.o_fp_status_tab,
|
||||||
|
.o_fp_related_links a {
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,61 +91,131 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job card: outer wrap is a plain div so we can place an interactive
|
||||||
|
// actions footer (Repeat Order form, doc download links) as a SIBLING
|
||||||
|
// of the main anchor — forms inside anchors are invalid HTML and
|
||||||
|
// browser-buggy. Hover/lift effect lives on the wrap; click target is
|
||||||
|
// the inner .o_fp_job_card_main anchor only.
|
||||||
.o_fp_job_card {
|
.o_fp_job_card {
|
||||||
@extend .o_fp_card;
|
@extend .o_fp_card;
|
||||||
padding: $fp-space-4;
|
padding: $fp-space-4;
|
||||||
border-radius: $fp-radius-tile;
|
border-radius: $fp-radius-tile;
|
||||||
margin-bottom: $fp-space-3;
|
margin-bottom: $fp-space-3;
|
||||||
box-shadow: $fp-shadow-card;
|
box-shadow: $fp-shadow-card;
|
||||||
|
transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease;
|
||||||
|
|
||||||
// Works for both <div> and <a> wrappers. When rendered as an anchor
|
&:hover {
|
||||||
// the whole card becomes a click target (jobs list + dashboard).
|
box-shadow: $fp-shadow-card-hover;
|
||||||
|
border-color: $fp-aqua;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_job_card_main {
|
||||||
display: block;
|
display: block;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease;
|
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: $fp-shadow-card-hover;
|
|
||||||
border-color: $fp-aqua;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid $fp-teal;
|
outline: 2px solid $fp-teal;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.o_fp_job_header {
|
.o_fp_job_header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-bottom: $fp-space-3;
|
margin-bottom: .55rem;
|
||||||
|
gap: .65rem;
|
||||||
|
|
||||||
.o_fp_job_ref {
|
.o_fp_job_ref {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $fp-text;
|
color: $fp-text;
|
||||||
font-size: .98rem;
|
font-size: .98rem;
|
||||||
}
|
|
||||||
.o_fp_job_meta {
|
|
||||||
color: $fp-muted;
|
|
||||||
font-size: .8rem;
|
|
||||||
margin-left: .65rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.o_fp_job_meta {
|
||||||
.o_fp_job_docs {
|
color: $fp-muted;
|
||||||
display: flex;
|
font-size: .8rem;
|
||||||
flex-wrap: wrap;
|
margin-left: .55rem;
|
||||||
gap: .35rem;
|
|
||||||
margin-top: $fp-space-3;
|
|
||||||
padding-top: .6rem;
|
|
||||||
border-top: 1px solid $fp-section-bg;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Part name / number row under the header
|
||||||
|
.o_fp_job_part {
|
||||||
|
color: $fp-text-body;
|
||||||
|
font-size: .78rem;
|
||||||
|
margin-bottom: .2rem;
|
||||||
|
.o_fp_job_part_icon { color: $fp-muted-light; margin-right: .3rem; }
|
||||||
|
}
|
||||||
|
// Shipping address row
|
||||||
|
.o_fp_job_ship {
|
||||||
|
color: $fp-muted;
|
||||||
|
font-size: .76rem;
|
||||||
|
margin-bottom: $fp-space-3;
|
||||||
|
.o_fp_job_ship_icon { color: $fp-muted-light; margin-right: .3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions footer — siblings of the main anchor. Doc download chips on
|
||||||
|
// the left, Repeat Order on the right. Border-top visually separates.
|
||||||
|
.o_fp_job_card_actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
margin-top: $fp-space-3;
|
||||||
|
padding-top: .7rem;
|
||||||
|
border-top: 1px solid $fp-section-bg;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.o_fp_job_card_docs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
// Compact icon-only download chip. Tooltip via `title` attr.
|
||||||
|
.o_fp_doc_quick_btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .3rem .55rem;
|
||||||
|
background: $fp-section-bg;
|
||||||
|
color: $fp-teal;
|
||||||
|
border: 1px solid $fp-card-border;
|
||||||
|
border-radius: $fp-radius-chip;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
&:hover {
|
||||||
|
background: $fp-mint;
|
||||||
|
color: $fp-teal-dark;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
&.o_fp_doc_quick_btn_pending {
|
||||||
|
background: $fp-card-bg;
|
||||||
|
color: $fp-muted-light;
|
||||||
|
border: 1px dashed $fp-card-border-dark;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy: kept for any place still rendering chips below the stepper.
|
||||||
|
.o_fp_job_docs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .35rem;
|
||||||
|
margin-top: $fp-space-3;
|
||||||
|
padding-top: .6rem;
|
||||||
|
border-top: 1px solid $fp-section-bg;
|
||||||
|
}
|
||||||
|
|
||||||
.o_fp_secondary_panels {
|
.o_fp_secondary_panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
// Auto-fit so 5 panels arrange nicely as 3+2 / 2+2+1 / 1 column at
|
// Auto-fit so 5 panels arrange nicely as 3+2 / 2+2+1 / 1 column at
|
||||||
@@ -200,3 +270,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter pills used by Account Summary (also reusable elsewhere)
|
||||||
|
.o_fp_filter_pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .25rem .75rem;
|
||||||
|
border-radius: $fp-radius-pill;
|
||||||
|
background: $fp-section-bg;
|
||||||
|
color: $fp-muted;
|
||||||
|
font-size: .8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
&:hover { background: $fp-mint; color: $fp-teal-dark; text-decoration: none; }
|
||||||
|
&.o_fp_filter_pill_active {
|
||||||
|
background: $fp-gradient-primary;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Fusion Plating — Portal · Sidebar shell
|
||||||
|
// Sticky 240px left rail wrapping every /my/* page. Grouped sections
|
||||||
|
// (Dashboard / ACTIVITY / DOCUMENTS / ACCOUNT). Active page = mint
|
||||||
|
// gradient fill + brand teal left bar. Below 768px collapses to a
|
||||||
|
// hamburger drawer with backdrop.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.o_fp_portal_shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: $fp-space-5;
|
||||||
|
background: $fp-page-bg;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
padding: $fp-space-4;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
padding: $fp-space-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_portal_sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: $fp-space-4;
|
||||||
|
background: $fp-card-bg;
|
||||||
|
border: 1px solid $fp-card-border;
|
||||||
|
border-radius: $fp-radius-card;
|
||||||
|
padding: .85rem .5rem;
|
||||||
|
box-shadow: $fp-shadow-card;
|
||||||
|
font-family: $fp-font;
|
||||||
|
align-self: start;
|
||||||
|
|
||||||
|
.o_fp_sidebar_header {
|
||||||
|
padding: .45rem .9rem .7rem;
|
||||||
|
font-size: .62rem;
|
||||||
|
color: $fp-muted;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid $fp-section-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_sidebar_section_label {
|
||||||
|
padding: .85rem .9rem .25rem;
|
||||||
|
font-size: .62rem;
|
||||||
|
color: $fp-muted-light;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_sidebar_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
padding: .5rem .9rem;
|
||||||
|
margin: .05rem .15rem;
|
||||||
|
color: $fp-text-body;
|
||||||
|
font-size: .85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $fp-section-bg;
|
||||||
|
color: $fp-teal-dark;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
&.o_fp_sidebar_active {
|
||||||
|
background: linear-gradient(90deg, $fp-mint 0%, $fp-mint-pastel 100%);
|
||||||
|
color: $fp-teal-dark;
|
||||||
|
font-weight: 600;
|
||||||
|
border-left: 3px solid $fp-teal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_sidebar_icon {
|
||||||
|
width: 1.15rem;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_sidebar_footer {
|
||||||
|
border-top: 1px solid $fp-section-bg;
|
||||||
|
margin: .7rem .15rem 0;
|
||||||
|
padding-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: slide-in drawer
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
z-index: 1040;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform .2s ease;
|
||||||
|
border-radius: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
border-left: none;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&.o_fp_open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile hamburger button (above main content, hidden on desktop)
|
||||||
|
.o_fp_portal_hamburger {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: $fp-card-bg;
|
||||||
|
border: 1px solid $fp-card-border;
|
||||||
|
border-radius: $fp-radius-button;
|
||||||
|
color: $fp-teal;
|
||||||
|
margin-bottom: $fp-space-3;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s ease;
|
||||||
|
|
||||||
|
&:hover { background: $fp-section-bg; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdrop behind the open mobile drawer
|
||||||
|
.o_fp_portal_backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 30, 30, .35);
|
||||||
|
z-index: 1030;
|
||||||
|
|
||||||
|
&.o_fp_open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_portal_main {
|
||||||
|
// Stretches with the grid row so the right column matches the
|
||||||
|
// sidebar's height on short pages (empty list states, statements
|
||||||
|
// tab, etc.) — uniform visual rhythm.
|
||||||
|
min-height: 100%;
|
||||||
|
// Bootstrap tables can grow wider than the grid track without this;
|
||||||
|
// min-width: 0 lets the flex/grid child shrink and lets overflow-x
|
||||||
|
// on inner .table-responsive containers do their job on Safari.
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
// Neutralise Odoo's container pt-3 + templates' mt-3 on the first
|
||||||
|
// child so the right column's top aligns flush with the sidebar's
|
||||||
|
// top edge. !important is required because Bootstrap 5 spacing
|
||||||
|
// utilities (.pt-3, .mt-3) ship with !important by default — without
|
||||||
|
// matching specificity Bootstrap wins and the right column sits
|
||||||
|
// ~32px (pt-3 + mt-3) lower than the sidebar.
|
||||||
|
#wrap > .container {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
#wrap > .container > :first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odoo's portal.portal_searchbar still renders an inline breadcrumb
|
||||||
|
// inside its navbar when the page sets breadcrumbs_searchbar=True.
|
||||||
|
// Now that our shell forces the outer breadcrumb to ALWAYS render
|
||||||
|
// (via the fp_portal_shell xpath above), the inline copy would be
|
||||||
|
// a visible duplicate. Hide it; the navbar's other content (title,
|
||||||
|
// sort/filter dropdowns) stays visible.
|
||||||
|
.o_portal_navbar > .breadcrumb,
|
||||||
|
.o_portal_navbar > ol.breadcrumb {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,8 +117,9 @@ class TestPortalDashboard(TransactionCase):
|
|||||||
statuses = [t['status'] for t in timeline]
|
statuses = [t['status'] for t in timeline]
|
||||||
self.assertEqual(statuses, ['done', 'done', 'done', 'done', 'done'])
|
self.assertEqual(statuses, ['done', 'done', 'done', 'done', 'done'])
|
||||||
|
|
||||||
def test_group_documents_v1_returns_4_groups(self):
|
def test_group_documents_returns_5_groups(self):
|
||||||
"""V1 doc grouping returns 4 groups; quality populated when CoC set."""
|
"""V1 doc grouping returns 5 groups: From You / Specs / Work Order /
|
||||||
|
Quality / Shipping. Quality populated when CoC set."""
|
||||||
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||||
Job = self.env['fusion.plating.portal.job']
|
Job = self.env['fusion.plating.portal.job']
|
||||||
att = self.env['ir.attachment'].create({
|
att = self.env['ir.attachment'].create({
|
||||||
@@ -133,13 +134,137 @@ class TestPortalDashboard(TransactionCase):
|
|||||||
'coc_attachment_id': att.id,
|
'coc_attachment_id': att.id,
|
||||||
})
|
})
|
||||||
groups = FpCustomerPortal()._fp_group_documents(job)
|
groups = FpCustomerPortal()._fp_group_documents(job)
|
||||||
self.assertEqual(len(groups), 4)
|
self.assertEqual(len(groups), 5)
|
||||||
keys = [g['key'] for g in groups]
|
keys = [g['key'] for g in groups]
|
||||||
self.assertEqual(keys, ['from_you', 'specs', 'quality', 'shipping'])
|
self.assertEqual(keys, ['from_you', 'specs', 'work_order', 'quality', 'shipping'])
|
||||||
# Quality group has the CoC populated (not pending)
|
# Quality group has the CoC populated (not pending)
|
||||||
quality = next(g for g in groups if g['key'] == 'quality')
|
quality = next(g for g in groups if g['key'] == 'quality')
|
||||||
self.assertTrue(any(d['label'] == 'Certificate of Conformance' and not d.get('pending')
|
self.assertTrue(any(d['label'] == 'Certificate of Conformance' and not d.get('pending')
|
||||||
for d in quality['docs']))
|
for d in quality['docs']))
|
||||||
# From You + Specifications are placeholders in V1
|
# From You is a placeholder when no SO link (test job has no x_fc_job_id)
|
||||||
from_you = next(g for g in groups if g['key'] == 'from_you')
|
from_you = next(g for g in groups if g['key'] == 'from_you')
|
||||||
self.assertTrue(all(d.get('pending') for d in from_you['docs']))
|
self.assertTrue(all(d.get('pending') for d in from_you['docs']))
|
||||||
|
# Work Order is placeholder without a backend fp.job link
|
||||||
|
wo = next(g for g in groups if g['key'] == 'work_order')
|
||||||
|
self.assertTrue(all(d.get('pending') for d in wo['docs']))
|
||||||
|
|
||||||
|
def test_account_summary_partitions_invoices_and_credits(self):
|
||||||
|
"""Account Summary helper splits posted moves by move_type."""
|
||||||
|
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||||
|
# fp_from_so_invoice=True bypasses the fusion_plating_jobs enforcement
|
||||||
|
# that normally requires invoices to originate from a Sale Order.
|
||||||
|
# payment_term is required by fusion_plating_invoicing's action_post gate.
|
||||||
|
# Both are test-data scaffolding only; they do not affect what is tested.
|
||||||
|
pt = self.env.ref('account.account_payment_term_immediate')
|
||||||
|
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
|
||||||
|
inv = Move.create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': '2026-05-01',
|
||||||
|
'invoice_payment_term_id': pt.id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': 'Test plating',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': 250.00,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
inv.action_post()
|
||||||
|
cm = Move.create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'move_type': 'out_refund',
|
||||||
|
'invoice_date': '2026-05-02',
|
||||||
|
'invoice_payment_term_id': pt.id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': 'Test credit',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': 50.00,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
cm.action_post()
|
||||||
|
|
||||||
|
controller = FpCustomerPortal()
|
||||||
|
data = controller._fp_account_summary_data(
|
||||||
|
self.partner.commercial_partner_id,
|
||||||
|
tab='invoices',
|
||||||
|
filter_state='all',
|
||||||
|
search='',
|
||||||
|
sort='date_desc',
|
||||||
|
page=1,
|
||||||
|
)
|
||||||
|
# Tab=invoices -> only out_invoice
|
||||||
|
names = data['records'].mapped('name')
|
||||||
|
self.assertIn(inv.name, names)
|
||||||
|
self.assertNotIn(cm.name, names)
|
||||||
|
|
||||||
|
data = controller._fp_account_summary_data(
|
||||||
|
self.partner.commercial_partner_id,
|
||||||
|
tab='credit_memos',
|
||||||
|
filter_state='all',
|
||||||
|
search='',
|
||||||
|
sort='date_desc',
|
||||||
|
page=1,
|
||||||
|
)
|
||||||
|
names = data['records'].mapped('name')
|
||||||
|
self.assertIn(cm.name, names)
|
||||||
|
self.assertNotIn(inv.name, names)
|
||||||
|
|
||||||
|
def test_account_summary_open_balance_sums_residuals(self):
|
||||||
|
"""Open Balance pill = sum of amount_residual across open invoices."""
|
||||||
|
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||||
|
pt = self.env.ref('account.account_payment_term_immediate')
|
||||||
|
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
|
||||||
|
inv = Move.create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': '2026-05-01',
|
||||||
|
'invoice_payment_term_id': pt.id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': 'Open inv',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': 750.00,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
inv.action_post()
|
||||||
|
|
||||||
|
controller = FpCustomerPortal()
|
||||||
|
open_balance = controller._fp_account_summary_open_balance(
|
||||||
|
self.partner.commercial_partner_id,
|
||||||
|
)
|
||||||
|
# The 750 invoice has amount_residual = 750 until paid
|
||||||
|
self.assertEqual(open_balance, 750.00)
|
||||||
|
|
||||||
|
def test_account_summary_search_matches_name_and_ref(self):
|
||||||
|
"""Search box filters by invoice number OR customer PO (ref)."""
|
||||||
|
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
|
||||||
|
pt = self.env.ref('account.account_payment_term_immediate')
|
||||||
|
Move = self.env['account.move'].with_context(fp_from_so_invoice=True)
|
||||||
|
inv = Move.create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': '2026-05-01',
|
||||||
|
'invoice_payment_term_id': pt.id,
|
||||||
|
'ref': 'PO-CUSTOMER-99999',
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': 'Sale',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': 100.0,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
inv.action_post()
|
||||||
|
controller = FpCustomerPortal()
|
||||||
|
|
||||||
|
# Search by ref (customer PO)
|
||||||
|
data = controller._fp_account_summary_data(
|
||||||
|
self.partner.commercial_partner_id,
|
||||||
|
tab='invoices', filter_state='all',
|
||||||
|
search='99999', sort='date_desc', page=1,
|
||||||
|
)
|
||||||
|
self.assertIn(inv, data['records'])
|
||||||
|
|
||||||
|
# Search that matches nothing
|
||||||
|
data = controller._fp_account_summary_data(
|
||||||
|
self.partner.commercial_partner_id,
|
||||||
|
tab='invoices', filter_state='all',
|
||||||
|
search='zzznotfoundzzz', sort='date_desc', page=1,
|
||||||
|
)
|
||||||
|
self.assertNotIn(inv, data['records'])
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<template id="portal_my_account_summary" name="Account Summary">
|
||||||
|
<t t-call="portal.portal_layout">
|
||||||
|
<div class="o_fp_account_summary">
|
||||||
|
|
||||||
|
<!-- Page header: title + Open Balance pill -->
|
||||||
|
<div class="d-flex justify-content-between align-items-baseline mb-3">
|
||||||
|
<h3 class="mb-0" style="color: var(--fp-text, #111827)">Account Summary</h3>
|
||||||
|
<div class="o_fp_badge o_fp_badge_paid" t-if="open_balance">
|
||||||
|
<span class="o_fp_badge_dot"/>
|
||||||
|
Open Balance:
|
||||||
|
<span t-out="open_balance_display"/>
|
||||||
|
</div>
|
||||||
|
<span class="o_fp_badge" t-else=""
|
||||||
|
style="background:#f3f7f6;color:#374151">
|
||||||
|
Open Balance: $0.00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab strip -->
|
||||||
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||||
|
<t t-foreach="tabs" t-as="tab_entry">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a t-attf-href="/my/account_summary?tab=#{tab_entry[0]}"
|
||||||
|
t-attf-class="nav-link #{'active' if active_tab == tab_entry[0] else ''}"
|
||||||
|
t-out="tab_entry[1]"/>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Filter pills + search + sort -->
|
||||||
|
<t t-if="active_tab != 'statements'">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="text-muted small">Showing:</span>
|
||||||
|
<t t-foreach="['open', 'closed', 'all']" t-as="fk">
|
||||||
|
<a t-attf-href="/my/account_summary?tab=#{active_tab}&filter_state=#{fk}&sort=#{sort}&search=#{search}"
|
||||||
|
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if filter_state == fk else ''}"
|
||||||
|
t-out="fk.capitalize()"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<form method="GET" action="/my/account_summary" class="d-flex gap-1 ms-auto m-0">
|
||||||
|
<input type="hidden" name="tab" t-att-value="active_tab"/>
|
||||||
|
<input type="hidden" name="filter_state" t-att-value="filter_state"/>
|
||||||
|
<input type="hidden" name="sort" t-att-value="sort"/>
|
||||||
|
<input type="text" name="search" t-att-value="search"
|
||||||
|
placeholder="Search invoice # or PO #"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
style="max-width: 260px"/>
|
||||||
|
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">Search</button>
|
||||||
|
</form>
|
||||||
|
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 200px">
|
||||||
|
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_desc&search=' + search"
|
||||||
|
t-att-selected="sort == 'date_desc'">Newest first</option>
|
||||||
|
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_asc&search=' + search"
|
||||||
|
t-att-selected="sort == 'date_asc'">Oldest first</option>
|
||||||
|
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_desc&search=' + search"
|
||||||
|
t-att-selected="sort == 'amount_desc'">Largest amount</option>
|
||||||
|
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_asc&search=' + search"
|
||||||
|
t-att-selected="sort == 'amount_asc'">Smallest amount</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<t t-if="active_tab == 'statements'">
|
||||||
|
<div class="o_fp_card text-center text-muted" style="padding: 2rem">
|
||||||
|
<p>Monthly statements coming soon.</p>
|
||||||
|
<p class="small">
|
||||||
|
For a copy in the meantime, contact your sales rep at EN Plating.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-elif="not records">
|
||||||
|
<div class="o_fp_card text-center text-muted" style="padding: 1.5rem">
|
||||||
|
<t t-if="search">No results for "<t t-out="search"/>".</t>
|
||||||
|
<t t-else="">No records in this tab.</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="o_fp_card" style="padding: 0; overflow: hidden">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Posted On</th>
|
||||||
|
<th>PO #</th>
|
||||||
|
<th>Due Date</th>
|
||||||
|
<th class="text-end">Balance</th>
|
||||||
|
<th class="text-end">View PDF</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="records" t-as="move">
|
||||||
|
<td t-out="move.name"/>
|
||||||
|
<td>
|
||||||
|
<t t-if="move.amount_residual == 0">
|
||||||
|
<span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Closed</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="o_fp_badge o_fp_badge_in_progress"><span class="o_fp_badge_dot"/>Open</span>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span t-if="move.invoice_date"
|
||||||
|
t-field="move.invoice_date"
|
||||||
|
t-options='{"widget": "date"}'/>
|
||||||
|
</td>
|
||||||
|
<td t-out="move.ref or ''"/>
|
||||||
|
<td>
|
||||||
|
<span t-if="move.invoice_date_due"
|
||||||
|
t-field="move.invoice_date_due"
|
||||||
|
t-options='{"widget": "date"}'/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span t-field="move.amount_residual"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": move.currency_id}'/>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a t-attf-href="/my/invoices/#{move.id}?report_type=pdf&download=true"
|
||||||
|
class="o_fp_btn_ghost o_fp_btn_sm">View PDF</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pager -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3"
|
||||||
|
t-if="pager and pager.get('page_count', 0) > 1">
|
||||||
|
<div class="text-muted small">
|
||||||
|
Showing
|
||||||
|
<t t-out="pager['offset'] + 1"/>–<t t-out="min(pager['offset'] + 10, total)"/>
|
||||||
|
of <t t-out="total"/>
|
||||||
|
</div>
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
<t t-foreach="pager.get('pages', [])" t-as="p">
|
||||||
|
<li t-attf-class="page-item #{'active' if p['num'] == pager['page']['num'] else ''}">
|
||||||
|
<a class="page-link" t-att-href="p['url']" t-out="p['num']"/>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -62,13 +62,13 @@
|
|||||||
<li t-if="page_name == 'fp_jobs'"
|
<li t-if="page_name == 'fp_jobs'"
|
||||||
class="breadcrumb-item active"
|
class="breadcrumb-item active"
|
||||||
aria-current="page">
|
aria-current="page">
|
||||||
Parts Portal
|
Work Orders
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Job detail -->
|
<!-- Job detail -->
|
||||||
<li t-if="page_name == 'fp_portal_job'"
|
<li t-if="page_name == 'fp_portal_job'"
|
||||||
class="breadcrumb-item">
|
class="breadcrumb-item">
|
||||||
<a href="/my/jobs">Parts Portal</a>
|
<a href="/my/jobs">Work Orders</a>
|
||||||
</li>
|
</li>
|
||||||
<li t-if="page_name == 'fp_portal_job'"
|
<li t-if="page_name == 'fp_portal_job'"
|
||||||
class="breadcrumb-item active"
|
class="breadcrumb-item active"
|
||||||
@@ -76,18 +76,11 @@
|
|||||||
<span t-out="job.name"/>
|
<span t-out="job.name"/>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Purchase Orders -->
|
<!-- Account Summary -->
|
||||||
<li t-if="page_name == 'fp_purchase_orders'"
|
<li t-if="page_name == 'fp_account_summary'"
|
||||||
class="breadcrumb-item active"
|
class="breadcrumb-item active"
|
||||||
aria-current="page">
|
aria-current="page">
|
||||||
Purchase Orders
|
Account Summary
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Invoices -->
|
|
||||||
<li t-if="page_name == 'fp_invoices'"
|
|
||||||
class="breadcrumb-item active"
|
|
||||||
aria-current="page">
|
|
||||||
Invoices
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Deliveries / Packing Slips -->
|
<!-- Deliveries / Packing Slips -->
|
||||||
|
|||||||
@@ -156,67 +156,49 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Upload -->
|
<!-- File Uploads — separate drawing + 3D model.
|
||||||
<div class="mb-4">
|
Customer can upload either or both. STL gets
|
||||||
<label class="form-label">Part Drawing or 3D Model</label>
|
trimesh surface-area auto-calc server-side
|
||||||
<div class="o_fp_file_drop_zone p-4">
|
(not shown to customer — backend uses it for
|
||||||
<i class="fa fa-cloud-upload"/>
|
future pricing). -->
|
||||||
<p class="mb-1 fw-semibold">
|
<div class="row">
|
||||||
Drag and drop your file here, or click to browse
|
<div class="col-md-6 mb-4">
|
||||||
</p>
|
<label class="form-label">Drawing (PDF)</label>
|
||||||
<p class="small text-muted mb-2">
|
<div class="o_fp_file_drop_zone p-3">
|
||||||
Accepted: STL, STP, STEP, IGES, PDF (max 50 MB)
|
<i class="fa fa-file-pdf-o"/>
|
||||||
</p>
|
<p class="mb-1 fw-semibold">PDF drawing</p>
|
||||||
<input type="file" name="part_file" id="part_file"
|
<p class="small text-muted mb-2">
|
||||||
class="form-control"
|
2D / dimensioned drawing
|
||||||
accept=".stl,.stp,.step,.iges,.igs,.pdf"/>
|
</p>
|
||||||
|
<input type="file" name="part_drawing" id="part_drawing"
|
||||||
|
class="form-control"
|
||||||
|
accept=".pdf"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<label class="form-label">3D Model</label>
|
||||||
|
<div class="o_fp_file_drop_zone p-3">
|
||||||
|
<i class="fa fa-cube"/>
|
||||||
|
<p class="mb-1 fw-semibold">STL / STP / STEP / IGES</p>
|
||||||
|
<p class="small text-muted mb-2">
|
||||||
|
Optional — speeds up estimation
|
||||||
|
</p>
|
||||||
|
<input type="file" name="part_3d_model" id="part_3d_model"
|
||||||
|
class="form-control"
|
||||||
|
accept=".stl,.stp,.step,.iges,.igs"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-4"/>
|
<!-- Manual measurements hidden per customer-feedback 2026-05-17:
|
||||||
|
backend computes these (or doesn't) — not the
|
||||||
<!-- Manual Measurements -->
|
customer's job. Fields kept as hidden inputs at 0
|
||||||
<h6 class="mb-3">
|
so the controller doesn't error on missing keys. -->
|
||||||
<i class="fa fa-ruler-combined me-2"/>Manual Measurements
|
<input type="hidden" name="geometry_source" value="upload"/>
|
||||||
<span class="text-muted small fw-normal ms-2">
|
<input type="hidden" name="dimensions_length" value="0"/>
|
||||||
(if no 3D model uploaded)
|
<input type="hidden" name="dimensions_width" value="0"/>
|
||||||
</span>
|
<input type="hidden" name="dimensions_height" value="0"/>
|
||||||
</h6>
|
<input type="hidden" name="surface_area" value="0"/>
|
||||||
|
|
||||||
<input type="hidden" name="geometry_source" value="manual"/>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="dimensions_length" class="form-label">Length (in)</label>
|
|
||||||
<input type="number" step="0.001" min="0"
|
|
||||||
id="dimensions_length" name="dimensions_length"
|
|
||||||
class="form-control" placeholder="0.000"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="dimensions_width" class="form-label">Width (in)</label>
|
|
||||||
<input type="number" step="0.001" min="0"
|
|
||||||
id="dimensions_width" name="dimensions_width"
|
|
||||||
class="form-control" placeholder="0.000"/>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="dimensions_height" class="form-label">Height (in)</label>
|
|
||||||
<input type="number" step="0.001" min="0"
|
|
||||||
id="dimensions_height" name="dimensions_height"
|
|
||||||
class="form-control" placeholder="0.000"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="surface_area" class="form-label">
|
|
||||||
Surface Area (sq in)
|
|
||||||
<span class="text-muted small fw-normal ms-1">
|
|
||||||
-- auto-calculated if STL uploaded
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" step="0.0001" min="0"
|
|
||||||
id="surface_area" name="surface_area"
|
|
||||||
class="form-control" placeholder="0.0000"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
@@ -295,26 +277,13 @@
|
|||||||
<h6 class="card-title mb-2" style="color: var(--bs-body-color);">
|
<h6 class="card-title mb-2" style="color: var(--bs-body-color);">
|
||||||
<t t-out="coat.name"/>
|
<t t-out="coat.name"/>
|
||||||
</h6>
|
</h6>
|
||||||
<p t-if="coat.process_type_id" class="small text-muted mb-1">
|
<p t-if="coat.code" class="small text-muted mb-1">
|
||||||
|
<i class="fa fa-tag me-1"/>
|
||||||
|
<t t-out="coat.code"/>
|
||||||
|
</p>
|
||||||
|
<p t-if="coat.process_family" class="small text-muted mb-1">
|
||||||
<i class="fa fa-flask me-1"/>
|
<i class="fa fa-flask me-1"/>
|
||||||
<t t-out="coat.process_type_id.name"/>
|
<t t-out="dict(coat._fields['process_family']._description_selection(coat.env)).get(coat.process_family)"/>
|
||||||
</p>
|
|
||||||
<p t-if="coat.spec_reference" class="small text-muted mb-1">
|
|
||||||
<i class="fa fa-bookmark me-1"/>
|
|
||||||
<t t-out="coat.spec_reference"/>
|
|
||||||
</p>
|
|
||||||
<p t-if="coat.thickness_min or coat.thickness_max" class="small text-muted mb-1">
|
|
||||||
<i class="fa fa-arrows-v me-1"/>
|
|
||||||
<t t-if="coat.thickness_min" t-out="coat.thickness_min"/>
|
|
||||||
<t t-if="coat.thickness_min and coat.thickness_max"> - </t>
|
|
||||||
<t t-if="coat.thickness_max" t-out="coat.thickness_max"/>
|
|
||||||
<t t-out="coat.thickness_uom or 'mils'"/>
|
|
||||||
</p>
|
|
||||||
<p t-if="coat.certification_level and coat.certification_level != 'commercial'"
|
|
||||||
class="small mb-0">
|
|
||||||
<span class="badge text-bg-warning">
|
|
||||||
<t t-out="dict(coat._fields['certification_level']._description_selection(coat.env)).get(coat.certification_level)"/>
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p t-if="coat.description" class="small text-muted mt-2 mb-0"
|
<p t-if="coat.description" class="small text-muted mt-2 mb-0"
|
||||||
t-out="coat.description"/>
|
t-out="coat.description"/>
|
||||||
@@ -408,9 +377,9 @@
|
|||||||
<strong t-out="coating.name"/>
|
<strong t-out="coating.name"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div t-if="coating.spec_reference" class="row mb-3">
|
<div t-if="coating.code" class="row mb-3">
|
||||||
<div class="col-sm-4 text-muted small fw-semibold">Spec</div>
|
<div class="col-sm-4 text-muted small fw-semibold">Code</div>
|
||||||
<div class="col-sm-8" t-out="coating.spec_reference"/>
|
<div class="col-sm-8" t-out="coating.code"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-sm-4 text-muted small fw-semibold">Quantity</div>
|
<div class="col-sm-4 text-muted small fw-semibold">Quantity</div>
|
||||||
|
|||||||
@@ -38,9 +38,9 @@
|
|||||||
<a href="/my/quote_requests" class="o_fp_kpi_hint o_fp_hint_action">View quotes →</a>
|
<a href="/my/quote_requests" class="o_fp_kpi_hint o_fp_hint_action">View quotes →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_kpi_tile">
|
<div class="o_fp_kpi_tile">
|
||||||
<div class="o_fp_kpi_label">Active POs</div>
|
<div class="o_fp_kpi_label">Active Sales Orders</div>
|
||||||
<div class="o_fp_kpi_value" t-out="po_count"/>
|
<div class="o_fp_kpi_value" t-out="po_count"/>
|
||||||
<a href="/my/purchase_orders" class="o_fp_kpi_hint">View POs →</a>
|
<a href="/my/orders" class="o_fp_kpi_hint">View orders →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_kpi_tile o_fp_kpi_hero">
|
<div class="o_fp_kpi_tile o_fp_kpi_hero">
|
||||||
<div class="o_fp_kpi_label">In-Flight Jobs</div>
|
<div class="o_fp_kpi_label">In-Flight Jobs</div>
|
||||||
@@ -67,49 +67,9 @@
|
|||||||
|
|
||||||
<t t-if="recent_jobs">
|
<t t-if="recent_jobs">
|
||||||
<t t-foreach="recent_jobs[:3]" t-as="job">
|
<t t-foreach="recent_jobs[:3]" t-as="job">
|
||||||
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
|
<t t-call="fusion_plating_portal.fp_portal_job_card">
|
||||||
<div class="o_fp_job_header">
|
<t t-set="job" t-value="job"/>
|
||||||
<div>
|
</t>
|
||||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
|
||||||
<span class="o_fp_job_meta">
|
|
||||||
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
|
|
||||||
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<t t-call="fusion_plating_portal.fp_portal_status_badge">
|
|
||||||
<t t-set="state" t-value="job.state"/>
|
|
||||||
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- State -> active step index mapping.
|
|
||||||
5 customer-visible stages: Received / Inspected / Plating / QC / Ship.
|
|
||||||
state_idx >= 5 means all done (shipped or complete). -->
|
|
||||||
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
|
|
||||||
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
|
|
||||||
<t t-set="steps" t-value="[
|
|
||||||
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
|
|
||||||
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
|
|
||||||
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
|
|
||||||
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
|
|
||||||
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
|
|
||||||
]"/>
|
|
||||||
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
|
||||||
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
|
||||||
|
|
||||||
<!-- Doc chips: CoC + tracking (V1) -->
|
|
||||||
<div class="o_fp_job_docs">
|
|
||||||
<t t-if="job.coc_attachment_id">
|
|
||||||
<span class="o_fp_doc_chip">📑 CoC</span>
|
|
||||||
</t>
|
|
||||||
<t t-else="">
|
|
||||||
<span class="o_fp_doc_chip o_fp_doc_chip_pending">📑 CoC · pending</span>
|
|
||||||
</t>
|
|
||||||
<t t-if="job.tracking_ref">
|
|
||||||
<span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-if="job_count > 3">
|
<t t-if="job_count > 3">
|
||||||
@@ -150,11 +110,11 @@
|
|||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purchase Orders -->
|
<!-- Sales Orders -->
|
||||||
<div class="o_fp_panel">
|
<div class="o_fp_panel">
|
||||||
<div class="o_fp_panel_title">
|
<div class="o_fp_panel_title">
|
||||||
<span class="o_fp_panel_icon">🛒</span> Recent Purchase Orders
|
<span class="o_fp_panel_icon">🛒</span> Recent Sales Orders
|
||||||
<a href="/my/purchase_orders" class="o_fp_panel_view_all">View all →</a>
|
<a href="/my/orders" class="o_fp_panel_view_all">View all →</a>
|
||||||
</div>
|
</div>
|
||||||
<t t-if="recent_pos">
|
<t t-if="recent_pos">
|
||||||
<t t-foreach="recent_pos[:3]" t-as="po">
|
<t t-foreach="recent_pos[:3]" t-as="po">
|
||||||
@@ -165,7 +125,7 @@
|
|||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<div class="o_fp_panel_row text-muted">No purchase orders yet.</div>
|
<div class="o_fp_panel_row text-muted">No sales orders yet.</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -259,8 +219,8 @@
|
|||||||
<t t-set="placeholder_count" t-value="'fp_portal_job_count'"/>
|
<t t-set="placeholder_count" t-value="'fp_portal_job_count'"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-call="portal.portal_docs_entry">
|
<t t-call="portal.portal_docs_entry">
|
||||||
<t t-set="title">Purchase Orders</t>
|
<t t-set="title">Sales Orders</t>
|
||||||
<t t-set="url" t-value="'/my/purchase_orders'"/>
|
<t t-set="url" t-value="'/my/orders'"/>
|
||||||
<t t-set="placeholder_count" t-value="'fp_purchase_order_count'"/>
|
<t t-set="placeholder_count" t-value="'fp_purchase_order_count'"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-call="portal.portal_docs_entry">
|
<t t-call="portal.portal_docs_entry">
|
||||||
|
|||||||
@@ -78,6 +78,184 @@
|
|||||||
</t>
|
</t>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Job card — shared between /my/home dashboard and /my/jobs list. -->
|
||||||
|
<!-- Pass `job` (fusion.plating.portal.job). Renders a wrap div with -->
|
||||||
|
<!-- inner anchor (whole card click target = detail page) and a sibling -->
|
||||||
|
<!-- actions footer (doc download chips + Repeat Order form). Forms -->
|
||||||
|
<!-- live OUTSIDE the anchor because forms-inside-anchors is invalid -->
|
||||||
|
<!-- HTML and clicks would otherwise double-fire. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<template id="fp_portal_job_card" name="Portal: Job Card">
|
||||||
|
<!-- Pull related backend records inline (cards are 3-5 per page,
|
||||||
|
query cost is fine). Each `t-set` is a no-op if the field
|
||||||
|
chain breaks. -->
|
||||||
|
<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"/>
|
||||||
|
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
|
||||||
|
<t t-set="picking" t-value="so.picking_ids.filtered(lambda p: p.state == 'done')[:1] if so and 'picking_ids' in so._fields else False"/>
|
||||||
|
|
||||||
|
<!-- Stepper state mapping (same as detail page).
|
||||||
|
received -> idx 0; in_progress -> 2; quality_check -> 3;
|
||||||
|
ready_to_ship -> 4; shipped / complete -> 5 (all done). -->
|
||||||
|
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
|
||||||
|
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
|
||||||
|
<t t-set="steps" t-value="[
|
||||||
|
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
|
||||||
|
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
|
||||||
|
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
|
||||||
|
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
|
||||||
|
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
|
||||||
|
]"/>
|
||||||
|
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
||||||
|
|
||||||
|
<div class="o_fp_job_card">
|
||||||
|
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card_main">
|
||||||
|
<div class="o_fp_job_header">
|
||||||
|
<div>
|
||||||
|
<span class="o_fp_job_ref" t-out="job.name"/>
|
||||||
|
<span class="o_fp_job_meta">
|
||||||
|
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
|
||||||
|
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<t t-call="fusion_plating_portal.fp_portal_status_badge">
|
||||||
|
<t t-set="state" t-value="job.state"/>
|
||||||
|
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part info: prefer the part catalog ref, fall back to
|
||||||
|
process types listed on the portal job. -->
|
||||||
|
<t t-if="part">
|
||||||
|
<div class="o_fp_job_part">
|
||||||
|
<span class="o_fp_job_part_icon">📦</span>
|
||||||
|
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
|
||||||
|
<t t-out="part.name or part.display_name"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-elif="job.process_type_ids">
|
||||||
|
<div class="o_fp_job_part">
|
||||||
|
<span class="o_fp_job_part_icon">📦</span>
|
||||||
|
<t t-out="', '.join(job.process_type_ids.mapped('name'))"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Shipping address: customer may have multiple sites; the
|
||||||
|
SO carries which one this job ships to. -->
|
||||||
|
<t t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
|
||||||
|
<div class="o_fp_job_ship">
|
||||||
|
<span class="o_fp_job_ship_icon">📍</span>
|
||||||
|
Ship to: <t t-out="ship_to.name"/>
|
||||||
|
<t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Actions footer: doc quick-downloads + Repeat Order. Lives
|
||||||
|
OUTSIDE the .o_fp_job_card_main anchor so the form button
|
||||||
|
doesn't double-fire navigation + form submission. -->
|
||||||
|
<div class="o_fp_job_card_actions">
|
||||||
|
<div class="o_fp_job_card_docs">
|
||||||
|
<t t-if="so">
|
||||||
|
<a t-attf-href="/my/jobs/#{job.id}/so_confirmation"
|
||||||
|
class="o_fp_doc_quick_btn" title="Sales Order Confirmation">
|
||||||
|
📄 SO
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
<t t-if="backend_job">
|
||||||
|
<a t-attf-href="/my/jobs/#{job.id}/wo_detail"
|
||||||
|
class="o_fp_doc_quick_btn" title="Work Order Detail">
|
||||||
|
🛠 WO
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
<t t-if="job.coc_attachment_id">
|
||||||
|
<a t-attf-href="/my/jobs/#{job.id}/coc"
|
||||||
|
class="o_fp_doc_quick_btn" title="Certificate of Conformance">
|
||||||
|
📑 CoC
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="o_fp_doc_quick_btn o_fp_doc_quick_btn_pending"
|
||||||
|
title="Will appear after QC completes">
|
||||||
|
📑 CoC
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-if="job.packing_list_attachment_id">
|
||||||
|
<a t-attf-href="/web/content/#{job.packing_list_attachment_id.id}?download=true"
|
||||||
|
class="o_fp_doc_quick_btn" title="Packing Slip">
|
||||||
|
📦 Packing
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<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_secondary o_fp_btn_sm">
|
||||||
|
<i class="fa fa-repeat"/> Repeat Order
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Reusable filter-pill + search + sort strip for portal list pages. -->
|
||||||
|
<!-- Render with t-call="fusion_plating_portal.fp_portal_list_controls" -->
|
||||||
|
<!-- and t-set the following vars in the call: -->
|
||||||
|
<!-- filters : list of (key, label) tuples for the pills -->
|
||||||
|
<!-- active_filter : the current filter key -->
|
||||||
|
<!-- sorts : list of (key, label) tuples for the sort dropdown-->
|
||||||
|
<!-- active_sort : the current sort key -->
|
||||||
|
<!-- search : current search string (for prefilling the input) -->
|
||||||
|
<!-- url : base path (e.g. '/my/jobs') -->
|
||||||
|
<!-- extra_qs : optional extra query-string suffix -->
|
||||||
|
<!-- target : CSS selector of the data-fp-filterable container -->
|
||||||
|
<!-- result_total : total record count (for the ">500" clip notice) -->
|
||||||
|
<!-- clipped : True if results were capped at 500 -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<template id="fp_portal_list_controls" name="FP Portal List Controls">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3 mb-3 o_fp_list_controls">
|
||||||
|
<!-- Filter pills -->
|
||||||
|
<div class="d-flex align-items-center gap-2" t-if="filters">
|
||||||
|
<span class="text-muted small">Showing:</span>
|
||||||
|
<t t-foreach="filters" t-as="f">
|
||||||
|
<a t-attf-href="#{url}?filter_state=#{f[0]}&sortby=#{active_sort}#{extra_qs or ''}"
|
||||||
|
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if active_filter == f[0] else ''}"
|
||||||
|
t-out="f[1]"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input — real-time client-side filtering, no submit -->
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2"
|
||||||
|
t-att-style="'flex: 1 1 auto; max-width: 360px;' + ('' if filters else 'margin-left: 0 !important')">
|
||||||
|
<input type="search"
|
||||||
|
class="form-control form-control-sm o_fp_list_search"
|
||||||
|
placeholder="Search…"
|
||||||
|
t-att-value="search or ''"
|
||||||
|
t-att-data-fp-target="target"
|
||||||
|
autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort dropdown — navigates on change (wired by fp_portal_list_search.js) -->
|
||||||
|
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 180px" t-if="sorts">
|
||||||
|
<t t-foreach="sorts" t-as="s">
|
||||||
|
<option t-att-value="url + '?filter_state=' + (active_filter or 'all') + '&sortby=' + s[0] + (extra_qs or '')"
|
||||||
|
t-att-selected="active_sort == s[0]"
|
||||||
|
t-out="s[1]"/>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Clip notice: shown server-side when >500 records were found -->
|
||||||
|
<div class="o_fp_list_search_meta small text-muted mb-2" t-if="clipped">
|
||||||
|
Showing latest 500 of <span t-out="result_total"/> — refine your filter to narrow further.
|
||||||
|
</div>
|
||||||
|
<!-- Live count: shown by JS while the user is typing in the search box -->
|
||||||
|
<div class="o_fp_list_search_count small text-muted mb-2 d-none"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
|
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
|
||||||
<!-- {label, sub, url, icon_class, pending} -->
|
<!-- {label, sub, url, icon_class, pending} -->
|
||||||
|
|||||||
115
fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml
Normal file
115
fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
Wraps every /my/* page (FP custom + Odoo default) in the new
|
||||||
|
sidebar shell. Inherits portal.portal_layout so we don't have
|
||||||
|
to edit every individual page template.
|
||||||
|
|
||||||
|
Implementation note (Approach D, $0 re-emit):
|
||||||
|
|
||||||
|
The plan originally proposed injecting an unbalanced main opening
|
||||||
|
tag in one xpath block and its closing tag in another. QWeb parses
|
||||||
|
each xpath payload as an independent XML fragment, so unbalanced
|
||||||
|
tags are rejected at load time.
|
||||||
|
|
||||||
|
Instead we use position="replace" on //div[@id='wrap'] with $0
|
||||||
|
inside the replacement payload. $0 is supported by Odoo 19's
|
||||||
|
Python view inheritance engine (tools/template_inheritance.py,
|
||||||
|
lines 162-169): any element whose text content is the literal
|
||||||
|
string "$0" has its text cleared and the deep-copied original node
|
||||||
|
appended as a child. This produces a fully balanced replacement tree
|
||||||
|
that nests the original #wrap (and all its Odoo-managed content)
|
||||||
|
inside our .o_fp_portal_main element.
|
||||||
|
|
||||||
|
Verified from portal_templates.xml line 155:
|
||||||
|
div id="wrap" class="o_portal_wrap"
|
||||||
|
div class="container pt-3 pb-5"
|
||||||
|
t t-out="0" (Odoo content slot)
|
||||||
|
/div
|
||||||
|
/div
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Inherit portal.portal_layout to wrap content in sidebar shell -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<template id="fp_portal_shell"
|
||||||
|
name="FP Portal Shell — Sidebar Wrap"
|
||||||
|
inherit_id="portal.portal_layout"
|
||||||
|
priority="50">
|
||||||
|
<!-- Force Odoo's outer breadcrumb container to render even when a page
|
||||||
|
sets breadcrumbs_searchbar=True (e.g. /my/orders, /my/invoices, and
|
||||||
|
other stock Odoo list pages). Keeps the breadcrumb consistently above
|
||||||
|
our shell, never inside the right column. We still respect
|
||||||
|
no_breadcrumbs and my_details (used by /my/account). -->
|
||||||
|
<xpath expr="//div[hasclass('o_portal') and hasclass('container') and hasclass('mt-3')]" position="attributes">
|
||||||
|
<attribute name="t-if">not no_breadcrumbs and not my_details</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Replace #wrap entirely. The $0 text node inside
|
||||||
|
<main class="o_fp_portal_main"> causes Odoo's inheritance
|
||||||
|
engine to re-emit the original #wrap div (with all its
|
||||||
|
children) at that position. Every existing portal page
|
||||||
|
continues to render correctly because Odoo's <t t-out="0"/>
|
||||||
|
content slot inside #wrap is preserved verbatim.
|
||||||
|
-->
|
||||||
|
<xpath expr="//div[@id='wrap']" position="replace">
|
||||||
|
<div class="o_fp_portal_shell">
|
||||||
|
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
|
||||||
|
<button type="button"
|
||||||
|
class="o_fp_portal_hamburger d-md-none"
|
||||||
|
aria-label="Open navigation">
|
||||||
|
<i class="fa fa-bars"/>
|
||||||
|
</button>
|
||||||
|
<!-- Backdrop for mobile drawer (hidden by default) -->
|
||||||
|
<div class="o_fp_portal_backdrop"/>
|
||||||
|
<!-- Sidebar navigation component -->
|
||||||
|
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
|
||||||
|
<!-- Main content area — original #wrap re-emitted here via $0 -->
|
||||||
|
<main class="o_fp_portal_main">$0</main>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Sidebar template — rendered by fp_portal_shell -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<template id="fp_portal_sidebar" name="FP Portal Sidebar">
|
||||||
|
<aside class="o_fp_portal_sidebar">
|
||||||
|
<!-- Partner display name header -->
|
||||||
|
<div class="o_fp_sidebar_header">
|
||||||
|
<t t-out="fp_partner_display_name or 'My Account'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation items, walked from the Python-side data structure.
|
||||||
|
fp_sidebar_items is injected by the controller mixin in Task 4.
|
||||||
|
Guards here handle the case where Task 4 hasn't deployed yet. -->
|
||||||
|
<t t-foreach="fp_sidebar_items or []" t-as="entry">
|
||||||
|
<!-- Section labels render as non-link headers -->
|
||||||
|
<t t-if="entry.get('type') == 'section_label'">
|
||||||
|
<div class="o_fp_sidebar_section_label" t-out="entry.get('label', '')"/>
|
||||||
|
</t>
|
||||||
|
<!-- Items render as anchor links -->
|
||||||
|
<t t-elif="entry.get('type') == 'item'">
|
||||||
|
<a t-att-href="entry.get('url', '#')"
|
||||||
|
t-attf-class="o_fp_sidebar_item #{'o_fp_sidebar_active' if entry.get('active') else ''}">
|
||||||
|
<span class="o_fp_sidebar_icon" t-out="entry.get('icon') or '•'"/>
|
||||||
|
<span t-out="entry.get('label', '')"/>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Footer: sign out link always present -->
|
||||||
|
<div class="o_fp_sidebar_footer">
|
||||||
|
<a href="/web/session/logout?redirect=/" class="o_fp_sidebar_item">
|
||||||
|
<span class="o_fp_sidebar_icon">↪</span>
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -7,24 +7,27 @@
|
|||||||
<odoo>
|
<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">
|
<template id="portal_my_quote_requests" name="My Quote Requests">
|
||||||
<t t-call="portal.portal_layout">
|
<t t-call="portal.portal_layout">
|
||||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
|
||||||
<t t-call="portal.portal_searchbar">
|
<t t-call="portal.portal_searchbar">
|
||||||
<t t-set="title">Quote Requests</t>
|
<t t-set="title">Quote Requests</t>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<!-- Tab navigation -->
|
<!-- Filter pills + search + sort strip -->
|
||||||
<ul class="nav nav-tabs mb-3">
|
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||||
<li class="nav-item" t-foreach="searchbar_filters" t-as="f">
|
<t t-set="filters" t-value="filters"/>
|
||||||
<a t-attf-class="nav-link #{'active' if filterby == f else ''}"
|
<t t-set="active_filter" t-value="filter_state"/>
|
||||||
t-attf-href="/my/quote_requests?filterby=#{f}&sortby=#{sortby}">
|
<t t-set="sorts" t-value="sorts"/>
|
||||||
<t t-out="searchbar_filters[f]['label']"/>
|
<t t-set="active_sort" t-value="sortby"/>
|
||||||
</a>
|
<t t-set="search" t-value="search"/>
|
||||||
</li>
|
<t t-set="url" t-value="url"/>
|
||||||
</ul>
|
<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">
|
<div class="d-flex justify-content-end mb-3">
|
||||||
<a href="/my/quote_requests/new" class="o_fp_btn_primary">
|
<a href="/my/quote_requests/new" class="o_fp_btn_primary">
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
<th class="text-end">Status</th>
|
<th class="text-end">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="o_fp_qr_filterable">
|
||||||
<tr t-foreach="quote_requests" t-as="qr">
|
<tr t-foreach="quote_requests" t-as="qr">
|
||||||
<td>
|
<td>
|
||||||
<a t-att-href="'/my/quote_requests/%s' % qr.id"
|
<a t-att-href="'/my/quote_requests/%s' % qr.id"
|
||||||
@@ -74,6 +77,16 @@
|
|||||||
<t t-set="label" t-value="dict(qr._fields['state']._description_selection(qr.env)).get(qr.state)"/>
|
<t t-set="label" t-value="dict(qr._fields['state']._description_selection(qr.env)).get(qr.state)"/>
|
||||||
</t>
|
</t>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</t>
|
</t>
|
||||||
@@ -418,15 +431,28 @@
|
|||||||
</template>
|
</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">
|
<template id="portal_my_jobs" name="My Work Orders">
|
||||||
<t t-call="portal.portal_layout">
|
<t t-call="portal.portal_layout">
|
||||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
|
||||||
<t t-call="portal.portal_searchbar">
|
<t t-call="portal.portal_searchbar">
|
||||||
<t t-set="title">Work Orders</t>
|
<t t-set="title">Work Orders</t>
|
||||||
</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">
|
<t t-if="not jobs">
|
||||||
<div class="o_fp_card text-center text-muted">
|
<div class="o_fp_card text-center text-muted">
|
||||||
<p class="mb-2">You have no plating jobs yet.</p>
|
<p class="mb-2">You have no plating jobs yet.</p>
|
||||||
@@ -434,36 +460,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="jobs">
|
<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-foreach="jobs" t-as="job">
|
||||||
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
|
<!-- Wrapper div is the filterable row unit.
|
||||||
<div class="o_fp_job_header">
|
Hidden span carries extra search terms that
|
||||||
<div>
|
are not visible in the card UI. -->
|
||||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
<div class="o_fp_job_card_wrap">
|
||||||
<span class="o_fp_job_meta">
|
<t t-call="fusion_plating_portal.fp_portal_job_card">
|
||||||
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
|
<t t-set="job" t-value="job"/>
|
||||||
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
|
</t>
|
||||||
</span>
|
<!-- Extra hidden search terms for this card -->
|
||||||
</div>
|
<t t-set="_backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
|
||||||
<t t-call="fusion_plating_portal.fp_portal_status_badge">
|
<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="state" t-value="job.state"/>
|
<t t-set="_part" t-value="_backend_job.part_catalog_id if _backend_job and 'part_catalog_id' in _backend_job._fields else False"/>
|
||||||
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
|
<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>
|
||||||
</div>
|
<t t-if="_so">
|
||||||
|
<t t-out="_so.name or ''"/>
|
||||||
<!-- State -> active step index map (same as dashboard) -->
|
<t t-out="_so.client_order_ref or ''"/>
|
||||||
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
|
</t>
|
||||||
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
|
<t t-out="job.notes or ''"/>
|
||||||
<t t-set="steps" t-value="[
|
</span>
|
||||||
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
|
</div>
|
||||||
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
|
|
||||||
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
|
|
||||||
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
|
|
||||||
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
|
|
||||||
]"/>
|
|
||||||
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
|
||||||
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
|
||||||
</a>
|
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
@@ -477,15 +499,28 @@
|
|||||||
<t t-call="portal.portal_layout">
|
<t t-call="portal.portal_layout">
|
||||||
<div class="o_fp_job_detail">
|
<div class="o_fp_job_detail">
|
||||||
|
|
||||||
<!-- Hero header -->
|
<!-- Hero header: WO ref + part + ship-to + key facts -->
|
||||||
|
<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"/>
|
||||||
|
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
|
||||||
|
|
||||||
<div class="o_fp_job_detail_hero">
|
<div class="o_fp_job_detail_hero">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<div class="o_fp_detail_label">Work Order</div>
|
<div class="o_fp_detail_label">Work Order</div>
|
||||||
<h2><span t-out="job.name"/></h2>
|
<h2><span t-out="job.name"/></h2>
|
||||||
<div t-if="job.process_type_ids" class="o_fp_detail_subtitle">
|
<t t-if="part">
|
||||||
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
|
<div class="o_fp_detail_subtitle">
|
||||||
</div>
|
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
|
||||||
|
<t t-out="part.name or part.display_name"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-elif="job.process_type_ids">
|
||||||
|
<div class="o_fp_detail_subtitle">
|
||||||
|
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
<div class="o_fp_detail_facts">
|
<div class="o_fp_detail_facts">
|
||||||
<div t-if="job.quantity">
|
<div t-if="job.quantity">
|
||||||
<span class="o_fp_fact_label">Qty </span>
|
<span class="o_fp_fact_label">Qty </span>
|
||||||
@@ -503,6 +538,12 @@
|
|||||||
<span class="o_fp_fact_label">Tracking </span>
|
<span class="o_fp_fact_label">Tracking </span>
|
||||||
<span class="o_fp_fact_value" t-out="job.tracking_ref"/>
|
<span class="o_fp_fact_value" t-out="job.tracking_ref"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
|
||||||
|
<span class="o_fp_fact_label">Ship to </span>
|
||||||
|
<span class="o_fp_fact_value">
|
||||||
|
<t t-out="ship_to.name"/><t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column align-items-end gap-2">
|
<div class="d-flex flex-column align-items-end gap-2">
|
||||||
@@ -582,107 +623,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- PURCHASE ORDERS — list -->
|
<!-- DELIVERIES / PACKING SLIPS — list with search + sort -->
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="portal_my_purchase_orders" name="My Purchase Orders">
|
|
||||||
<t t-call="portal.portal_layout">
|
|
||||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
|
||||||
<t t-call="portal.portal_searchbar">
|
|
||||||
<t t-set="title">Purchase Orders</t>
|
|
||||||
</t>
|
|
||||||
|
|
||||||
<t t-if="not orders">
|
|
||||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
|
||||||
<p class="text-muted mb-0">No purchase orders found.</p>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
<t t-if="orders" t-call="portal.portal_table">
|
|
||||||
<thead>
|
|
||||||
<tr class="active">
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th class="text-end">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr t-foreach="orders" t-as="order">
|
|
||||||
<td t-out="order.name"/>
|
|
||||||
<td>
|
|
||||||
<span t-field="order.date_order" t-options='{"widget": "date"}'/>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span t-field="order.amount_total"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</t>
|
|
||||||
</t>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- INVOICES — list -->
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<template id="portal_my_fp_invoices" name="My Invoices">
|
|
||||||
<t t-call="portal.portal_layout">
|
|
||||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
|
||||||
<t t-call="portal.portal_searchbar">
|
|
||||||
<t t-set="title">Invoices</t>
|
|
||||||
</t>
|
|
||||||
|
|
||||||
<t t-if="not invoices">
|
|
||||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
|
||||||
<p class="text-muted mb-0">No invoices found.</p>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
<t t-if="invoices" t-call="portal.portal_table">
|
|
||||||
<thead>
|
|
||||||
<tr class="active">
|
|
||||||
<th>Invoice</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Due Date</th>
|
|
||||||
<th class="text-end">Amount Due</th>
|
|
||||||
<th class="text-end">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr t-foreach="invoices" t-as="inv">
|
|
||||||
<td t-out="inv.name"/>
|
|
||||||
<td>
|
|
||||||
<span t-if="inv.invoice_date"
|
|
||||||
t-field="inv.invoice_date"
|
|
||||||
t-options='{"widget": "date"}'/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span t-if="inv.invoice_date_due"
|
|
||||||
t-field="inv.invoice_date_due"
|
|
||||||
t-options='{"widget": "date"}'/>
|
|
||||||
<span t-else="" class="text-muted">--</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span t-field="inv.amount_residual"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<span t-field="inv.amount_total"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</t>
|
|
||||||
</t>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
|
||||||
<!-- DELIVERIES / PACKING SLIPS — list -->
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<template id="portal_my_deliveries" name="My Deliveries">
|
<template id="portal_my_deliveries" name="My Deliveries">
|
||||||
<t t-call="portal.portal_layout">
|
<t t-call="portal.portal_layout">
|
||||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
|
||||||
<t t-call="portal.portal_searchbar">
|
<t t-call="portal.portal_searchbar">
|
||||||
<t t-set="title">Packing Slips / Deliveries</t>
|
<t t-set="title">Packing Slips / Deliveries</t>
|
||||||
</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">
|
<t t-if="not deliveries">
|
||||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
<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>
|
<p class="text-muted mb-0">No deliveries found.</p>
|
||||||
@@ -692,13 +654,17 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="active">
|
<tr class="active">
|
||||||
<th>Reference</th>
|
<th>Reference</th>
|
||||||
|
<th>Origin</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th class="text-end">Status</th>
|
<th class="text-end">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="o_fp_deliveries_filterable">
|
||||||
<tr t-foreach="deliveries" t-as="dlv">
|
<tr t-foreach="deliveries" t-as="dlv">
|
||||||
<td t-out="dlv.name"/>
|
<td t-out="dlv.name"/>
|
||||||
|
<td>
|
||||||
|
<span t-out="dlv.origin or ''"/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span t-if="dlv.date_done"
|
<span t-if="dlv.date_done"
|
||||||
t-field="dlv.date_done"
|
t-field="dlv.date_done"
|
||||||
@@ -709,6 +675,13 @@
|
|||||||
<span class="o_fp_badge_dot"/>Delivered
|
<span class="o_fp_badge_dot"/>Delivered
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</t>
|
</t>
|
||||||
@@ -716,15 +689,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- CERTIFICATIONS — list -->
|
<!-- CERTIFICATIONS — list with search + sort -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<template id="portal_my_certifications" name="My Certifications">
|
<template id="portal_my_certifications" name="My Certifications">
|
||||||
<t t-call="portal.portal_layout">
|
<t t-call="portal.portal_layout">
|
||||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
|
||||||
<t t-call="portal.portal_searchbar">
|
<t t-call="portal.portal_searchbar">
|
||||||
<t t-set="title">Certifications & Quality</t>
|
<t t-set="title">Certifications & Quality</t>
|
||||||
</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">
|
<t t-if="not cert_jobs">
|
||||||
<div class="o_fp_portal_card card bg-body-tertiary border-0 p-4 text-center">
|
<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>
|
<p class="text-muted mb-0">No certificates available yet.</p>
|
||||||
@@ -739,7 +725,7 @@
|
|||||||
<th class="text-end">Download</th>
|
<th class="text-end">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="o_fp_certs_filterable">
|
||||||
<tr t-foreach="cert_jobs" t-as="cj">
|
<tr t-foreach="cert_jobs" t-as="cj">
|
||||||
<td>
|
<td>
|
||||||
<a t-att-href="'/my/jobs/%s' % cj.id" t-out="cj.name"/>
|
<a t-att-href="'/my/jobs/%s' % cj.id" t-out="cj.name"/>
|
||||||
@@ -760,6 +746,19 @@
|
|||||||
<i class="fa fa-download"/> CoC
|
<i class="fa fa-download"/> CoC
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -14,6 +14,41 @@
|
|||||||
-->
|
-->
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- /my/orders — inject filter strip + data-fp-filterable before the -->
|
||||||
|
<!-- Odoo portal table so real-time search works client-side. -->
|
||||||
|
<!-- Sort dropdown reuses Odoo's existing sortby param; filter pills -->
|
||||||
|
<!-- link to URL params that Odoo's stock route honours natively. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<template id="portal_my_orders_fp_search"
|
||||||
|
inherit_id="sale.portal_my_orders"
|
||||||
|
priority="50">
|
||||||
|
|
||||||
|
<!-- Inject the controls strip right after the portal_searchbar call
|
||||||
|
and before the "no orders" alert / portal_table. The anchor is
|
||||||
|
the <div t-if="not orders"> alert — we inject before it. -->
|
||||||
|
<xpath expr="//div[@t-if='not orders']" position="before">
|
||||||
|
<t t-call="fusion_plating_portal.fp_portal_list_controls">
|
||||||
|
<t t-set="filters" t-value="False"/>
|
||||||
|
<t t-set="active_filter" t-value="'all'"/>
|
||||||
|
<t t-set="sorts" t-value="False"/>
|
||||||
|
<t t-set="active_sort" t-value="'date'"/>
|
||||||
|
<t t-set="search" t-value="''"/>
|
||||||
|
<t t-set="url" t-value="'/my/orders'"/>
|
||||||
|
<t t-set="extra_qs" t-value="''"/>
|
||||||
|
<!-- Odoo's portal.portal_table emits a <table class="o_portal_my_doc_table">
|
||||||
|
so we don't need to add our own id; the JS just needs a stable selector. -->
|
||||||
|
<t t-set="target" t-value="'.o_portal_my_doc_table tbody'"/>
|
||||||
|
<t t-set="result_total" t-value="len(orders) if orders else 0"/>
|
||||||
|
<t t-set="clipped" t-value="False"/>
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Sale order portal content: add Part # column -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
<template id="sale_order_portal_content_fp_part_column"
|
<template id="sale_order_portal_content_fp_part_column"
|
||||||
inherit_id="sale.sale_order_portal_content">
|
inherit_id="sale.sale_order_portal_content">
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user