Compare commits
61 Commits
db382b63f2
...
v1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
88de9b81b7
|
|||
|
3fa212e561
|
|||
|
318459cef8
|
|||
|
aff137b319
|
|||
|
8d2110e8e8
|
|||
|
1d5b28e95b
|
|||
|
7606643ff4
|
|||
|
3229ce9c41
|
|||
|
bf48ed1dcb
|
|||
|
45428c9626
|
|||
|
0f1f399d1a
|
|||
|
19edee4e11
|
|||
|
ba61f91c53
|
|||
|
5ef4c22c59
|
|||
|
60daabfe91
|
|||
|
71d550dfb1
|
|||
|
cc025e488d
|
|||
|
9eef03e5f4
|
|||
|
d392e5ddd7
|
|||
|
2ae057cc90
|
|||
|
8b4d88b226
|
|||
|
c9de750261
|
|||
|
7a7143b6e7
|
|||
|
e9c295be91
|
|||
|
f512ef9928
|
|||
|
3fd89a307c
|
|||
|
4d3f1f9760
|
|||
|
a182609415
|
|||
|
1a3decbeb6
|
|||
|
de4da0eec6
|
|||
|
cc567c8ff1
|
|||
|
6a3e9e6adf
|
|||
|
86973f7542
|
|||
|
8371f4e89e
|
|||
|
451c27e9d7
|
|||
|
f74071e39e
|
|||
|
f9595b94e0
|
|||
|
b8c493fc20
|
|||
|
b92d75f558
|
|||
|
3ef0d2aafd
|
|||
|
6c5db94aaa
|
|||
|
b4d8d9dbc1
|
|||
|
9ac5ffb11d
|
|||
|
ea164cdcd6
|
|||
|
3ee8eb6f21
|
|||
|
8d73954362
|
|||
|
eed4189a07
|
|||
| ac817a69d4 | |||
| 4421b28efe | |||
| 003035a3a4 | |||
| cd6b60ddcd | |||
| 5b1a35430a | |||
| f0fb99d1ef | |||
| e4cfb3fa9c | |||
| 7fef10abd3 | |||
| 6f8b2beea3 | |||
| 911da359e3 | |||
| 9c6c5ff690 | |||
| 9cb58809c8 | |||
| dfc84c64ee | |||
| 0b854e23d2 |
391
README.md
391
README.md
@@ -1,42 +1,395 @@
|
||||
# Opportunities Extension
|
||||
|
||||
Extensao de navegador para adicionar o atalho "Opportunities Extension" nas paginas Oracle Fusion permitidas.
|
||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
|
||||
|
||||
## Estrutura de desenvolvimento
|
||||
## Opportunity Views
|
||||
|
||||
- `src/content.js`: script unico mantido no projeto.
|
||||
- `scripts/build.mjs`: gera os pacotes especificos para cada navegador.
|
||||
- `dist/chromium/`: output para Google Chrome e Microsoft Edge.
|
||||
- `dist/firefox/`: output para Firefox.
|
||||
**Overview** is the default table view. **Calendar** displays opportunities
|
||||
by Close Date (`EffectiveDate`) with month and week views only. Both views share
|
||||
Reference, search and all existing filters; switching views does not send requests.
|
||||
Events use date-only values without time-zone shifts, stage colors, and a read-only
|
||||
detail with an opportunity link and copy button. Invalid or missing dates appear
|
||||
in a separate list. Calendar navigation does not change the selected Reference.
|
||||
|
||||
Table sorting remembers the last column and direction independently for Overview,
|
||||
Installed Base, Service Requests, Rate Card, Consumption projection and Manage Time.
|
||||
Preferences are restored when reopening each view and survive page reloads in the
|
||||
same browser profile. Opportunity product tables retain their original order.
|
||||
Each day shows a filtered opportunity count, at most two events and an overflow
|
||||
link to the remaining events. The grid fits the available height and reduces
|
||||
visible events to one or zero when needed to keep every week visible. Missing
|
||||
close dates are available in a collapsible list below the calendar.
|
||||
Fiscal labels use `FY27-Q2-W19`: June-May fiscal
|
||||
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
|
||||
assigned by the week's Sunday, except the first fiscal week which belongs to Q1.
|
||||
|
||||
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
|
||||
with its MIT license. It is initialized only on first use, without a runtime CDN.
|
||||
|
||||
## Customer Organization Charts
|
||||
|
||||
The organization-chart icon beside **View customer service requests** opens a
|
||||
builder shared by opportunities for the same customer. Add people and direct
|
||||
reports, change their manager using **Reports to**, or click a person to edit
|
||||
name and notes, and view contact details. **Name** uses the shared searchable select, populated by
|
||||
`requestQueryAccountContact` after a 350 ms pause in typing, using the Token Relay
|
||||
Bearer token and filtering by the customer's `PartyId`. Each POST returns up to
|
||||
15 relevance-ranked results; empty queries and chart opening do not send searches.
|
||||
Superseded responses are ignored. Contact names are keyed by `PartyId`;
|
||||
options display only the person's name and job title. **Job Title** and **Email address**
|
||||
are read-only and fetched afresh using the person's name as `keywords` whenever
|
||||
Person details opens. The stored contact `PartyId` identifies the correct result.
|
||||
Job Title is saved with the chart and shown immediately on the node. A fresh
|
||||
response updates the matching person's name and Job Title automatically, preserving
|
||||
notes and reporting relationships; **Save chart** persists those changes.
|
||||
Email remains in memory only; existing persisted emails are removed by migration. Existing
|
||||
saved people remain available even if absent from the contact list. Notes appear on hover or
|
||||
keyboard focus. Branches can be expanded/collapsed; zoom, fit and canvas panning
|
||||
help navigate larger trees. Deleting a person promotes their direct reports to
|
||||
that person's manager without deleting those reports.
|
||||
|
||||
**Apply changes** updates the working tree; **Save chart** persists it. Unsaved
|
||||
changes require confirmation before closing. The extension background stores
|
||||
JSON-compatible records in IndexedDB, scoped by current user and customer ID in
|
||||
the current browser profile. Records include schema version, revision, update
|
||||
timestamp and a flat node array (`id`, `parentId`, `name`, `jobTitle`, `notes`,
|
||||
`collapsed`, and optional `contactPartyId`). Concurrent stale saves are rejected. Charts are local and do not
|
||||
sync between devices or write to Oracle services. They support up to 500 people
|
||||
and 60 reporting levels, with required name and optional notes.
|
||||
|
||||
The interaction is inspired by [OrgChart](https://github.com/dabeng/OrgChart).
|
||||
Layout uses bundled [D3 Hierarchy 3.1.2](https://d3js.org/d3-hierarchy/tree), with
|
||||
the extension's existing modal, theme and autocomplete patterns. D3 and icon
|
||||
licenses are in `src/vendor/d3-hierarchy/` and `src/vendor/lucide/`. There is no
|
||||
runtime CDN dependency or new browser permission.
|
||||
|
||||
For synthetic browser checks, install Playwright separately (or provide it through
|
||||
`NODE_PATH`) and run `node scripts/verify-org-chart.cjs`. `TEST_BROWSER_CHANNEL`
|
||||
can select an installed `msedge` or `chrome`. This exercises real IndexedDB in a
|
||||
local fixture and writes light/dark/mobile screenshots to a temporary folder;
|
||||
native extension storage and Oracle integration still require the browser smoke
|
||||
check below.
|
||||
|
||||
## Development Structure
|
||||
|
||||
- `src/content/`: page integration, modal shell, theme and content composition.
|
||||
- `src/background/`: synchronous message registration and background composition.
|
||||
- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug.
|
||||
- `src/auth/`: credential-specific selection, reuse and session recovery.
|
||||
- `src/services/`: Fusion/SPA operations and background HTTP/JSON transport.
|
||||
- `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters.
|
||||
- `src/shared/messages.js`: names of the existing cross-context messages.
|
||||
- `scripts/build.mjs`: generates browser-specific packages.
|
||||
- `tests/`: isolated synthetic regression tests and a visual fixture.
|
||||
- `docs/`: architecture/contracts and incremental refactoring record.
|
||||
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
|
||||
- `dist/firefox/`: output for Firefox.
|
||||
|
||||
## CRM Service Request Badges
|
||||
|
||||
The header's **Refresh all** button (between theme and Preferences) invalidates
|
||||
persisted Products and activity caches and clears in-memory data views. It reloads
|
||||
the selected Reference, fetching fresh products and then the permitted SR/Actions
|
||||
queries. Customer details, rate cards, consumption charts and AI summaries are
|
||||
requested again when opened. Preferences, cookies and authentication are preserved.
|
||||
The button is disabled during invalidation and list reload; product/activity badges
|
||||
continue to show subsequent background progress. Cache-clear failures are surfaced
|
||||
instead of silently reusing cached data, and stale product responses cannot restore
|
||||
an invalidated cache.
|
||||
|
||||
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
||||
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
||||
do not trigger CRM Service Request or Workload Actions queries or see their badges.
|
||||
Identity must contain a valid user (id, name, roles) and token before dependent
|
||||
requests start. Failed identity validation keeps those requests waiting and retries
|
||||
with backoff. Reload the page to refresh identity/roles.
|
||||
The identity token is retained in debug responses and hidden by default; the
|
||||
explicit sensitive-values toggle reveals the original value.
|
||||
|
||||
After an initial identity failure, recovery opens `https://spa.oracle.com/` in one
|
||||
inactive auxiliary tab. The top-frame-only `spa-page.js` content script inspects
|
||||
the page without sending HTTP requests. It rejects error pages, SAML/login forms,
|
||||
OAuth callbacks and incomplete documents; cookies, HTTP 200 and elapsed time alone
|
||||
do not establish readiness. After a stable loaded document is verified, identity
|
||||
is fetched in that tab's same-origin context, at most once per document. A failed
|
||||
read requires a fresh verified navigation before retrying. Rejected callbacks
|
||||
restart at the SPA home URL, never at the one-use callback URL. A two-minute
|
||||
readiness timeout ends the cycle without an additional API probe. The owned tab
|
||||
and observers are cleaned up on success or failure. Debug metadata records the
|
||||
page state, wait reason and `spa-tab` execution context; sensitive values remain
|
||||
hidden by default. Reload the extension after changes to the manifest or script.
|
||||
|
||||
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
|
||||
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
|
||||
preserves the originating window and Firefox cookie store, and retries within
|
||||
the same five-attempt limit. Debug entries retain the HTTP status and recovery
|
||||
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
|
||||
|
||||
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
|
||||
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
|
||||
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
||||
query endpoint, independently of the opportunity query version. Calls appear in
|
||||
the Service Requests debug category with masked credentials.
|
||||
After a complete successful SR query (HTTP 200 with valid responses on all pages),
|
||||
its result is reused for subsequent Reference/product cycles in the current page
|
||||
session. Refresh all explicitly clears it. Failed pages are retried up to five
|
||||
total attempts each; completed pages are not requested again. Resetting the query
|
||||
stops further retries from the previous run.
|
||||
|
||||
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
|
||||
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
||||
email and contact flags. A red badge indicates no match within that query's scope.
|
||||
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
||||
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||
timestamp rather than the activity response time. Actions entries match each
|
||||
product's update; the SR snapshot matches the latest product update in the cycle.
|
||||
SR snapshots are stored separately by update timestamp so changing Reference does
|
||||
not overwrite a previous period's snapshot. Expired snapshots are removed on
|
||||
subsequent SR cache writes; Refresh all invalidates every snapshot.
|
||||
No credentials, request headers or debug envelopes are stored in this cache.
|
||||
Products loaded from cache restore matching activity entries without network calls.
|
||||
Missing, expired or mismatched entries are not treated as confirmed empty results;
|
||||
they wait for a real product refresh before a new activity query is allowed.
|
||||
Empty and entirely failed product cycles do not query activities. In mixed cycles,
|
||||
only fresh products permit activity API calls on cache misses; an already-loaded
|
||||
SR result is reused and associated with the new product update.
|
||||
Activity lookups do not advance the existing Last update timestamp.
|
||||
Closing and reopening the modal preserves results and does not
|
||||
reload products or trigger these lookups. Already-running product requests may
|
||||
finish in the background and update their related activities.
|
||||
|
||||
## Workload Actions
|
||||
|
||||
The missing Act badge includes a separate plus control that opens an Action form
|
||||
with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
|
||||
Opening the form loads team members and action lookups using the WWB-TS token.
|
||||
Team members use the product RevnId; when multiple workloads are available, the
|
||||
user selects one before its members are requested. Owner options use email for
|
||||
both label and value; lookup options display name and submit code.
|
||||
All Action selects support local autocomplete by label or code, arrow-key
|
||||
navigation and Enter selection. Escape or leaving the field restores the last
|
||||
selected option; typed search text is never submitted as an option value.
|
||||
Fields are enabled only after both metadata requests return HTTP 200. Loading,
|
||||
errors and manual retry are supported, and debug logs mask the credential.
|
||||
Save validates all required fields and the date range before posting to the WWB
|
||||
actions endpoint. Dates are sent at midnight UTC in ISO format; notes are an
|
||||
array of strings (empty when omitted). The current user.id supplies createdBy
|
||||
and updatedBy. While saving, editing and dismissal are locked to prevent duplicate
|
||||
submissions. Success closes the form and displays a toast; failure preserves the
|
||||
form and displays an error toast without automatically retrying the POST.
|
||||
Cancel or Escape dismisses an idle form. The form is unavailable to SALES_REP
|
||||
users, like the other Action features.
|
||||
|
||||
SR and Action filter badges appear before Status for users allowed to view these
|
||||
activities. They filter the loaded data without issuing requests and combine with
|
||||
the existing opportunity filters. The SR below 4h option replaces the other SR
|
||||
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
|
||||
(including zero, excluding missing values). Action matches use the same ownership
|
||||
rule as the row badge. Loading/failed lookups are not treated as missing records;
|
||||
with both positive and negative options selected, all activity states remain
|
||||
visible. These selections are kept in memory, not persisted.
|
||||
|
||||
Workload Actions are queried in the background after the product cycle completes,
|
||||
and only after SR has completed successfully (or was restored/reused). If SR is
|
||||
unavailable, Actions are not requested and remain unknown rather than absent.
|
||||
Each failed workload lookup gets up to five total attempts, stopping on success
|
||||
or when its session is superseded. Cached products still do not trigger network
|
||||
lookups. Each distinct `RevnId` uses `requestWlActions` with
|
||||
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
||||
aggregates all products: green means an action's `owner` matches the general
|
||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||
means ownership could not be confirmed (including a missing principal). Tooltips
|
||||
retain all returned actions, regardless of owner. Credentials stay in memory only;
|
||||
the principal is used to scope persisted activity entries.
|
||||
Queries are shared per product cycle (up to four concurrently). Reference changes
|
||||
that load products from cache do not refresh activities.
|
||||
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
||||
The debug panel groups these requests under Actions, with credentials masked.
|
||||
|
||||
## Build
|
||||
|
||||
Use o Node.js:
|
||||
Use Node.js:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
O build recria `dist/chromium` e `dist/firefox` a partir da mesma fonte.
|
||||
The build updates `dist/chromium` and `dist/firefox` from the same source.
|
||||
It preserves the output directories and unchanged files, replacing changed files
|
||||
only after their complete contents have been written to a temporary file. Scripts
|
||||
are published before the manifest, avoiding missing or partially written scripts
|
||||
while an unpacked extension is loaded. Reload the extension and the Oracle page
|
||||
after updating; clear historical extension errors to check for new failures.
|
||||
|
||||
## Como carregar em modo desenvolvimento
|
||||
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
||||
Chromium service worker and Firefox background scripts. The build composes classic
|
||||
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
||||
`src/features/debug/styles.css`, `src/features/opportunities/styles.css`,
|
||||
`src/features/install-base/styles.css`, `src/features/service-requests/styles.css`
|
||||
`src/features/consumption/plan-detail.css` and `src/features/ratecard/styles.css` at
|
||||
build time. There is no
|
||||
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||
|
||||
### Chrome ou Microsoft Edge
|
||||
## Tests and Maintenance
|
||||
|
||||
1. Abra `chrome://extensions` ou `edge://extensions`.
|
||||
2. Ative o modo de desenvolvedor.
|
||||
3. Clique em "Carregar sem compactacao".
|
||||
4. Selecione a pasta `dist/chromium`.
|
||||
Installed Base and Service Requests include a search input above their tables.
|
||||
Search filters loaded records across every displayed column, including raw and
|
||||
formatted numbers/dates, without additional API calls. Matching ignores case
|
||||
and accents; all whitespace-separated terms must match somewhere in the row.
|
||||
The result count updates as users type. Clearing the input restores all records;
|
||||
reopening a dialog starts with an empty search. Search remains available during
|
||||
loading and retains the entered terms when results arrive. Both themes are supported.
|
||||
|
||||
Each Cloud Consumption plan includes a **Rate card** button beside Contract ID.
|
||||
It opens an animated right-side drawer with skeleton rows, an all-column search
|
||||
and sortable headers. The drawer reuses the Cloud Consumption header component
|
||||
with the title **Rate Card**, customer subtitle, and the active plan's Booking
|
||||
Customer, Sold To Party Name, Contract ID, Header Status, Start Date and End Date.
|
||||
There is no separate plan-number subtitle or nested Rate Card action; its Close button
|
||||
dismisses only the drawer. `requestRatecardByPlan` sends the active plan's `subPlanNum`
|
||||
as the payload's `planNumber`, with `recordsSize: 10000`, to the fixed SPA endpoint.
|
||||
It uses the existing OCICONS-TS `uiaasHeader.value` in `G2m-Authorization` and is
|
||||
logged in the Consumption debug category with the existing credential masking.
|
||||
The eleven requested fields are displayed, including percentage discounts and
|
||||
USD prices with at least four decimal places. Search includes raw and formatted
|
||||
values; prices, quantities, percentages and dates sort by value. Only the drawer
|
||||
table scrolls, horizontally when its available width is below 1100px.
|
||||
Closing the drawer, changing plans or closing Cloud Consumption discards late UI
|
||||
updates. Concurrent requests for the same plan are shared; reopening after completion
|
||||
fetches again. Errors remain local. No new persisted cache, permissions, token
|
||||
refresh or retries are added. The provided contract requests up to 10000 records;
|
||||
the response's `searchAfter` is not used without a defined pagination contract.
|
||||
`tests/ratecard.test.cjs` covers transport, formatting, filtering, sorting and
|
||||
drawer lifecycle with synthetic responses and DOM, without launching browsers.
|
||||
|
||||
Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after
|
||||
account plans arrive, independently of the chart. Selecting another plan loads
|
||||
its `subPlanNum` on demand. The GET uses `active=true` and the raw OCICONS-TS
|
||||
`uiaasHeader.value` in `G2m-Authorization`, through the existing consumer session.
|
||||
Projection badges align with the chart container's left edge, while the
|
||||
Quarterly/Monthly/Weekly/Daily controls stay on the right of the same header row:
|
||||
red Forfeit, blue Overage.
|
||||
Hover or keyboard focus reveals an interactive tooltip; clicking pins it for
|
||||
access to the Order number links. Escape, Close, outside clicks, tab changes and
|
||||
modal closure dismiss it. Each root line retains its nested `clines` details.
|
||||
Only HTTPS order links on `accountdashboard.oracle.com` become clickable.
|
||||
Amounts use the requested non-LC fields, formatted without inferring a local
|
||||
currency. Details are kept only in the current modal, with in-flight deduplication
|
||||
and local loading/empty/error states. Failed details do not block the chart.
|
||||
The existing Consumption debug category includes this request.
|
||||
|
||||
The Service Requests action sits immediately after Installed Base below Customer.
|
||||
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
|
||||
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
|
||||
`id` (not `registryId`) in the GET `requestCustomerServiceRequest` URL. The SR
|
||||
credential is `uiaasHeader.value` from `SPA-ORANGE`, sent verbatim in
|
||||
`custsr-auth`, without a Bearer prefix, regardless of `uiaasHeader.label`.
|
||||
Pages of 40 are consolidated using `totalResults`, `count`
|
||||
and `offset`. Concurrent clicks for the same customer share the in-flight query;
|
||||
results are not cached, and closed/replaced dialogs ignore late responses.
|
||||
The table maps Name/Registry ID from `customer`, Product Name from `component`,
|
||||
and formats status as a badge and dates using the existing date formatter.
|
||||
Debug contains a Service Requests category; credential headers are masked before
|
||||
delivery to content. This feature adds no permissions or authentication retries.
|
||||
|
||||
The Installed Base action sits beside customer consumption below Customer. It
|
||||
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
|
||||
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
|
||||
`uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore`
|
||||
is false. Missing credentials, invalid collections and stalled pagination show
|
||||
an error rather than partial results. These requests appear in debug; credential
|
||||
headers in this flow are masked before delivery to the content script. No new
|
||||
cache, permissions or session recovery logic is introduced. Amounts use two
|
||||
decimal places without inferring a currency from the separate LC fields.
|
||||
|
||||
The opportunities table places product counts below Name and the customer
|
||||
consumption action below Customer, without a separate Actions column. Compact
|
||||
columns retain their values; Name and Customer share the remaining width while
|
||||
Owner is fixed at 132px. Both text cells reserve at least two text lines, and
|
||||
their controls stay anchored 10px above the row bottom, including while products
|
||||
load. Longer text can grow the row without overlapping controls. Horizontal
|
||||
scrolling is reserved for table containers below
|
||||
1120px. The scoped table stylesheet does not change product-table column widths
|
||||
or the existing light/dark colors.
|
||||
|
||||
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
|
||||
Each entry identifies the opportunity and page offset, stays `running` while
|
||||
waiting for headers or the response body, and updates with HTTP status, response,
|
||||
duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
||||
mask authorization and sensitive response headers before messaging the source
|
||||
tab/frame; this change adds no timeout, retry or authentication behavior.
|
||||
|
||||
Debug Requests opens as a full-page view inside the extension using Ctrl+D, without
|
||||
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D
|
||||
returns to the underlying view. Header/category navigation remains above the
|
||||
scrollable timeline; expanded details use two columns on wide screens and one on
|
||||
narrow screens. Theme-specific colors distinguish success, failure, running,
|
||||
HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
|
||||
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
|
||||
supported protection prefixes and JSON documents wrapped in strings are normalized
|
||||
and indented, including while sensitive values are visible. Large JSON responses
|
||||
are captured in full, preserving indentation, highlighting and sensitive-field
|
||||
masking. Original sensitive values are retained for explicit reveal, including
|
||||
identity, tokenrelay and catalog tokens. Already incomplete
|
||||
or invalid JSON remains plain text. The visibility toggle retains its existing
|
||||
behavior. Focus, expansion and timeline scroll position survive log updates.
|
||||
|
||||
Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests
|
||||
requests; unknown request types appear under Other. Only categories with entries
|
||||
are shown. All preserves the complete timeline. Every tab sorts by request start
|
||||
time, retaining original request IDs and showing start times in collapsed rows.
|
||||
Counts and running states update without changing the selected category or
|
||||
collapsing expanded details. Use arrow keys, Home and End to navigate the tabs.
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run check
|
||||
```
|
||||
|
||||
Tests use built-in Node.js modules and synthetic credentials, without real Fusion
|
||||
requests. The characterization suite verifies manifests, source/output equality,
|
||||
fiscal dates, payloads, preferences, pagination, cache/progress and both browser API
|
||||
styles. Architecture tests enforce source inclusion, explicit factory dependencies,
|
||||
isolated state and unchanged CSS. These complement consumption, session recovery
|
||||
and AI suites. `check` also fails if generated packages are stale.
|
||||
There is no separate lint configuration or runtime dependency installation.
|
||||
|
||||
See [architecture and contracts](docs/architecture.md) and the
|
||||
[incremental refactoring record](docs/refactoring.md) for execution boundaries,
|
||||
authentication, storage, known risks and verification limits.
|
||||
Mock-based tests do not replace testing the installed extension in each browser.
|
||||
|
||||
Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and
|
||||
`node scripts/build.mjs --check`. Development checks were run with Node 24.19.0.
|
||||
|
||||
For a local UI smoke test, run `node scripts/preview.mjs` and open the loopback URL
|
||||
printed by the command. The fixture uses synthetic opportunities, tokens, products,
|
||||
AI text and consumption; unexpected requests fail closed. It does not authenticate
|
||||
against Fusion or read real extension storage. `/consumption-trend.html` provides
|
||||
a focused chart fixture. Stop the server with Ctrl+C when finished.
|
||||
|
||||
## Loading in Development Mode
|
||||
|
||||
### Chrome or Microsoft Edge
|
||||
|
||||
1. Open `chrome://extensions` or `edge://extensions`.
|
||||
2. Enable Developer mode.
|
||||
3. Click **Load unpacked**.
|
||||
4. Select the `dist/chromium` folder.
|
||||
|
||||
### Firefox
|
||||
|
||||
1. Abra `about:debugging#/runtime/this-firefox`.
|
||||
2. Clique em "Carregar extensao temporaria".
|
||||
3. Selecione o arquivo `dist/firefox/manifest.json`.
|
||||
1. Open `about:debugging#/runtime/this-firefox`.
|
||||
2. Click **Load Temporary Add-on**.
|
||||
3. Select the `dist/firefox/manifest.json` file.
|
||||
|
||||
## Paginas atendidas
|
||||
## Supported Pages
|
||||
|
||||
- `https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome`
|
||||
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
||||
|
||||
Ao encontrar o grupo `#yourapps_groupNode_sales`, a extensao adiciona o tile antes do item `.flat-grid-cell.flat-grid-cell-addicon`. Ao clicar no tile, exibe o alerta `Deu certo`.
|
||||
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
||||
|
||||
97
RELEASE_NOTES.md
Normal file
97
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Release Notes
|
||||
|
||||
## Release 1.0.9 - 2026-10-28
|
||||
|
||||
- Added a landscape print/PDF view with filters, totals, and opportunities.
|
||||
- Resolve the CRM Service Request saved search dynamically before querying records.
|
||||
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
|
||||
- Align opportunity badges and customer actions consistently.
|
||||
- Move release history from README to this dedicated file.
|
||||
|
||||
## Release 1.0.0
|
||||
|
||||
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
||||
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
||||
- Opportunity type view preferences persisted in the browser.
|
||||
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
|
||||
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
|
||||
- Light and dark themes based on Oracle Redwood.
|
||||
- Separate outputs for Chromium/Edge and Firefox.
|
||||
|
||||
## Release 1.0.1
|
||||
|
||||
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
|
||||
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
|
||||
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
|
||||
|
||||
## Release 1.0.2
|
||||
|
||||
- Modularized the extension with regression tests and documented execution boundaries.
|
||||
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
|
||||
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
|
||||
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
|
||||
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
|
||||
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
|
||||
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
|
||||
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.3 - 2026-10-13
|
||||
|
||||
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
|
||||
- Added searchable Action forms, required-field validation, saving and local badge updates.
|
||||
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
|
||||
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
|
||||
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
|
||||
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
|
||||
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
|
||||
|
||||
## Release 1.0.4 - 2026-10-19
|
||||
|
||||
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
|
||||
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
|
||||
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
|
||||
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
|
||||
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
|
||||
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||
|
||||
## Release 1.0.5 - 2026-10-20
|
||||
|
||||
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
|
||||
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
|
||||
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
|
||||
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
|
||||
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
|
||||
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||
|
||||
## Release 1.0.6 - 2026-10-21
|
||||
|
||||
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
|
||||
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
|
||||
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
|
||||
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
|
||||
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.7 - 2026-10-26
|
||||
|
||||
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
|
||||
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
|
||||
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
|
||||
|
||||
## Release 1.0.8 - 2026-10-27
|
||||
|
||||
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
|
||||
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
|
||||
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
|
||||
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
|
||||
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
|
||||
- Removed AI-generated opportunity summaries and their dedicated requests.
|
||||
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
|
||||
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
|
||||
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
|
||||
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
|
||||
3427
dist/chromium/background.js
vendored
3427
dist/chromium/background.js
vendored
File diff suppressed because it is too large
Load Diff
13111
dist/chromium/content.js
vendored
13111
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
21
dist/chromium/manifest.json
vendored
21
dist/chromium/manifest.json
vendored
@@ -2,12 +2,17 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.9",
|
||||
"permissions": [
|
||||
"cookies"
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
"declarativeNetRequestWithHostAccess"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||
"https://spa.oracle.com/*",
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -20,6 +25,16 @@
|
||||
],
|
||||
"all_frames": true,
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://spa.oracle.com/*"
|
||||
],
|
||||
"js": [
|
||||
"spa-page.js"
|
||||
],
|
||||
"all_frames": false,
|
||||
"run_at": "document_end"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
|
||||
58
dist/chromium/spa-page.js
vendored
Normal file
58
dist/chromium/spa-page.js
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
// Source: src/auth/spa-page.js
|
||||
function createSpaPage() {
|
||||
function start() {
|
||||
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
|
||||
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
|
||||
if (!runtime) return;
|
||||
const documentId = `${performance.timeOrigin}:${location.pathname}`;
|
||||
let identityRequest = null;
|
||||
|
||||
function inspect() {
|
||||
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
|
||||
let status = "loading";
|
||||
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
|
||||
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|
||||
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|
||||
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
|
||||
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
|
||||
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
|
||||
return { status, documentId };
|
||||
}
|
||||
|
||||
runtime.onMessage.addListener((message, sender, reply) => {
|
||||
if (sender.id !== runtime.id) return false;
|
||||
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
|
||||
reply(inspect());
|
||||
return false;
|
||||
}
|
||||
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
|
||||
const page = inspect();
|
||||
if (page.status !== "ready" || page.documentId !== message.documentId) {
|
||||
reply({ error: "SPA page is not ready or has navigated." });
|
||||
return false;
|
||||
}
|
||||
// A document is probed at most once; failed identity reads require a fresh navigation.
|
||||
if (!identityRequest) identityRequest = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
||||
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
|
||||
});
|
||||
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
|
||||
contentType: response.headers.get("content-type") || "", body: await response.text() };
|
||||
} catch (error) {
|
||||
return { error: error.message || "Unable to read SPA identity." };
|
||||
} finally { clearTimeout(timer); }
|
||||
})();
|
||||
identityRequest.then(reply);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return { start };
|
||||
}
|
||||
|
||||
createSpaPage().start();
|
||||
})();
|
||||
3427
dist/firefox/background.js
vendored
3427
dist/firefox/background.js
vendored
File diff suppressed because it is too large
Load Diff
13111
dist/firefox/content.js
vendored
13111
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
21
dist/firefox/manifest.json
vendored
21
dist/firefox/manifest.json
vendored
@@ -2,12 +2,17 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.9",
|
||||
"permissions": [
|
||||
"cookies"
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
"declarativeNetRequestWithHostAccess"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||
"https://spa.oracle.com/*",
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -20,6 +25,16 @@
|
||||
],
|
||||
"all_frames": true,
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://spa.oracle.com/*"
|
||||
],
|
||||
"js": [
|
||||
"spa-page.js"
|
||||
],
|
||||
"all_frames": false,
|
||||
"run_at": "document_end"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
|
||||
58
dist/firefox/spa-page.js
vendored
Normal file
58
dist/firefox/spa-page.js
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
// Source: src/auth/spa-page.js
|
||||
function createSpaPage() {
|
||||
function start() {
|
||||
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
|
||||
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
|
||||
if (!runtime) return;
|
||||
const documentId = `${performance.timeOrigin}:${location.pathname}`;
|
||||
let identityRequest = null;
|
||||
|
||||
function inspect() {
|
||||
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
|
||||
let status = "loading";
|
||||
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
|
||||
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|
||||
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|
||||
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
|
||||
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
|
||||
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
|
||||
return { status, documentId };
|
||||
}
|
||||
|
||||
runtime.onMessage.addListener((message, sender, reply) => {
|
||||
if (sender.id !== runtime.id) return false;
|
||||
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
|
||||
reply(inspect());
|
||||
return false;
|
||||
}
|
||||
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
|
||||
const page = inspect();
|
||||
if (page.status !== "ready" || page.documentId !== message.documentId) {
|
||||
reply({ error: "SPA page is not ready or has navigated." });
|
||||
return false;
|
||||
}
|
||||
// A document is probed at most once; failed identity reads require a fresh navigation.
|
||||
if (!identityRequest) identityRequest = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
||||
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
|
||||
});
|
||||
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
|
||||
contentType: response.headers.get("content-type") || "", body: await response.text() };
|
||||
} catch (error) {
|
||||
return { error: error.message || "Unable to read SPA identity." };
|
||||
} finally { clearTimeout(timer); }
|
||||
})();
|
||||
identityRequest.then(reply);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return { start };
|
||||
}
|
||||
|
||||
createSpaPage().start();
|
||||
})();
|
||||
213
docs/architecture.md
Normal file
213
docs/architecture.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Architecture and Compatibility Contracts
|
||||
|
||||
This document describes the integrated modular implementation. Verification and
|
||||
preexisting risks are tracked in [refactoring.md](refactoring.md).
|
||||
|
||||
## Sources, Outputs, and Runtime Boundaries
|
||||
|
||||
- Editable sources are factory modules under `src/`; the former monoliths were removed.
|
||||
- `scripts/sources.cjs` explicitly lists modules for each context. `compose.cjs`
|
||||
validates syntax, serializes CSS, wraps the modules in an IIFE and calls the
|
||||
corresponding application factory/start method. `build.mjs` writes two packages.
|
||||
- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs.
|
||||
- Both manifests use V3. Chromium has a classic background service worker; Firefox
|
||||
uses `background.scripts`. Neither entry point is declared as a module.
|
||||
- Content scripts run at `document_idle`, in all matching frames on the two
|
||||
configured Fusion welcome pages. Their code also checks the hostname/path.
|
||||
- `src/auth/spa-page.js` is a separate top-frame-only SPA content script, copied
|
||||
into both distributions. It handles only extension messages for DOM readiness
|
||||
inspection and a fixed same-origin identity GET; it sends no requests on its own.
|
||||
`services/spa/page-client.js` connects this receiver to the background recovery
|
||||
flow and the existing debug envelope. No page-world script is injected.
|
||||
- There are no extension UI pages, injected page-context scripts, custom CSP,
|
||||
declared execution world, or web-accessible resources.
|
||||
- Permissions remain `cookies` and `storage`, with host access limited to the
|
||||
configured Fusion host and `spa.oracle.com`.
|
||||
|
||||
Content owns the DOM and per-frame state. Background owns privileged cookies,
|
||||
temporary tabs, extension storage and SPA requests. These contexts communicate
|
||||
using serializable runtime messages, not shared variables. Page localStorage is
|
||||
different from extension storage and is not a safe place for credentials.
|
||||
|
||||
## Current Ownership and Side Effects
|
||||
|
||||
| Context | Responsibilities | State and effects |
|
||||
| --- | --- | --- |
|
||||
| Content | Fusion tile and main/preferences/consumption modals | DOM nodes, injected CSS, keyboard/pointer/resize listeners, MutationObserver |
|
||||
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
|
||||
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
|
||||
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
|
||||
| Content | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close |
|
||||
| Content | Rate card | Pure column/filter/sort model, controller with per-plan in-flight sharing, animated drawer with skeleton/search/table; response guards and cleanup on close or plan switch |
|
||||
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
|
||||
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
|
||||
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
|
||||
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` |
|
||||
| Background | Products | Four workers, per-opportunity in-flight map, lazy cache read and serialized writes |
|
||||
| Background | Cookie/session recovery | Inactive tabs, polling/retry delays, cleanup in `finally` |
|
||||
| Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes |
|
||||
|
||||
Closing the main modal hides it rather than destroying it. Many requests can
|
||||
continue while it is hidden. Version and request-ID checks suppress stale UI
|
||||
updates; they do not abort network traffic. No AbortController is currently used.
|
||||
Some listeners have window guards; there is no general disposal lifecycle.
|
||||
|
||||
## Authentication and API Routing
|
||||
|
||||
Never substitute one token family for another or assume an opaque token is a JWT.
|
||||
Production tokens are held in memory, not deliberately persisted by the extension.
|
||||
|
||||
| Credential | Acquisition | Consumers | Lifetime/recovery |
|
||||
| --- | --- | --- | --- |
|
||||
| XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for both tokenrelays | Cookie recovery tab on missing/invalid response; existing retry rules |
|
||||
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
|
||||
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
|
||||
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
|
||||
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans, consumption trend, plan detail and rate card authorization | Same service response, distinct credential from ACCP-TS |
|
||||
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
|
||||
|
||||
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
|
||||
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
|
||||
operation-specific headers rather than unifying all calls under a default token.
|
||||
|
||||
| Request | Method/path | Important contract |
|
||||
| --- | --- | --- |
|
||||
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | RecordSet OR plus EffectiveDate AND, original fields, Preference transient, pages of 15 |
|
||||
| requestOpptyProducts | GET `/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/{number}/child/ChildRevenue` | Encoded identifier, original query/fields/order, count/offset pagination, maximum 250 pages |
|
||||
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
|
||||
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
|
||||
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
|
||||
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
|
||||
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
|
||||
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
|
||||
| requestConsumptionPlanDetail | GET `provider-proxy/g2m-consumption/service/v1/plan/plandetail/{subPlanNum}?active=true` | Raw OCICONS-TS in G2m-Authorization; array of lines with optional nested clines; no retry or persisted cache |
|
||||
| requestRatecardByPlan | POST `provider-proxy/g2m-consumption/service/v1/ratecard/byPlan` | `{planNumber,recordsSize:10000}`, where planNumber is the selected plan's subPlanNum; raw OCICONS-TS in fixed G2m-Authorization; response must be an array of records; no retries, cursor pagination or persistent cache |
|
||||
|
||||
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
|
||||
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
|
||||
POST retry policies require operation-specific review; do not add generic retries.
|
||||
|
||||
## Message Contracts
|
||||
|
||||
All names below have the `opportunitiesExtension.` prefix. Unknown messages return
|
||||
false without a response. Supported background messages return true immediately
|
||||
and later call sendResponse. `platform/background-api.js` centralizes the existing
|
||||
callback/promise differences. `platform/content-messages.js` retains the original
|
||||
cookie callback path as well as general runtime messaging; no browser behavior
|
||||
was silently changed during extraction.
|
||||
|
||||
| Message | Input | Result |
|
||||
| --- | --- | --- |
|
||||
| getXsrfToken | type | ok, cookieName, token, matchedCookieNames, lookupDetails; error on failure |
|
||||
| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error |
|
||||
| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure |
|
||||
| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error |
|
||||
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans/installBase/customerServiceRequests, debugRequests |
|
||||
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
|
||||
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
|
||||
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
|
||||
| requestConsumptionPlanDetail | subPlanNum, authorization | ok, subPlanNum, items (including clines), debugRequests; error envelope on failure |
|
||||
| requestRatecardByPlan | planNumber, authorization | ok, planNumber, items, debugRequests; error envelope on failure |
|
||||
|
||||
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
|
||||
`running`, `complete` and `complete-with-errors`; content additionally tracks idle
|
||||
and error states. A new content request ID rejects progress from an older job.
|
||||
Do not expose a caller-provided arbitrary request URL through the background.
|
||||
|
||||
## Storage Contracts
|
||||
|
||||
| Location/key | Value |
|
||||
| --- | --- |
|
||||
| Page localStorage: `opportunities-extension-filter-preferences` | JSON object: period, stages, statuses, customerKeys, ownerKeys, customerSearch, ownerSearch, tableSearch; unset customer/owner selections may be null |
|
||||
| Page localStorage: `opportunities-extension-theme` | `light` or `dark` |
|
||||
| Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours |
|
||||
| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` |
|
||||
|
||||
Product TTL defaults to 24 hours and is capped at 30 days in background. Storage
|
||||
failures are tolerated for the active view. Recreating background reloads stored
|
||||
products but does not restore in-flight jobs. Cache TTL does not physically delete
|
||||
expired entries. Account/container isolation limitations are tracked separately.
|
||||
|
||||
## UI Contracts
|
||||
|
||||
Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter
|
||||
semantics, formatting, theme colors, keyboard shortcuts and row expansion.
|
||||
WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name
|
||||
with the existing fallbacks. Changing reference resets customers/owners to all;
|
||||
closing/reopening preserves search. Fiscal years begin in June; RENEWALS currently
|
||||
has no date range and therefore does not load opportunities.
|
||||
|
||||
Preserve SVG chart data normalization, animation, tooltips and series selection.
|
||||
AI text is inserted with textContent. Debug masking and DOM isolation have known
|
||||
limitations; see the separate security backlog rather than assuming full redaction.
|
||||
|
||||
## Module Ownership and Dependencies
|
||||
|
||||
`content/main.js` and `background/main.js` only initialize. Each `application.js`
|
||||
is a composition root: constructs feature-owned state and factories, wires explicit
|
||||
function dependencies, then exposes `start`. The returned components are available
|
||||
to isolated tests, not published on the page/global object in production.
|
||||
|
||||
Factories do not import or instantiate peers. Features receive service/auth ports;
|
||||
services/auth receive transport/platform ports; pure models receive data. The
|
||||
composition roots supply callback notifications for UI updates and debug events,
|
||||
preserving existing interactions without module-import cycles. Shared messages
|
||||
are the only source included in both contexts, with distinct instances/state.
|
||||
Some stateful UI collaborators intentionally share a feature-owned state object;
|
||||
these objects are created per application, never stored in a global registry.
|
||||
|
||||
| Original responsibility | Integrated module(s) | Reason for boundary |
|
||||
| --- | --- | --- |
|
||||
| content initialization/tile | `content/main.js`, `fusion-page.js` | Page lifecycle and route guard |
|
||||
| modal/theme/CSS | `content/modal.js`, `theme.js`, `styles.js`, `styles/redwood.css` | DOM shell separate from operations and cascade |
|
||||
| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation |
|
||||
| table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state |
|
||||
| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog |
|
||||
| token families | `auth/content-session.js`, `sales-common.js`, `token-relay.js`, `spa-credentials.js` | Different credentials and lifetime policies |
|
||||
| cookie lookup/recovery, SPA session | `auth/xsrf-*.js`, `consumer-session.js` | Privileged recovery separate from UI |
|
||||
| product queue/cache | `features/products/background-jobs.js`, `cache.js`, `services/fusion/products.js` | Scheduling, persistence and pagination |
|
||||
| product progress/details | `features/products/content-jobs.js`, `details.js` | Message consumer vs row rendering |
|
||||
| AI summary | `features/ai-summary/content.js`, `state.js` | Per-opportunity lifecycle, safe text and auth port |
|
||||
| consumption | `features/consumption/`, `services/spa/` | Plans, chart model/view/controller vs HTTP operations |
|
||||
| debug | `features/debug/content-log.js`, `content-view.js` | Retention/formatting vs DOM presentation |
|
||||
| background dispatcher | `background/messages.js`, `shared/messages.js` | Stable serializable message contracts |
|
||||
| browser APIs | `platform/background-api.js`, `content-messages.js` | Only APIs actually used, not a universal wrapper |
|
||||
|
||||
The background composition deliberately has no DOM dependency. Listener registration
|
||||
is synchronous, before any storage read or request. Worker restart reconstructs
|
||||
volatile state and lazily reads the existing product cache; tokens are not persisted.
|
||||
Content effects (DOM, fetch, storage, animation) enter through the environment at
|
||||
the composition root. localStorage access stays lazy to retain blocked-storage
|
||||
fallbacks. Tests may inject effects or individual callback ports without changing
|
||||
source strings. Time-sensitive modules are tested with isolated clocks.
|
||||
|
||||
## Adding a Feature
|
||||
|
||||
1. Define its message/API/storage contract and synthetic expected behavior first.
|
||||
2. Add a factory under the owning feature, or a service under the correct host.
|
||||
Accept narrow function dependencies; do not call another factory inside it.
|
||||
3. Add its source path to the appropriate array in `scripts/sources.cjs`. Source
|
||||
inclusion and duplicate factory checks run automatically.
|
||||
4. Construct it in that context's `application.js`, supplying auth, platform and
|
||||
render callbacks explicitly. Extend `background/messages.js` only if crossing
|
||||
contexts is necessary; do not expose arbitrary destinations.
|
||||
5. Add a `tests/*.test.cjs` suite using `tests/support/application.cjs` for composed
|
||||
integration or `vm.Script` for an individual pure factory. Use only synthetic
|
||||
data and fail unexpected network requests.
|
||||
6. Run tests, build and `--check`; inspect the local fixture and load both packages
|
||||
in the target browsers. Record any browser not available for verification.
|
||||
|
||||
As a concrete example, adding a new consumption period should first define its
|
||||
request/data/authorization contract and synthetic tests, then extend the period
|
||||
allowlist and normalization/presentation through the existing trend message. It
|
||||
must not add a URL-proxy message or reuse the general Fusion token. These edits
|
||||
belong in the consumption feature and SPA service, not the bootstrap.
|
||||
|
||||
## Source vs Generated Code
|
||||
|
||||
Distribution `content.js` and `background.js` are generated bundles and may remain
|
||||
large. No copy/export/execute JavaScript generator feature was found in the source.
|
||||
AI summaries are text, not executable JavaScript. No empty generators subsystem
|
||||
was added. Build serialization uses JSON.stringify for CSS, retains quotes,
|
||||
backslashes, Unicode and line breaks, and is syntax-checked by vm.Script. Visual
|
||||
fixtures now load composed scripts normally; they no longer eval rewritten sources.
|
||||
65
docs/browser-smoke.md
Normal file
65
docs/browser-smoke.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Native Browser Validation
|
||||
|
||||
This checklist complements the synthetic tests; it is not evidence of a native
|
||||
pass until executed and recorded separately for each browser.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Use an authorized test profile/account. Do not delete cookies, sign out, change
|
||||
account security settings or inspect credential values to force failure cases.
|
||||
- Connect Chrome/Edge through Settings > Computer use for assisted browser tests.
|
||||
Firefox needs a separate supported/manual test session.
|
||||
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
|
||||
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
|
||||
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
|
||||
directory and version 1.0.7; avoid testing a previously installed stale copy.
|
||||
- Keep debug secrets hidden. Record only status codes, request names, durations
|
||||
and sanitized errors. Do not export real tokens, response bodies or account data.
|
||||
- AI generation and tests requiring a deliberately expired session must use an
|
||||
explicitly authorized test environment. Do not trigger live writes solely to
|
||||
complete this checklist.
|
||||
|
||||
## Checklist
|
||||
|
||||
| Check | Expected result |
|
||||
| --- | --- |
|
||||
| Package load | No manifest/background/content syntax errors; no additional permissions |
|
||||
| Both Fusion welcome routes | Exactly one tile before the add-icon item; no tile on unrelated pages |
|
||||
| Modal open/close/reopen | Same search value and matching results; Esc closes the correct dialog |
|
||||
| Filters and sorting | Correct customer/owner/stage/status/text combinations; WON includes WON_PENDING |
|
||||
| Reference change | Customer and owner reset to all; stale responses do not overwrite new results |
|
||||
| Preferences | Save/reopen retains selected record sets and cache duration; restore original settings afterward |
|
||||
| Products | Progress completes; badges/counts and expanded rows agree; warm cache avoids redundant product fetches |
|
||||
| AI in authorized test environment | Summary appears without replacing products; concurrent expansions share scoped-token acquisition; reopen uses valid token |
|
||||
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work |
|
||||
| Themes and layout | Light/dark controls work; check long names, WON_PENDING and full update timestamps |
|
||||
| Debug | Expected operation names/statuses; sensitive values remain hidden |
|
||||
| Organization charts | Icon after customer SR action; add/report/reparent/edit/notes/collapse; Save chart then reopen from another opportunity for the same customer; reload page and restart browser retains chart; theme and small viewport remain usable |
|
||||
| Organization chart conflicts | Two tabs open the same chart; save one, then save stale second tab; second save reports a conflict and retains local edits rather than overwriting the first |
|
||||
| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded |
|
||||
| Multiple tabs | Progress remains scoped to requesting frame/job; no duplicate UI/listeners from normal navigation |
|
||||
| Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |
|
||||
| Natural session expiry | When encountered, record recovery outcome and temporary-tab cleanup without revealing cookies or forcing sign-out |
|
||||
|
||||
Background interruption does not currently resume in-flight jobs. Do not mistake
|
||||
that known limitation for a new regression or claim a mocked restart proves native
|
||||
worker suspension. Any differences must be compared with the pre-refactor behavior.
|
||||
|
||||
## Execution Record
|
||||
|
||||
2026-09-17 preflight:
|
||||
|
||||
| Target | Discovery | Native test result |
|
||||
| --- | --- | --- |
|
||||
| Chrome | Executable version 153.0.8010.37; automation not connected | Blocked pending connection |
|
||||
| Edge | Executable version 153.0.4234.32; automation not connected | Blocked pending connection |
|
||||
| Firefox | No executable found at the checked standard Program Files path; installation not confirmed | Not run |
|
||||
|
||||
The running-process diagnostic was denied by the environment, so browser running
|
||||
state was not established. Connected targets were Brave and the in-app browser;
|
||||
neither was substituted for this native matrix. The user chose to connect
|
||||
Chrome/Edge. All five local suites and the output consistency check passed again.
|
||||
No real Fusion/SPA calls, runtime changes, release or commit were made in preflight.
|
||||
|
||||
For each completed run, append date, browser/version, package path, checklist
|
||||
outcomes, sanitized errors and remaining gaps. Never mark unexecuted checks passed.
|
||||
184
docs/refactoring.md
Normal file
184
docs/refactoring.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Incremental Refactoring Record
|
||||
|
||||
## Stage 1: Characterization Baseline
|
||||
|
||||
Stage 1 initially added tests and documentation only, before any runtime extraction.
|
||||
The following records that historical baseline. The completed integration and
|
||||
remaining verification gaps are recorded under "Completed Extraction Stages" below.
|
||||
|
||||
### Coverage Added
|
||||
|
||||
`tests/characterization.test.cjs` contains 22 test cases using Node's built-in
|
||||
test runner, assert and vm. `tests/support/characterization.cjs` provides isolated
|
||||
fixtures: no real browser cookies, account identifiers, tokens or network access.
|
||||
Unexpected fetches reject rather than falling through to global fetch.
|
||||
|
||||
- Exact current manifests and source/output equality, with classic-script syntax validation.
|
||||
- Disallowed-origin initialization and reinjection when the tile already exists.
|
||||
- Fiscal boundaries, leap years, rolling quarters and unsupported RENEWALS.
|
||||
- Full opportunity query payload, pagination offsets/counts and stale request versions.
|
||||
- Filter storage round-trip, WON_PENDING, search, numeric sort, reference reset and malformed preferences.
|
||||
- Missing-cookie tokenrelay recovery and existing 400/401/403 retry limit.
|
||||
- Chrome callback and Firefox promise mocks for background dispatch, storage, cookies and tabs.
|
||||
- Product pagination, default/custom TTL, concurrency limit, in-flight sharing and partial errors.
|
||||
- Persisted product reads after recreating the background context; storage failures and stalled pages.
|
||||
- XSRF discovery, recovery timeout, successful recovery and temporary-tab cleanup.
|
||||
- SPA credential selection, account payloads, encoded identifiers and debug envelopes.
|
||||
- Invalid messages, network errors, HTTP 401 and HTTP 200 invalid JSON in consumption.
|
||||
|
||||
Existing suites remain intact: consumption normalization/requests, consumer-session
|
||||
recovery, AI summary rendering/state, scoped token reuse/expiry and bounded retry.
|
||||
Tests for the current security defects are deliberately not encoded as acceptable
|
||||
behavior. They belong to separately approved fixes.
|
||||
|
||||
### Former Temporary Test Seam
|
||||
|
||||
The original content IIFE had no exports, so baseline tests initially exposed
|
||||
functions before initialization with a guarded source marker. That temporary
|
||||
mechanism is now removed. Tests use the actual application factories through
|
||||
`tests/support/application.cjs`, without replacing source strings. Background
|
||||
integration tests still execute the production listener and asynchronous replies.
|
||||
|
||||
### Running Checks
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npm run check
|
||||
```
|
||||
|
||||
Without npm, use the Node commands in package.json individually. The new suite is:
|
||||
|
||||
```sh
|
||||
node tests/characterization.test.cjs
|
||||
```
|
||||
|
||||
At baseline, `build.mjs --check` did not compare output contents. It now checks
|
||||
both generated manifests and both scripts byte-for-byte against composition.
|
||||
Characterization tests retain the exact manifest expectations.
|
||||
|
||||
Stage-1 verification on Node.js v24.19.0:
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `node --check src/content.js` and `node --check src/background.js` | Passed |
|
||||
| `node --check tests/support/characterization.cjs` | Passed |
|
||||
| `node tests/consumptionTrend.test.cjs` | Passed |
|
||||
| `node tests/consumerTokenSessionRetry.test.cjs` | Passed |
|
||||
| `node tests/aiSummary.test.cjs` | Passed |
|
||||
| `node tests/characterization.test.cjs` | 22 passed, 0 failed |
|
||||
| `node scripts/build.mjs --check` | Passed; distribution equivalence also checked by the suite |
|
||||
| `git diff --check` | Passed |
|
||||
|
||||
The individual commands from the npm scripts were executed directly. No build
|
||||
regeneration was needed because runtime sources and the build were unchanged;
|
||||
`git diff -- src dist scripts/build.mjs` was empty.
|
||||
|
||||
### Verification Limits
|
||||
|
||||
- API mocks are not proof of Chrome/Edge/Firefox compatibility or real authentication.
|
||||
- Recreating a VM tests cache rehydration, not actual service-worker suspension or job recovery.
|
||||
- DOM mocks do not prove layout, focus, accessibility or SPA lifecycle correctness.
|
||||
- The original visual fixture used eval. The completed extraction removed it and
|
||||
serves the composed factory through a normal local script instead.
|
||||
- No installed-browser smoke test or live Fusion request was performed in stage 1.
|
||||
- No JavaScript export/generator feature was located. Do not create a generators
|
||||
directory until its actual source and standalone-output contracts are identified.
|
||||
|
||||
## Separate Security and Reliability Backlog
|
||||
|
||||
These are preexisting observations, not authorized functional changes in this refactor.
|
||||
|
||||
| Issue | Evidence/impact | Separate change to evaluate |
|
||||
| --- | --- | --- |
|
||||
| Debug redaction after truncation | Invalid/truncated JSON is returned unmasked by formatDebugBody; reproduced with a synthetic token | Redact before truncation, fail closed on malformed bodies, test all response shapes |
|
||||
| Debug secrets and shared DOM | Raw headers/bodies are retained for the reveal control; revealed text is in page DOM | Review retention, safe diagnostic schema and whether revealing credentials remains acceptable |
|
||||
| Cookie store selection | Lookup scans all stores instead of binding to sender account/container | Scope selection to the requesting context and test multi-account behavior |
|
||||
| Product cache isolation | Cache and in-flight keys use OptyNumber only | Design identity partitioning and explicit cache migration/retention policy |
|
||||
| Privileged message validation | No explicit sender identity/origin check; supplied header names are accepted | Validate senders/messages and allowlist headers without introducing an arbitrary proxy |
|
||||
| Session tab retry | Opening failures retry indefinitely; interruption can strand work | Decide cancellation/limits and worker lifecycle recovery explicitly |
|
||||
| DOM lifecycle | Observer can remain active; guards do not cover every reinjection/resize path | Define ownership/disposal and characterize navigation before changing behavior |
|
||||
| Token expiry | General/SPA tokens have policies different from Sales Common | Review independently; do not unify policies as part of extraction |
|
||||
|
||||
Never include real secrets in repro cases, snapshots, documentation or test output.
|
||||
Do not add persistent token storage to compensate for worker termination.
|
||||
|
||||
## Completed Extraction Stages
|
||||
|
||||
All modules listed below are integrated through `scripts/sources.cjs` and the
|
||||
two composition roots. Former `src/content.js` and `src/background.js` were removed;
|
||||
their names remain only for the generated distribution scripts. No version change,
|
||||
commit, publication, runtime dependency, permission expansion or CSP change occurred.
|
||||
|
||||
| Stage/responsibility | Files/boundaries | Preserved contracts and evidence | Remaining risk/next safe step |
|
||||
| --- | --- | --- | --- |
|
||||
| Build and pure logic | `scripts/compose.cjs`, `sources.cjs`; fiscal-periods, formatters, consumption model | Classic IIFEs, exact manifests, dates, payload/normalization tests; syntax checked per source and composed bundle | Native browser package loading still needs a smoke test |
|
||||
| Platform and dispatch | `platform/`, `background/main.js`, `messages.js`, `shared/messages.js` | Sync listener registration, callback/promise mocks, async responses, sender-frame progress and unknown-message behavior | Sender validation is a separate security change |
|
||||
| Auth and HTTP operations | `auth/`, `services/` | Credential selection, scoped-token reuse/expiry, 401/403 retry, missing-cookie and HTML-session recovery, request/debug envelopes | Real SSO and worker suspension not simulated completely |
|
||||
| Products | `features/products/` | Four-worker queue, pagination, TTL, cache restore, in-flight sharing, partial errors and expansion behavior | Existing identity/cache-retention policy unchanged |
|
||||
| Content features | opportunities, preferences, AI-summary, consumption and debug factories/state | Filter/search storage round-trip, sorting, stale requests, AI safe text, plan/period controls, preserved labels and DOM | Existing DOM lifecycle and narrow-screen limitations remain |
|
||||
| Bootstrap, CSS and documentation | `content/main.js`, `background/main.js`, application roots, styles; README/docs | CSS byte-identical to original (hash test); factories connected, no orphan sources or peer-factory dependencies; old marker/eval fixture removed | Native browser matrix below remains pending |
|
||||
|
||||
The automated suites were run after the initial extraction and again after the
|
||||
platform/auth refinements, cleanup and final composition. Intermediate verification
|
||||
found and corrected test-harness expectations for source inventory and fixture
|
||||
callback messaging; no production auth, cookie or session policy was changed.
|
||||
|
||||
### Final Automated Verification
|
||||
|
||||
Executed with Node.js 24.19.0, directly through the package-script equivalents:
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| `node scripts/build.mjs` | Both packages regenerated; all source/composed JavaScript syntax valid |
|
||||
| `node scripts/test.cjs` | All five suite files passed: AI, architecture (6 cases), characterization (22 cases), session recovery, consumption |
|
||||
| `node scripts/build.mjs --check` | Generated scripts/manifests match source composition |
|
||||
| `git diff --check` | No whitespace errors |
|
||||
|
||||
There is no configured linter. Syntax validation uses Node's JavaScript parser
|
||||
through vm.Script, not eval or new Function in the extension. No generators feature
|
||||
was located; standalone JavaScript generation therefore has no feature-specific
|
||||
snapshot to assert. The architectural suite verifies deterministic composition,
|
||||
source coverage, dependency injection, independent per-frame state and lazy
|
||||
localStorage failure handling. Feature suites use synthetic credentials only.
|
||||
|
||||
### UI Smoke Verification
|
||||
|
||||
The loopback fixture was exercised in the Codex in-app browser, using the same
|
||||
composed content factories with injected synthetic runtime/fetch/storage adapters.
|
||||
It is not an installed extension and does not validate privileged browser APIs.
|
||||
|
||||
- Paginated synthetic opportunities, product progress/counts and dashboard rendered.
|
||||
- Search text/filter remained after closing and reopening the modal.
|
||||
- Light/dark toggle and product expansion retained the existing presentation.
|
||||
- AI text, including literal HTML delimiters, appeared as text above the product table.
|
||||
- Preferences retained checkbox/cache-duration selections after Save and reopen.
|
||||
- Cloud Consumption opened with plan tabs; weekly default, daily switch, alternate
|
||||
plan and series visibility toggles worked. Debug listed scoped relay and AI calls.
|
||||
- Screenshots inspected at 1920x1080, 1280x720 and 390x844. Desktop columns rendered
|
||||
with complete Last Update Date and WON_PENDING. Existing compact-table scrolling
|
||||
and clipping at 1280 remain unchanged; mobile controls stack. This is not a full
|
||||
mobile workflow/accessibility audit and no layout redesign was included.
|
||||
- No console errors were reported during the exercised flows.
|
||||
|
||||
### Outstanding Native Browser Matrix
|
||||
|
||||
| Target | Package/runtime | Status |
|
||||
| --- | --- | --- |
|
||||
| Chrome | `dist/chromium`, MV3 service worker | Not run as an installed extension |
|
||||
| Edge | `dist/chromium`, MV3 service worker | Not run as an installed extension |
|
||||
| Firefox | `dist/firefox`, MV3 background scripts | Not run as an installed extension |
|
||||
|
||||
The available automation connection exposed the in-app browser and a Brave
|
||||
profile, not Chrome, Edge or Firefox. No live Fusion requests, user-session access
|
||||
or extension installation were used to substitute for the missing native tests.
|
||||
Actual suspension/reactivation, container routing and SSO remain unverified.
|
||||
|
||||
The next safe validation is to load the generated packages in an authorized test
|
||||
profile in each target browser, then exercise tile insertion, modal reopen,
|
||||
filters/preferences, products, AI, consumption, debug and background restart.
|
||||
Resolve the separately listed security issues only in explicitly approved changes.
|
||||
|
||||
See [the native browser checklist and execution record](browser-smoke.md) for the
|
||||
2026-09-17 follow-up. Local tests and output checks passed again; Chrome/Edge were
|
||||
installed but not connected to automation. Native verification remains pending
|
||||
while the user connects those browsers.
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "opportunities-extension",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"check": "node --check src/content.js && node scripts/build.mjs --check"
|
||||
"check": "node scripts/build.mjs --check && node scripts/test.cjs",
|
||||
"test": "node scripts/test.cjs",
|
||||
"preview": "node scripts/preview.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import composition from "./compose.cjs";
|
||||
import output from "./write-output.cjs";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const srcDir = path.join(rootDir, "src");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const contentScript = await readFile(path.join(srcDir, "content.js"), "utf8");
|
||||
const backgroundScript = await readFile(path.join(srcDir, "background.js"), "utf8");
|
||||
const contentScript = composition.composeScript("content");
|
||||
const backgroundScript = composition.composeScript("background");
|
||||
const spaPageScript = composition.composeScript("spa-page");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
const baseManifest = {
|
||||
manifest_version: 3,
|
||||
name: "Opportunities Extension",
|
||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
version: "0.1.0",
|
||||
version: "1.0.9",
|
||||
permissions: [
|
||||
"cookies"
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
"declarativeNetRequestWithHostAccess"
|
||||
],
|
||||
host_permissions: [
|
||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||
"https://spa.oracle.com/*",
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
content_scripts: [
|
||||
{
|
||||
@@ -29,6 +36,12 @@ const baseManifest = {
|
||||
js: ["content.js"],
|
||||
all_frames: true,
|
||||
run_at: "document_idle"
|
||||
},
|
||||
{
|
||||
matches: ["https://spa.oracle.com/*"],
|
||||
js: ["spa-page.js"],
|
||||
all_frames: false,
|
||||
run_at: "document_end"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -59,23 +72,25 @@ const targets = [
|
||||
}
|
||||
];
|
||||
|
||||
if (!checkOnly) {
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
|
||||
JSON.parse(manifestJson);
|
||||
|
||||
if (checkOnly) {
|
||||
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript };
|
||||
for (const [file, contents] of Object.entries(expected)) {
|
||||
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
|
||||
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputDir = path.join(distDir, target.name);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await writeFile(path.join(outputDir, "manifest.json"), manifestJson);
|
||||
await writeFile(path.join(outputDir, "content.js"), contentScript);
|
||||
await writeFile(path.join(outputDir, "background.js"), backgroundScript);
|
||||
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
||||
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
|
||||
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||
}
|
||||
|
||||
if (!checkOnly) {
|
||||
|
||||
39
scripts/compose.cjs
Normal file
39
scripts/compose.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const sources = require("./sources.cjs");
|
||||
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js", "src/vendor/d3-hierarchy/d3-hierarchy.min.js"], background: [] };
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
|
||||
/** Compose classic scripts at build time; never load or evaluate code at runtime. */
|
||||
function composeScript(target, { bootstrap = true } = {}) {
|
||||
const files = sources[target];
|
||||
if (!files) throw new Error(`Unknown script target: ${target}`);
|
||||
if (new Set(files).size !== files.length) throw new Error(`Duplicate source in ${target}`);
|
||||
const parts = files.map(file => {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
new vm.Script(source, { filename: file });
|
||||
return `// Source: ${file}\n${source}`;
|
||||
});
|
||||
if (target === "content") {
|
||||
const hierarchySource = fs.readFileSync(path.join(root, vendorSources.content[1]), "utf8");
|
||||
const hierarchyLicense = fs.readFileSync(path.join(root, "src/vendor/d3-hierarchy/LICENSE"), "utf8");
|
||||
const iconLicense = fs.readFileSync(path.join(root, "src/vendor/lucide/LICENSE"), "utf8");
|
||||
parts.unshift(`/* ${iconLicense} */\nlet organizationLayout;\nfunction loadOrganizationLayout() {\nif (!organizationLayout) { const exports = {}; const module = { exports };\n/* ${hierarchyLicense} */\n${hierarchySource}\norganizationLayout = exports; }\nreturn organizationLayout;\n}`);
|
||||
const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
|
||||
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
|
||||
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||
}
|
||||
const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
|
||||
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
|
||||
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
|
||||
new vm.Script(output, { filename: `${target}.js` });
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = { composeScript, sources, vendorSources };
|
||||
46
scripts/preview.mjs
Normal file
46
scripts/preview.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import http from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import composition from "./compose.cjs";
|
||||
|
||||
// Development-only routes. Never expose the workspace or forward API requests.
|
||||
const routes = new Map([
|
||||
["/", ["../tests/fixtures/extension.html", "text/html"]],
|
||||
["/extension.js", ["../tests/fixtures/extension.js", "text/javascript"]],
|
||||
["/consumption-trend.html", ["../tests/fixtures/consumption-trend.html", "text/html"]]
|
||||
]);
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (pathname === "/org-storage-fixture.js") {
|
||||
const files = ["../src/features/org-chart/model.js", "../src/features/org-chart/storage.js"];
|
||||
const parts = [];
|
||||
for (const file of files) parts.push(await readFile(new URL(file, import.meta.url), "utf8"));
|
||||
response.setHeader("Content-Type", "text/javascript");
|
||||
response.end(parts.join("\n") + "\nwindow.fixtureOrgStorage = createOrgChartStorage({ environment: { indexedDB }, model: createOrgChartModel() });");
|
||||
return;
|
||||
}
|
||||
if (pathname === "/fixture-runtime.js") {
|
||||
response.setHeader("Content-Type", "text/javascript");
|
||||
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);
|
||||
return;
|
||||
}
|
||||
const route = routes.get(pathname);
|
||||
if (!route) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", `${route[1]}; charset=utf-8`);
|
||||
response.end(await readFile(new URL(route[0], import.meta.url)));
|
||||
} catch {
|
||||
response.writeHead(500).end("Fixture could not be loaded.");
|
||||
}
|
||||
});
|
||||
server.listen(Number(process.env.PORT) || 0, "127.0.0.1", () => {
|
||||
console.log(`Synthetic extension preview: http://127.0.0.1:${server.address().port}/`);
|
||||
});
|
||||
130
scripts/sources.cjs
Normal file
130
scripts/sources.cjs
Normal file
@@ -0,0 +1,130 @@
|
||||
module.exports = {
|
||||
"spa-page": ["src/auth/spa-page.js"],
|
||||
"content": [
|
||||
"src/features/org-chart/model.js",
|
||||
"src/features/org-chart/icons.js",
|
||||
"src/features/org-chart/calling-codes.js",
|
||||
"src/features/org-chart/view.js",
|
||||
"src/services/fusion/account-contact.js",
|
||||
"src/features/preferences/table-sorting.js",
|
||||
"src/shared/time-dates.js",
|
||||
"src/features/manage-time/view.js",
|
||||
"src/shared/messages.js",
|
||||
"src/shared/debug-body.js",
|
||||
"src/content/contracts.js",
|
||||
"src/auth/session-state.js",
|
||||
"src/features/opportunities/query-state.js",
|
||||
"src/features/opportunities/table-state.js",
|
||||
"src/features/opportunities/filter-state.js",
|
||||
"src/features/preferences/state.js",
|
||||
"src/features/preferences/request-cache.js",
|
||||
"src/features/products/content-state.js",
|
||||
"src/features/consumption/plans-state.js",
|
||||
"src/features/consumption/trend-state.js",
|
||||
"src/features/opportunities/dashboard-state.js",
|
||||
"src/features/debug/state.js",
|
||||
"src/features/opportunities/columns.js",
|
||||
"src/features/opportunities/print.js",
|
||||
"src/features/opportunities/badge-content.js",
|
||||
"src/content/fusion-page.js",
|
||||
"src/content/modal.js",
|
||||
"src/content/theme.js",
|
||||
"src/content/icons.js",
|
||||
"src/content/svg.js",
|
||||
"src/content/styles.js",
|
||||
"src/auth/content-session.js",
|
||||
"src/auth/current-user.js",
|
||||
"src/auth/token-relay.js",
|
||||
"src/auth/xsrf-content.js",
|
||||
"src/platform/content-messages.js",
|
||||
"src/features/opportunities/controller.js",
|
||||
"src/services/fusion/opportunities.js",
|
||||
"src/services/fusion/crm-service-requests.js",
|
||||
"src/features/opportunities/crm-service-requests.js",
|
||||
"src/features/opportunities/product-activities.js",
|
||||
"src/features/opportunities/refresh-all.js",
|
||||
"src/features/opportunities/activity-cache.js",
|
||||
"src/features/opportunities/activity-progress.js",
|
||||
"src/features/opportunities/activity-filters.js",
|
||||
"src/features/opportunities/crm-sr-popover.js",
|
||||
"src/features/opportunities/crm-sr-form.js",
|
||||
"src/features/opportunities/internal-sr-form.js",
|
||||
"src/features/opportunities/searchable-select.js",
|
||||
"src/features/opportunities/internal-sr-session.js",
|
||||
"src/features/opportunities/sr-session-preparation.js",
|
||||
"src/features/opportunities/workload-actions.js",
|
||||
"src/features/opportunities/workload-action-form.js",
|
||||
"src/features/opportunities/workload-action-options.js",
|
||||
"src/features/opportunities/fiscal-periods.js",
|
||||
"src/features/opportunities/table.js",
|
||||
"src/features/opportunities/calendar.js",
|
||||
"src/features/opportunities/dashboard.js",
|
||||
"src/features/opportunities/filters.js",
|
||||
"src/features/opportunities/formatters.js",
|
||||
"src/features/opportunities/links.js",
|
||||
"src/features/preferences/storage.js",
|
||||
"src/features/preferences/view.js",
|
||||
"src/features/products/content-jobs.js",
|
||||
"src/features/products/workloads.js",
|
||||
"src/features/products/details.js",
|
||||
"src/features/products/ramp-projection.js",
|
||||
"src/features/products/forecast-update.js",
|
||||
"src/features/products/ramp-projection-view.js",
|
||||
"src/features/consumption/plans-action.js",
|
||||
"src/features/install-base/action.js",
|
||||
"src/features/install-base/view.js",
|
||||
"src/features/opportunities/customer-table-search.js",
|
||||
"src/features/service-requests/action.js",
|
||||
"src/features/service-requests/view.js",
|
||||
"src/features/consumption/plans-view.js",
|
||||
"src/features/consumption/header-view.js",
|
||||
"src/features/ratecard/model.js",
|
||||
"src/features/ratecard/controller.js",
|
||||
"src/features/ratecard/view.js",
|
||||
"src/features/consumption/plan-detail-controller.js",
|
||||
"src/features/consumption/plan-detail-view.js",
|
||||
"src/features/consumption/trend-controller.js",
|
||||
"src/features/consumption/chart.js",
|
||||
"src/features/consumption/model.js",
|
||||
"src/features/debug/content-log.js",
|
||||
"src/features/debug/content-view.js",
|
||||
"src/content/main.js",
|
||||
"src/content/application.js"
|
||||
],
|
||||
"background": [
|
||||
"src/features/org-chart/model.js",
|
||||
"src/features/org-chart/storage.js",
|
||||
"src/shared/time-dates.js",
|
||||
"src/services/fusion/time-entries.js",
|
||||
"src/shared/messages.js",
|
||||
"src/shared/debug-body.js",
|
||||
"src/background/contracts.js",
|
||||
"src/features/products/cache-state.js",
|
||||
"src/auth/consumer-session.js",
|
||||
"src/auth/spa-credentials.js",
|
||||
"src/auth/sr-request-headers.js",
|
||||
"src/services/spa/accounts.js",
|
||||
"src/services/spa/install-base.js",
|
||||
"src/services/spa/customer-service-requests.js",
|
||||
"src/services/spa/workload-actions.js",
|
||||
"src/services/spa/workloads.js",
|
||||
"src/features/products/workloads-cache.js",
|
||||
"src/services/spa/action-options.js",
|
||||
"src/services/fusion/sr-details.js",
|
||||
"src/services/fusion/sr-create.js",
|
||||
"src/services/spa/current-user.js",
|
||||
"src/services/spa/page-client.js",
|
||||
"src/services/spa/consumption.js",
|
||||
"src/services/spa/response-shapes.js",
|
||||
"src/services/http/background-json.js",
|
||||
"src/features/products/background-jobs.js",
|
||||
"src/features/products/cache.js",
|
||||
"src/services/fusion/products.js",
|
||||
"src/auth/xsrf-recovery.js",
|
||||
"src/auth/xsrf-cookies.js",
|
||||
"src/platform/background-api.js",
|
||||
"src/background/main.js",
|
||||
"src/background/messages.js",
|
||||
"src/background/application.js"
|
||||
]
|
||||
};
|
||||
13
scripts/test.cjs
Normal file
13
scripts/test.cjs
Normal file
@@ -0,0 +1,13 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const files = fs.readdirSync(path.join(root, "tests")).filter(name => name.endsWith(".test.cjs")).sort();
|
||||
let failed = false;
|
||||
for (const file of files) {
|
||||
const result = spawnSync(process.execPath, [path.join(root, "tests", file)], { cwd: root, stdio: "inherit" });
|
||||
if (result.error) console.error(result.error.message);
|
||||
if (result.status !== 0) failed = true;
|
||||
}
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
273
scripts/verify-org-chart.cjs
Normal file
273
scripts/verify-org-chart.cjs
Normal file
@@ -0,0 +1,273 @@
|
||||
// Synthetic browser verification; install Playwright separately or provide NODE_PATH.
|
||||
// No Oracle requests, credentials or real customer data are used.
|
||||
const { chromium } = require("playwright");
|
||||
const { spawn } = require("node:child_process");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
(async () => {
|
||||
const server = spawn(process.execPath, ["scripts/preview.mjs"], { cwd: path.resolve(__dirname, ".."), stdio: ["ignore", "pipe", "inherit"] });
|
||||
let browser;
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), "org-chart-verification-"));
|
||||
try {
|
||||
const url = await new Promise((resolve, reject) => {
|
||||
server.once("error", reject); server.once("exit", code => reject(new Error("Preview exited: " + code)));
|
||||
server.stdout.on("data", chunk => { const found = String(chunk).match(/http:\/\/127\.0\.0\.1:\d+\//); if (found) resolve(found[0]); });
|
||||
});
|
||||
browser = await chromium.launch({ headless: true, ...(process.env.TEST_BROWSER_CHANNEL ? { channel: process.env.TEST_BROWSER_CHANNEL } : {}) });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
const errors = []; page.on("pageerror", error => errors.push(error.message));
|
||||
// Any external request is a fixture regression, not a permitted live test.
|
||||
await page.route("**/*", route => new URL(route.request().url()).origin === new URL(url).origin ? route.continue() : route.abort());
|
||||
page.setDefaultTimeout(10000);
|
||||
await page.goto(url);
|
||||
await page.evaluate(() => new Promise((resolve,reject) => {
|
||||
const request=indexedDB.open("opportunities-extension-organizations",1);
|
||||
request.onupgradeneeded=()=>request.result.createObjectStore("charts",{keyPath:"key"});
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onsuccess=()=>{
|
||||
const db=request.result, tx=db.transaction("charts","readwrite");
|
||||
tx.objectStore("charts").put({key:"legacy-migration",schemaVersion:1,revision:3,nodes:[{id:"legacy",parentId:null,name:"Legacy",role:"Old title",emailAddress:"old@example.invalid",notes:"Retain notes",collapsed:false}]});
|
||||
tx.oncomplete=()=>{db.close();resolve();}; tx.onerror=()=>reject(tx.error);
|
||||
};
|
||||
}));
|
||||
const open = () => page.getByRole("button", { name: "View customer organization chart", exact: true }).first().click();
|
||||
const dialog = page.locator(".oe-org-dialog");
|
||||
const button = label => dialog.getByRole("button", { name: label, exact: true });
|
||||
const editor = page.locator(".oe-org-editor");
|
||||
async function addPerson(name, role, notes = "", parent) {
|
||||
await button(parent ? "Add report to " + parent : "Add person").click();
|
||||
const search = editor.getByRole("combobox", { name: "Name", exact: true });
|
||||
await search.fill(name);
|
||||
await page.locator("#oe-org-name-options").getByRole("option").filter({ hasText: name }).click();
|
||||
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).inputValue(), role);
|
||||
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).evaluate(input => input.readOnly), true);
|
||||
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill(notes);
|
||||
await editor.getByRole("button", { name: "Add person", exact: true }).click();
|
||||
}
|
||||
async function save() {
|
||||
await button("Save chart").click();
|
||||
await page.waitForFunction(() => document.querySelector(".oe-org-footer .oe-org-status")?.textContent.startsWith("Saved locally:"));
|
||||
}
|
||||
await open();
|
||||
await dialog.getByRole("heading", { name: "No people yet" }).waitFor();
|
||||
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries || []),[]);
|
||||
await button("Add person").click();
|
||||
const nameSearch=editor.getByRole("combobox",{name:"Name",exact:true});
|
||||
await editor.getByRole("button", { name: "Create Contact", exact: true }).click();
|
||||
const contactDialog = page.locator(".oe-contact-dialog");
|
||||
await contactDialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 3);
|
||||
assert.equal(await contactDialog.locator('#oe-contact-validation').textContent(), "Complete the highlighted required fields with valid values.");
|
||||
await contactDialog.getByRole("textbox", { name: "First Name", exact: true }).fill("Test");
|
||||
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 2);
|
||||
assert.equal(await contactDialog.locator("#oe-contact-workCountry option").count(), 246);
|
||||
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry option").count(), 246);
|
||||
const country = contactDialog.getByRole("combobox", { name: "Work Phone country code", exact: true });
|
||||
await country.fill("Brazil");
|
||||
await contactDialog.getByRole("option", { name: "BR · Brazil (+55)", exact: true }).click();
|
||||
assert.equal(await contactDialog.locator("#oe-contact-workCountry").inputValue(), "BR");
|
||||
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry").inputValue(), "");
|
||||
assert.equal(await contactDialog.locator('input[name="primaryPhone"]').count(), 0);
|
||||
await page.screenshot({path:path.join(output,"create-contact.png")});
|
||||
await contactDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
assert.equal(await editor.getByRole("button", { name: "Create Contact", exact: true }).evaluate(item => item === document.activeElement), true);
|
||||
await nameSearch.fill("A"); await nameSearch.fill("Al"); await nameSearch.fill("Alex");
|
||||
await page.locator("#oe-org-name-options [role=option]").waitFor();
|
||||
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries),["Alex"]);
|
||||
await page.evaluate(()=>{window.fixtureContactCompleted=[]; window.fixtureContactDelay={Alex:1400};});
|
||||
await nameSearch.fill("Alex");
|
||||
await page.waitForFunction(()=>window.fixtureContactQueries.length===2);
|
||||
await nameSearch.fill("Taylor");
|
||||
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Taylor Chen"}).waitFor();
|
||||
await page.waitForFunction(()=>window.fixtureContactCompleted.includes("Alex"));
|
||||
assert.equal(await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Alex Morgan"}).count(),0);
|
||||
await page.evaluate(()=>{window.fixtureContactsFailure=true; window.fixtureContactDelay={};});
|
||||
await nameSearch.fill("Alex");
|
||||
await page.locator("#oe-org-name-options [role=status]").filter({hasText:"failed with status 503"}).waitFor();
|
||||
await page.evaluate(()=>window.fixtureContactsFailure=false);
|
||||
await nameSearch.fill("Jordan");
|
||||
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Jordan Silva"}).waitFor();
|
||||
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
|
||||
await button("Add person").click();
|
||||
await editor.getByRole("combobox", { name: "Name", exact: true }).fill("Not a contact");
|
||||
await editor.getByRole("button", { name: "Add person", exact: true }).click();
|
||||
assert.equal(await page.locator(".oe-org-node").count(), 0);
|
||||
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await addPerson("Alex Morgan", "Chief Technology Officer", "Decision maker for the cloud program.");
|
||||
await addPerson("Taylor Chen", "Infrastructure Director", "Owns the migration roadmap.", "Alex Morgan");
|
||||
await addPerson("Jordan Silva", "Data and Analytics Director", "Coordinates the analytics team.", "Alex Morgan");
|
||||
await addPerson("Sam Rivera", "Cloud Architect", "Technical workshop: November 12.", "Taylor Chen");
|
||||
const queriesBeforeOpportunities = await page.evaluate(() => window.fixtureContactQueries.length);
|
||||
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 400; });
|
||||
await button("View opportunities for Alex Morgan").click();
|
||||
const contactOpportunities = page.locator(".oe-contact-opportunities");
|
||||
await contactOpportunities.locator('table[aria-busy="true"]').waitFor();
|
||||
assert.equal(await contactOpportunities.locator("tbody .oe-contact-skeleton").count(),35);
|
||||
await contactOpportunities.getByText("6 opportunities", {exact:true}).waitFor();
|
||||
assert.equal(await contactOpportunities.locator(".oe-contact-skeleton").count(),0);
|
||||
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 0; });
|
||||
assert.equal(await page.evaluate(() => window.fixtureContactQueries.length), queriesBeforeOpportunities, "Opportunity lookup must reuse the person's PartyNumber without searching contacts");
|
||||
assert.equal(await contactOpportunities.locator("tbody tr").count(), 6);
|
||||
const presentation = await contactOpportunities.locator("table").evaluate(table => ({
|
||||
marker: table.getAttribute("data-table-layout"),
|
||||
textHeader: getComputedStyle(table.querySelector("th")).textAlign,
|
||||
moneyHeader: getComputedStyle(table.querySelector('th[data-align="money"]')).textAlign,
|
||||
moneyCell: getComputedStyle(table.querySelector('td[data-align="money"]')).textAlign,
|
||||
nowrap: getComputedStyle(table.querySelector(".opportunities-extension-sort-label")).whiteSpace
|
||||
}));
|
||||
assert.deepEqual(presentation,{marker:"standard",textHeader:"left",moneyHeader:"right",moneyCell:"right",nowrap:"nowrap"});
|
||||
await page.screenshot({path:path.join(output,"shared-table.png")});
|
||||
assert.equal(await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("target"), "_blank");
|
||||
assert.ok((await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("href")).endsWith("?puid=CONTACT0"));
|
||||
assert.deepEqual((await page.evaluate(()=>window.fixtureOpportunitiesByContact)).map(url=>new URL(url).searchParams.get("offset")), ["0","5"]);
|
||||
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber",exact:true}).click();
|
||||
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber, ascending",exact:true}).click();
|
||||
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
|
||||
assert.equal(new URL((await page.evaluate(()=>window.fixtureOpportunitiesByContact))[0]).searchParams.get("q"), "(OpportunityContact.ContactPartyNumber='18000')");
|
||||
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
|
||||
await button("View opportunities for Alex Morgan").click();
|
||||
await contactOpportunities.getByText("6 opportunities",{exact:true}).waitFor();
|
||||
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
|
||||
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
|
||||
await button("Fit chart to view").click();
|
||||
assert.equal(await page.locator(".oe-org-node").count(), 4);
|
||||
await save();
|
||||
await button("Edit Alex Morgan").hover();
|
||||
assert.equal(await page.locator(".oe-org-tooltip").innerText(), "Decision maker for the cloud program.");
|
||||
assert.equal(await dialog.getByRole("button", { name: /inline/i }).count(), 0);
|
||||
await button("Edit Alex Morgan").click();
|
||||
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact0@example.invalid");
|
||||
assert.equal(await editor.getByRole("combobox", { name: "Name", exact: true }).inputValue(), "Alex Morgan");
|
||||
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Updated contact notes");
|
||||
await button("Apply changes").click();
|
||||
await page.evaluate(() => {
|
||||
window.fixtureOriginalOrgSave = window.fixtureOrgStorage.save;
|
||||
window.fixtureOrgStorage.save = async () => { throw new Error("Synthetic storage failure"); };
|
||||
});
|
||||
await button("Save chart").click();
|
||||
await page.locator(".oe-org-status.oe-org-error").filter({ hasText: "Synthetic storage failure" }).waitFor();
|
||||
assert.equal(await button("Save chart").isEnabled(), true);
|
||||
await page.evaluate(() => { window.fixtureOrgStorage.save = window.fixtureOriginalOrgSave; });
|
||||
await button("Collapse reports for Taylor Chen").click();
|
||||
assert.equal(await page.locator(".oe-org-node").count(), 3);
|
||||
await save();
|
||||
await page.reload(); await open();
|
||||
await button("Expand reports for Taylor Chen").waitFor();
|
||||
assert.equal(await page.locator(".oe-org-node").count(), 3);
|
||||
await button("Edit Alex Morgan").click();
|
||||
await page.waitForFunction(() => document.querySelector("#oe-org-role").value === "Chief Technology Officer");
|
||||
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
|
||||
await button("Expand reports for Taylor Chen").click();
|
||||
await button("Edit Taylor Chen").click();
|
||||
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact1@example.invalid");
|
||||
assert.deepEqual(await editor.locator("#oe-org-parent option").allTextContents(), ["No manager (top level)", "Alex Morgan - Chief Technology Officer", "Jordan Silva - Data and Analytics Director"]);
|
||||
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Unapplied note");
|
||||
await button("Close organization chart").click();
|
||||
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
|
||||
assert.equal(await editor.getByRole("textbox", { name: "Notes", exact: true }).inputValue(), "Unapplied note");
|
||||
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
// Reparent through the searchable select, then restore the original manager.
|
||||
await button("Edit Sam Rivera").click();
|
||||
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Jordan Silva - Data and Analytics Director", exact: true }).click();
|
||||
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
|
||||
await button("Edit Sam Rivera").click();
|
||||
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Taylor Chen - Infrastructure Director", exact: true }).click();
|
||||
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
|
||||
await button("Fit chart to view").click();
|
||||
await save();
|
||||
const boxes = await page.locator(".oe-org-node").evaluateAll(nodes => nodes.map(node => { const r = node.getBoundingClientRect(); return { x:r.x,y:r.y,w:r.width,h:r.height }; }));
|
||||
for (let a=0;a<boxes.length;a++) for(let b=a+1;b<boxes.length;b++) {
|
||||
const p=boxes[a],q=boxes[b]; assert.ok(p.x+p.w<=q.x || q.x+q.w<=p.x || p.y+p.h<=q.y || q.y+q.h<=p.y, "Cards must not overlap");
|
||||
}
|
||||
await page.mouse.move(5,5);
|
||||
await page.screenshot({ path: path.join(output, "light.png") });
|
||||
await page.locator(".opportunities-extension-modal").first().evaluate(node => node.dataset.theme="dark");
|
||||
await button("Edit Jordan Silva").click();
|
||||
await page.keyboard.press("Control+d");
|
||||
const debug = page.locator(".opportunities-extension-debug-panel");
|
||||
await debug.getByRole("button", { name: "Close debug", exact: true }).waitFor();
|
||||
assert.equal(await debug.evaluate(node => !node.closest("[inert]") && node.parentElement.classList.contains("opportunities-extension-modal")), true);
|
||||
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
|
||||
await debug.getByRole("button", { name: "Close debug", exact: true }).click();
|
||||
assert.equal(await editor.isVisible(), true);
|
||||
await editor.getByRole("button", { name: "Delete person", exact: true }).click();
|
||||
await page.keyboard.press("Control+d");
|
||||
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await page.getByRole("alertdialog").isVisible(), true);
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
|
||||
await page.screenshot({ path: path.join(output, "dark-editor.png") });
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.screenshot({ path: path.join(output, "mobile-editor.png") });
|
||||
const bounds = await dialog.boundingBox(); assert.ok(bounds.x>=0 && bounds.x+bounds.width<=390 && bounds.y+bounds.height<=844);
|
||||
await page.setViewportSize({width:1440,height:960});
|
||||
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await button("Delete Taylor Chen").click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
|
||||
assert.equal(await page.locator(".oe-org-node").count(), 4);
|
||||
await button("Delete Taylor Chen").click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Delete person", exact: true }).click();
|
||||
await save();
|
||||
const storageResult = await page.evaluate(async () => {
|
||||
const identity={principal:"example@example.invalid",customerId:"100000810210835"};
|
||||
const store=window.fixtureOrgStorage, chart=(await store.load(identity)).chart;
|
||||
const sam=chart.nodes.find(n=>n.name==="Sam Rivera"), alex=chart.nodes.find(n=>n.name==="Alex Morgan");
|
||||
let conflict=false; try { await store.save({...identity,nodes:[],revision:0}); } catch { conflict=true; }
|
||||
return {conflict,promoted:sam.parentId===alex.id,notes:sam.notes,contactPartyId:sam.contactPartyId,
|
||||
independentUser:(await store.load({...identity,principal:"different@example.invalid"})).chart.nodes.length,
|
||||
independentCustomer:(await store.load({...identity,customerId:"another-customer"})).chart.nodes.length,
|
||||
afterConflict:(await store.load(identity)).chart.nodes.length};
|
||||
});
|
||||
assert.deepEqual(storageResult,{conflict:true,promoted:true,notes:"Technical workshop: November 12.",contactPartyId:"2003",independentUser:0,independentCustomer:0,afterConflict:3});
|
||||
const rawRecords = await page.evaluate(() => new Promise((resolve,reject) => {
|
||||
const request=indexedDB.open("opportunities-extension-organizations",3);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onsuccess=()=>{
|
||||
const db=request.result, tx=db.transaction("charts","readonly"), all=tx.objectStore("charts").getAll();
|
||||
all.onsuccess=()=>{resolve(all.result);db.close();}; all.onerror=()=>reject(all.error);
|
||||
};
|
||||
}));
|
||||
assert.ok(rawRecords.every(record=>record.nodes.every(node=>!("role" in node)&&!("emailAddress" in node)&&typeof node.jobTitle === "string")));
|
||||
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].notes,"Retain notes");
|
||||
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").revision,3);
|
||||
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].jobTitle,"Old title");
|
||||
await button("Close organization chart").click();
|
||||
await page.getByRole("button", { name: "View customer organization chart", exact: true }).nth(1).click();
|
||||
await button("Edit Sam Rivera").waitFor(); // same customer, different opportunity
|
||||
const queriesBefore=await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length);
|
||||
await button("Edit Sam Rivera").click();
|
||||
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
|
||||
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
|
||||
await button("Edit Sam Rivera").click();
|
||||
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
|
||||
assert.equal(await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length),queriesBefore+2);
|
||||
assert.equal(await button("Save chart").isDisabled(),true);
|
||||
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
|
||||
await page.evaluate(()=>{
|
||||
window.fixtureContactOverrides={"2003":{PartyName:"Samuel Rivera",JobTitle:"Principal Architect"}};
|
||||
window.fixtureContactDelay={"Sam Rivera":250};
|
||||
});
|
||||
await button("Edit Sam Rivera").click();
|
||||
await editor.getByRole("textbox",{name:"Notes",exact:true}).fill("Pending note preserved");
|
||||
await page.waitForFunction(()=>document.querySelector("#oe-org-role").value==="Principal Architect");
|
||||
assert.equal(await editor.getByRole("combobox",{name:"Name",exact:true}).inputValue(),"Samuel Rivera");
|
||||
assert.equal(await editor.getByRole("textbox",{name:"Notes",exact:true}).inputValue(),"Pending note preserved");
|
||||
assert.equal(await button("Edit Samuel Rivera").count(),1);
|
||||
await save();
|
||||
const updated=await page.evaluate(async()=>{
|
||||
const chart=(await window.fixtureOrgStorage.load({principal:"example@example.invalid",customerId:"100000810210835"})).chart;
|
||||
return chart.nodes.find(node=>node.contactPartyId==="2003");
|
||||
});
|
||||
assert.equal(updated.name,"Samuel Rivera");assert.equal(updated.jobTitle,"Principal Architect");
|
||||
assert.equal(updated.notes,"Pending note preserved");assert.equal("emailAddress" in updated,false);
|
||||
await button("Close organization chart").click();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS: contact autocomplete, chart creation, reparenting, editing, notes, collapse, reload, failed-save recovery, unsaved protection, deletion, account/user isolation and revision conflict.");
|
||||
console.log("Screenshots: " + output);
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
})().catch(error => { console.error(error); process.exitCode=1; });
|
||||
20
scripts/write-output.cjs
Normal file
20
scripts/write-output.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
async function writeOutput(file, contents, io = fs) {
|
||||
try {
|
||||
if (await io.readFile(file, "utf8") === contents) return;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
// Replace a complete file; never remove or truncate a script the browser may load.
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await io.writeFile(temporary, contents, { encoding: "utf8", flag: "wx" });
|
||||
await io.rename(temporary, file);
|
||||
} finally {
|
||||
await io.rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { writeOutput };
|
||||
417
src/auth/consumer-session.js
Normal file
417
src/auth/consumer-session.js
Normal file
@@ -0,0 +1,417 @@
|
||||
function createAuthConsumerSession({
|
||||
CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
||||
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
|
||||
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
|
||||
CUSTOMER_TOKEN_SERVICE_URL,
|
||||
CUSTOMER_TOKEN_SESSION_URL,
|
||||
extractTokenServiceAuthorization,
|
||||
fetchJson,
|
||||
supportsFirefoxCookieStoreTabs,
|
||||
tabsCreate,
|
||||
tabsUpdate,
|
||||
tabsRemove,
|
||||
responseHeaders,
|
||||
cookieChanges,
|
||||
inspectPage,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
wait
|
||||
}) {
|
||||
const sessionTabs = new Map();
|
||||
async function requestConsumerTokenService(sender) {
|
||||
const debugRequests = [];
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests);
|
||||
const accountSummaryAuthorization = extractTokenServiceAuthorization(
|
||||
tokenResponse,
|
||||
"ACCP-TS",
|
||||
"account-ti-authorization"
|
||||
);
|
||||
const accountPlansAuthorization = extractTokenServiceAuthorization(
|
||||
tokenResponse,
|
||||
"OCICONS-TS",
|
||||
"g2m-authorization"
|
||||
);
|
||||
const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1];
|
||||
|
||||
if (tokenRequestDebugEntry) {
|
||||
tokenRequestDebugEntry.metadata = {
|
||||
...tokenRequestDebugEntry.metadata,
|
||||
accountSummaryTokenService: "ACCP-TS",
|
||||
accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)",
|
||||
accountSummaryAuthorizationValue: accountSummaryAuthorization.value
|
||||
? `(present, ${accountSummaryAuthorization.value.length} characters)`
|
||||
: "(missing)",
|
||||
accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)",
|
||||
accountPlansTokenService: "OCICONS-TS",
|
||||
accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)",
|
||||
accountPlansAuthorizationValue: accountPlansAuthorization.value
|
||||
? `(present, ${accountPlansAuthorization.value.length} characters)`
|
||||
: "(missing)",
|
||||
accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)"
|
||||
};
|
||||
}
|
||||
|
||||
if (!accountSummaryAuthorization.value) {
|
||||
throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
||||
}
|
||||
|
||||
if (!accountPlansAuthorization.value) {
|
||||
throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authorizations: {
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
|
||||
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) {
|
||||
let attempt = 0;
|
||||
let sessionTab = null;
|
||||
let receivedValidJson = false;
|
||||
|
||||
try {
|
||||
while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
attempt += 1;
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Cache-Control": "no-cache, no-store, max-age=0",
|
||||
Pragma: "no-cache"
|
||||
}
|
||||
}, "requestConsumerTokenService", debugRequests);
|
||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.attempt = String(attempt);
|
||||
}
|
||||
|
||||
receivedValidJson = true;
|
||||
return tokenResponse;
|
||||
} catch (error) {
|
||||
const isHtmlFallback = error
|
||||
&& error.code === "INVALID_JSON_RESPONSE"
|
||||
&& error.httpStatus === 200;
|
||||
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||
const retryReason = isSessionUnauthorized
|
||||
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||
: "HTTP 200 response did not contain valid JSON";
|
||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||
|
||||
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.attempt = String(attempt);
|
||||
if (needsSessionRecovery) {
|
||||
debugEntry.metadata.retryLimitReached = "true";
|
||||
debugEntry.metadata.retryReason = retryReason;
|
||||
debugEntry.error = isHtmlFallback
|
||||
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||
error.message = debugEntry.error;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!sessionTab) {
|
||||
try {
|
||||
sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry);
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
||||
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
|
||||
}
|
||||
} catch (sessionError) {
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
||||
debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
|
||||
}
|
||||
}
|
||||
} else if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session";
|
||||
}
|
||||
|
||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.attempt = String(attempt);
|
||||
debugEntry.metadata.retryReason = retryReason;
|
||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||
}
|
||||
|
||||
if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
|
||||
else await wait(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("requestConsumerTokenService retry loop ended unexpectedly.");
|
||||
} finally {
|
||||
if (sessionTab && Number.isInteger(sessionTab.id)) {
|
||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||
|
||||
try {
|
||||
await closeConsumerTokenSessionTab(sessionTab.id);
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
||||
? "closed-after-valid-json"
|
||||
: "closed-after-retry-finished";
|
||||
}
|
||||
} catch (error) {
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getConsumerTokenRetryDelay(attempt) {
|
||||
return Math.min(
|
||||
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)),
|
||||
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) {
|
||||
let attempt = 0;
|
||||
|
||||
// Opening failures must not consume the token service's five attempts.
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
||||
debugEntry.metadata.sessionTabOpenAttempt = String(attempt);
|
||||
}
|
||||
|
||||
try {
|
||||
const tab = await openConsumerTokenSessionTab(sender);
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
|
||||
delete debugEntry.metadata.sessionTabNextRetryDelayMs;
|
||||
}
|
||||
return tab;
|
||||
} catch (error) {
|
||||
const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5));
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapState = "retrying-tab-open";
|
||||
debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error";
|
||||
debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs);
|
||||
}
|
||||
await wait(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openConsumerTokenSessionTab(sender) {
|
||||
const details = {
|
||||
url: CUSTOMER_TOKEN_SESSION_URL,
|
||||
active: false
|
||||
};
|
||||
|
||||
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
|
||||
details.windowId = sender.tab.windowId;
|
||||
}
|
||||
|
||||
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
|
||||
details.cookieStoreId = sender.tab.cookieStoreId;
|
||||
}
|
||||
|
||||
const tab = await tabsCreate(details);
|
||||
if (Number.isInteger(tab?.id)) {
|
||||
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
|
||||
const signal = reason => {
|
||||
state.readyVersion++;
|
||||
state.readyReason = reason;
|
||||
state.wake?.();
|
||||
};
|
||||
state.listener = details => {
|
||||
if (details.tabId !== tab.id
|
||||
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
|
||||
if (details.type === "main_frame") {
|
||||
if (details.statusCode === 401 || details.statusCode === 403) {
|
||||
state.failedStatus = details.statusCode;
|
||||
state.consumedVersion = state.readyVersion;
|
||||
state.wake?.();
|
||||
} else if (details.statusCode >= 200 && details.statusCode < 300) {
|
||||
state.failedStatus = 0;
|
||||
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
|
||||
}
|
||||
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
|
||||
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
|
||||
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
|
||||
state.failedStatus = 0;
|
||||
signal("spa-identity-json-response");
|
||||
}
|
||||
};
|
||||
state.cookieListener = change => {
|
||||
const cookie = change.cookie;
|
||||
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|
||||
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|
||||
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|
||||
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
|
||||
signal("spa-session-cookie-changed");
|
||||
};
|
||||
try {
|
||||
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
|
||||
cookieChanges?.addListener(state.cookieListener);
|
||||
sessionTabs.set(tab.id, state);
|
||||
} catch (error) {
|
||||
responseHeaders?.removeListener?.(state.listener);
|
||||
cookieChanges?.removeListener(state.cookieListener);
|
||||
await tabsRemove(tab.id).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
|
||||
const state = sessionTabs.get(tab.id);
|
||||
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
|
||||
const status = state.failedStatus;
|
||||
state.failedStatus = 0;
|
||||
state.consumedVersion = state.readyVersion;
|
||||
state.lastReloadAt = Date.now();
|
||||
state.reloads++;
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
|
||||
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
|
||||
}
|
||||
try {
|
||||
// Restart at the entry point; OAuth callback URLs may contain single-use state.
|
||||
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
|
||||
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
|
||||
return true;
|
||||
} catch (error) {
|
||||
state.failedStatus = status;
|
||||
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSpaPage(tab, debugEntry) {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + 120000;
|
||||
let restartedDocument = null;
|
||||
let restarts = 0;
|
||||
if (debugEntry) {
|
||||
delete debugEntry.metadata.nextRetryDelayMs;
|
||||
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
|
||||
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
|
||||
}
|
||||
while (Date.now() < deadline) {
|
||||
const state = sessionTabs.get(tab.id);
|
||||
if (state?.closed) break;
|
||||
let page;
|
||||
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
|
||||
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
|
||||
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
|
||||
const alreadyRead = documentId && documentId === tab.spaDocumentId;
|
||||
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
|
||||
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
|
||||
const previousReloads = state?.reloads || 0;
|
||||
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
|
||||
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
|
||||
restarts += (state?.reloads || 0) - previousReloads;
|
||||
if (restarted) restartedDocument = documentId;
|
||||
}
|
||||
} else if (page?.status === "ready" && documentId) {
|
||||
// Confirm the parsed application document without waiting for visual assets.
|
||||
await wait(100);
|
||||
let confirmed;
|
||||
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
|
||||
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
|
||||
tab.spaDocumentId = documentId;
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
|
||||
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await wait(1000);
|
||||
}
|
||||
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
|
||||
error.code = "SPA_PAGE_NOT_READY";
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
|
||||
const state = sessionTabs.get(tab.id);
|
||||
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + 20000;
|
||||
if (debugEntry) {
|
||||
delete debugEntry.metadata.nextRetryDelayMs;
|
||||
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
|
||||
}
|
||||
let restarts = 0;
|
||||
let reason = "timeout-fallback";
|
||||
// Do not issue API probes while SSO redirects are still establishing the session.
|
||||
while (!state.closed && Date.now() < deadline) {
|
||||
if (state.failedStatus) {
|
||||
if (restarts >= 3) {
|
||||
await wait(Math.max(0, deadline - Date.now()));
|
||||
break;
|
||||
}
|
||||
await recoverConsumerTokenSessionTab(tab, debugEntry);
|
||||
restarts++;
|
||||
if (state.failedStatus) await wait(2000);
|
||||
continue;
|
||||
}
|
||||
if (state.readyVersion > state.consumedVersion) {
|
||||
await wait(250);
|
||||
if (state.failedStatus) continue;
|
||||
state.consumedVersion = state.readyVersion;
|
||||
reason = state.readyReason;
|
||||
break;
|
||||
}
|
||||
let timer;
|
||||
try {
|
||||
await new Promise(resolve => {
|
||||
state.wake = resolve;
|
||||
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
|
||||
});
|
||||
} finally {
|
||||
clearTimeout?.(timer);
|
||||
state.wake = null;
|
||||
}
|
||||
}
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.sessionWaitReason = reason;
|
||||
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeConsumerTokenSessionTab(id) {
|
||||
const state = sessionTabs.get(id);
|
||||
sessionTabs.delete(id);
|
||||
if (state) {
|
||||
state.closed = true;
|
||||
state.wake?.();
|
||||
responseHeaders?.removeListener?.(state.listener);
|
||||
cookieChanges?.removeListener(state.cookieListener);
|
||||
}
|
||||
await tabsRemove(id);
|
||||
}
|
||||
|
||||
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
|
||||
}
|
||||
59
src/auth/content-session.js
Normal file
59
src/auth/content-session.js
Normal file
@@ -0,0 +1,59 @@
|
||||
function createAuthContentSession({
|
||||
messageTypes,
|
||||
AUTH_STATUS,
|
||||
appendBackgroundRequestLog,
|
||||
authSessionState,
|
||||
requestTokenRelay,
|
||||
sendRuntimeMessage,
|
||||
updateAuthBadge
|
||||
}) {
|
||||
function requestTokenRelayOnce() {
|
||||
if (!authSessionState.tokenRelayRequest) {
|
||||
authSessionState.authStatus = AUTH_STATUS.pending;
|
||||
updateAuthBadge(authSessionState.authStatus);
|
||||
authSessionState.tokenRelayRequest = requestTokenRelay()
|
||||
.then((token) => {
|
||||
authSessionState.accessToken = token;
|
||||
authSessionState.authStatus = AUTH_STATUS.authenticated;
|
||||
requestConsumerTokenServiceOnce().catch(() => {});
|
||||
return token;
|
||||
})
|
||||
.catch((error) => {
|
||||
authSessionState.tokenRelayRequest = null;
|
||||
authSessionState.accessToken = "";
|
||||
authSessionState.principal = "";
|
||||
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
updateAuthBadge(authSessionState.authStatus);
|
||||
});
|
||||
} else {
|
||||
updateAuthBadge(authSessionState.authStatus);
|
||||
}
|
||||
|
||||
return authSessionState.tokenRelayRequest;
|
||||
}
|
||||
|
||||
function requestConsumerTokenServiceOnce() {
|
||||
if (!authSessionState.consumerTokenServiceRequest) {
|
||||
authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({
|
||||
type: messageTypes.requestConsumerTokenService
|
||||
}).then((response) => {
|
||||
appendBackgroundRequestLog(response && response.debugRequests);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(response && response.error
|
||||
? response.error
|
||||
: "Unable to load consumer token service.");
|
||||
}
|
||||
|
||||
return response.authorizations;
|
||||
});
|
||||
}
|
||||
|
||||
return authSessionState.consumerTokenServiceRequest;
|
||||
}
|
||||
|
||||
return { requestTokenRelayOnce, requestConsumerTokenServiceOnce };
|
||||
}
|
||||
34
src/auth/current-user.js
Normal file
34
src/auth/current-user.js
Normal file
@@ -0,0 +1,34 @@
|
||||
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
|
||||
const { setTimeout } = environment;
|
||||
let pending = null;
|
||||
let allowed = false;
|
||||
let userId = "";
|
||||
function ensure() {
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
let retryDelay = 1000;
|
||||
// Keep a single pending identity request until the response is usable.
|
||||
for (;;) {
|
||||
try {
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
|
||||
appendBackgroundRequestLog(response?.debugRequests);
|
||||
const user = response?.user;
|
||||
if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
|
||||
&& typeof user.name === "string" && user.name.trim()
|
||||
&& Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
|
||||
userId = user.id;
|
||||
allowed = !user.roles.includes("SALES_REP");
|
||||
return allowed;
|
||||
}
|
||||
} catch (error) {
|
||||
appendBackgroundRequestLog(error?.debugRequests);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
retryDelay = Math.min(retryDelay * 2, 10000);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
|
||||
}
|
||||
9
src/auth/session-state.js
Normal file
9
src/auth/session-state.js
Normal file
@@ -0,0 +1,9 @@
|
||||
function createAuthSessionState({ AUTH_STATUS } = {}) {
|
||||
return {
|
||||
tokenRelayRequest: null,
|
||||
consumerTokenServiceRequest: null,
|
||||
accessToken: "",
|
||||
principal: "",
|
||||
authStatus: AUTH_STATUS.idle
|
||||
};
|
||||
}
|
||||
40
src/auth/spa-credentials.js
Normal file
40
src/auth/spa-credentials.js
Normal file
@@ -0,0 +1,40 @@
|
||||
function createAuthSpaCredentials({
|
||||
findNestedObject
|
||||
}) {
|
||||
function normalizeMessageAuthorization(authorization, fallbackHeaderName) {
|
||||
const headerName = authorization && typeof authorization.name === "string"
|
||||
? authorization.name.trim()
|
||||
: "";
|
||||
const headerValue = authorization && typeof authorization.value === "string"
|
||||
? authorization.value.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
name: headerName || fallbackHeaderName,
|
||||
value: headerValue
|
||||
};
|
||||
}
|
||||
|
||||
function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) {
|
||||
const tokenService = Array.isArray(response)
|
||||
? response.find((entry) => entry && entry.tokenService === tokenServiceName)
|
||||
: findNestedObject(response, (entry) => entry.tokenService === tokenServiceName);
|
||||
const authorizationHeader = tokenService && tokenService.uiaasHeader;
|
||||
const headerName = authorizationHeader && typeof authorizationHeader.label === "string"
|
||||
? authorizationHeader.label.trim()
|
||||
: "";
|
||||
const headerValue = authorizationHeader && typeof authorizationHeader.value === "string"
|
||||
? authorizationHeader.value.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
name: headerName || fallbackHeaderName,
|
||||
value: headerValue,
|
||||
expires: authorizationHeader && authorizationHeader.expires
|
||||
? String(authorizationHeader.expires)
|
||||
: ""
|
||||
};
|
||||
}
|
||||
|
||||
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
|
||||
}
|
||||
52
src/auth/spa-page.js
Normal file
52
src/auth/spa-page.js
Normal file
@@ -0,0 +1,52 @@
|
||||
function createSpaPage() {
|
||||
function start() {
|
||||
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
|
||||
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
|
||||
if (!runtime) return;
|
||||
const documentId = `${performance.timeOrigin}:${location.pathname}`;
|
||||
let identityRequest = null;
|
||||
|
||||
function inspect() {
|
||||
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
|
||||
let status = "loading";
|
||||
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
|
||||
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|
||||
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|
||||
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
|
||||
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
|
||||
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
|
||||
return { status, documentId };
|
||||
}
|
||||
|
||||
runtime.onMessage.addListener((message, sender, reply) => {
|
||||
if (sender.id !== runtime.id) return false;
|
||||
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
|
||||
reply(inspect());
|
||||
return false;
|
||||
}
|
||||
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
|
||||
const page = inspect();
|
||||
if (page.status !== "ready" || page.documentId !== message.documentId) {
|
||||
reply({ error: "SPA page is not ready or has navigated." });
|
||||
return false;
|
||||
}
|
||||
// A document is probed at most once; failed identity reads require a fresh navigation.
|
||||
if (!identityRequest) identityRequest = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
||||
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
|
||||
});
|
||||
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
|
||||
contentType: response.headers.get("content-type") || "", body: await response.text() };
|
||||
} catch (error) {
|
||||
return { error: error.message || "Unable to read SPA identity." };
|
||||
} finally { clearTimeout(timer); }
|
||||
})();
|
||||
identityRequest.then(reply);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return { start };
|
||||
}
|
||||
131
src/auth/sr-request-headers.js
Normal file
131
src/auth/sr-request-headers.js
Normal file
@@ -0,0 +1,131 @@
|
||||
function createSrRequestHeaders({ runtimeApi }) {
|
||||
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||
const appPath = "/ic/builder/rt/SeaaS_initiation/";
|
||||
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
let observed = null;
|
||||
let started = false;
|
||||
function valid(record) {
|
||||
return record?.origin === origin && typeof record.value === "string"
|
||||
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
|
||||
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
|
||||
&& Date.now() - record.observedAt < maxAge;
|
||||
}
|
||||
function observeHeaders(rawUrl, headers) {
|
||||
let url;
|
||||
try { url = new URL(rawUrl); } catch { return; }
|
||||
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
|
||||
// Read only the client ID, not cookies, bodies or authorization.
|
||||
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
||||
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
||||
const record = { origin, value: value?.trim(), observedAt: Date.now() };
|
||||
if (!valid(record)) return;
|
||||
observed = record;
|
||||
// Session storage survives service-worker suspension without persisting across browser sessions.
|
||||
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
|
||||
}
|
||||
function start() {
|
||||
if (started || !runtimeApi?.webRequest) return;
|
||||
started = true;
|
||||
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
|
||||
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
||||
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
||||
}, filter, ["requestHeaders"]);
|
||||
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
|
||||
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
|
||||
}, filter, ["responseHeaders"]);
|
||||
}
|
||||
async function previewTokenHeaders(url) {
|
||||
const target = new URL(url);
|
||||
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
|
||||
throw new Error("Unexpected Service Request token URL.");
|
||||
}
|
||||
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
|
||||
}
|
||||
async function sessionHeaders(targetOrigin, contentType) {
|
||||
if (!valid(observed)) {
|
||||
try {
|
||||
const stored = await runtimeApi.storage?.session?.get(storageKey);
|
||||
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
|
||||
} catch { /* A missing capture must not prevent trying the other required headers. */ }
|
||||
}
|
||||
return {
|
||||
Accept: "application/json", "Content-Type": contentType,
|
||||
Authorization: "Session", Origin: targetOrigin,
|
||||
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
|
||||
};
|
||||
}
|
||||
async function tokenHeaders(url, serviceRequestUrl) {
|
||||
const headers = await previewTokenHeaders(url);
|
||||
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
|
||||
const serviceTarget = new URL(serviceRequestUrl);
|
||||
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
|
||||
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
|
||||
throw new Error("Unexpected Service Request URL.");
|
||||
}
|
||||
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||
throw new Error("Reload the extension to enable the Service Request token header permissions.");
|
||||
}
|
||||
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
|
||||
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
|
||||
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const ruleIds = [7201, 7202];
|
||||
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||
removeRuleIds: ruleIds,
|
||||
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
|
||||
id: ruleIds[index], priority: 1,
|
||||
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
|
||||
condition: { regexFilter, initiatorDomains: [extensionDomain],
|
||||
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
|
||||
}))
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
async function internalServiceRequestHeaders(url) {
|
||||
const target = new URL(url);
|
||||
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
|
||||
throw new Error("Unexpected Internal Service Request URL.");
|
||||
}
|
||||
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
|
||||
}
|
||||
const headers = await sessionHeaders(target.origin, "application/json");
|
||||
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
|
||||
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||
removeRuleIds: [7203],
|
||||
addRules: [{
|
||||
id: 7203, priority: 1,
|
||||
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
|
||||
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
|
||||
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
|
||||
}]
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
async function timeEntriesHeaders(url) {
|
||||
const target = new URL(url);
|
||||
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
|
||||
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
|
||||
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
|
||||
throw new Error("Unexpected time entries URL.");
|
||||
}
|
||||
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||
throw new Error("Reload the extension to enable the time entry header permissions.");
|
||||
}
|
||||
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||
removeRuleIds: [isDelete ? 7205 : 7204],
|
||||
addRules: [{
|
||||
id: isDelete ? 7205 : 7204, priority: 1,
|
||||
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
|
||||
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
|
||||
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
|
||||
}]
|
||||
});
|
||||
return sessionHeaders(target.origin, "application/json");
|
||||
}
|
||||
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
|
||||
}
|
||||
168
src/auth/token-relay.js
Normal file
168
src/auth/token-relay.js
Normal file
@@ -0,0 +1,168 @@
|
||||
function createAuthTokenRelay({
|
||||
TOKEN_RELAY_URL,
|
||||
authSessionState,
|
||||
createDebugBody,
|
||||
getXsrfToken,
|
||||
logRequestFailure,
|
||||
logRequestStart,
|
||||
logRequestSuccess,
|
||||
maskToken,
|
||||
readResponseBodyForDebug,
|
||||
refreshXsrfCookie,
|
||||
renderDebugPanel,
|
||||
environment
|
||||
}) {
|
||||
const { fetch } = environment;
|
||||
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
|
||||
if (url === TOKEN_RELAY_URL) authSessionState.principal = "";
|
||||
let refreshResult = null;
|
||||
let refreshAttempted = false;
|
||||
|
||||
while (true) {
|
||||
const xsrfTokenSource = await getXsrfToken();
|
||||
|
||||
if (!xsrfTokenSource.token) {
|
||||
if (refreshAttempted) {
|
||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
||||
throw new Error("XSRF token cookie not found after automatic refresh.");
|
||||
}
|
||||
|
||||
refreshResult = await refreshXsrfCookie("missing-cookie");
|
||||
refreshAttempted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||
const controller = environment.AbortController ? new environment.AbortController() : null;
|
||||
let timeoutId;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
if (!environment.setTimeout) return;
|
||||
timeoutId = environment.setTimeout(() => {
|
||||
reject(new Error("Token relay timed out after 30 seconds. Please try again."));
|
||||
controller?.abort();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await Promise.race([fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
headers: {
|
||||
"x-xsrf-token": xsrfTokenSource.token
|
||||
}
|
||||
}), timeout]);
|
||||
|
||||
const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
|
||||
|
||||
if (!response.ok) {
|
||||
logRequestSuccess(requestEntry, response, responseBody);
|
||||
|
||||
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
|
||||
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
|
||||
refreshAttempted = true;
|
||||
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
|
||||
renderDebugPanel();
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Token relay failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = await Promise.race([response.json(), timeout]);
|
||||
} catch (error) {
|
||||
if (controller?.signal.aborted) throw error;
|
||||
logRequestSuccess(requestEntry, response, responseBody);
|
||||
|
||||
if (!refreshAttempted) {
|
||||
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
|
||||
refreshAttempted = true;
|
||||
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
|
||||
renderDebugPanel();
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
|
||||
logRequestSuccess(requestEntry, response, responseBody);
|
||||
|
||||
if (!refreshAttempted) {
|
||||
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
|
||||
refreshAttempted = true;
|
||||
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
|
||||
renderDebugPanel();
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error("Token relay response does not contain access_token.");
|
||||
}
|
||||
|
||||
logRequestSuccess(requestEntry, response, responseBody);
|
||||
|
||||
if (url === TOKEN_RELAY_URL) {
|
||||
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
||||
}
|
||||
if (includeExpiry) {
|
||||
const lifetime = Number(responseData.expires_in);
|
||||
return {
|
||||
access_token: responseData.access_token,
|
||||
expiresAt: Number.isFinite(lifetime) && lifetime > 0
|
||||
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
|
||||
};
|
||||
}
|
||||
return responseData.access_token;
|
||||
} catch (error) {
|
||||
logRequestFailure(requestEntry, error.message || "Request failed.");
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
|
||||
return logRequestStart({
|
||||
name,
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
"x-xsrf-token": xsrfTokenSource.token || "(missing)"
|
||||
},
|
||||
metadata: {
|
||||
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
|
||||
xsrfTokenSource: xsrfTokenSource.source,
|
||||
xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length
|
||||
? xsrfTokenSource.matchedCookieNames.join(", ")
|
||||
: "(none)",
|
||||
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
|
||||
? xsrfTokenSource.lookupDetails.join(" | ")
|
||||
: "(none)",
|
||||
xsrfTokenError: xsrfTokenSource.error || "",
|
||||
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isInvalidXsrfStatus(status) {
|
||||
return status === 400 || status === 401 || status === 403;
|
||||
}
|
||||
|
||||
function formatXsrfRefreshResult(result) {
|
||||
if (!result) {
|
||||
return "not-requested";
|
||||
}
|
||||
|
||||
const outcome = result.refreshed ? "refreshed" : "not-refreshed";
|
||||
const cookieName = result.cookieName || "(none)";
|
||||
const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
|
||||
return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
|
||||
}
|
||||
|
||||
return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult };
|
||||
}
|
||||
42
src/auth/xsrf-content.js
Normal file
42
src/auth/xsrf-content.js
Normal file
@@ -0,0 +1,42 @@
|
||||
function createAuthXsrfContent({
|
||||
getXsrfTokenFromExtensionCookies,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
async function getXsrfToken() {
|
||||
const extensionCookie = await getXsrfTokenFromExtensionCookies();
|
||||
|
||||
if (extensionCookie.token) {
|
||||
return extensionCookie;
|
||||
}
|
||||
|
||||
const xsrfCookie = document.cookie
|
||||
.split(";")
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith("XSRF-TOKEN-"));
|
||||
|
||||
if (!xsrfCookie) {
|
||||
return {
|
||||
token: "",
|
||||
cookieName: "",
|
||||
source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback",
|
||||
matchedCookieNames: extensionCookie.matchedCookieNames || [],
|
||||
lookupDetails: extensionCookie.lookupDetails || [],
|
||||
error: extensionCookie.error || ""
|
||||
};
|
||||
}
|
||||
|
||||
const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("="));
|
||||
const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1);
|
||||
return {
|
||||
token: decodeURIComponent(tokenValue),
|
||||
cookieName,
|
||||
source: "document.cookie",
|
||||
matchedCookieNames: extensionCookie.matchedCookieNames || [],
|
||||
lookupDetails: extensionCookie.lookupDetails || [],
|
||||
error: ""
|
||||
};
|
||||
}
|
||||
|
||||
return { getXsrfToken };
|
||||
}
|
||||
@@ -1,46 +1,13 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
|
||||
const ORACLE_COOKIE_URLS = [
|
||||
"https://eeho.fa.us2.oraclecloud.com/",
|
||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
|
||||
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
|
||||
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
|
||||
];
|
||||
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
|
||||
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
|
||||
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||
|
||||
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || message.type !== "opportunitiesExtension.getXsrfToken") {
|
||||
return false;
|
||||
}
|
||||
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
cookieName: result.cookie ? result.cookie.name : "",
|
||||
token: result.cookie ? result.cookie.value : "",
|
||||
matchedCookieNames: result.matchedCookieNames,
|
||||
lookupDetails: result.lookupDetails
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
cookieName: "",
|
||||
token: "",
|
||||
matchedCookieNames: [],
|
||||
lookupDetails: [],
|
||||
error: error.message || "Unable to read cookies."
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function createAuthXsrfCookies({
|
||||
ORACLE_COOKIE_URLS,
|
||||
ORACLE_DOMAIN,
|
||||
XSRF_COOKIE_NAME,
|
||||
XSRF_COOKIE_PREFIX,
|
||||
cookiesGet,
|
||||
cookiesGetAll,
|
||||
cookiesGetAllCookieStores,
|
||||
removeUndefinedValues
|
||||
}) {
|
||||
async function getXsrfTokenCookie() {
|
||||
const lookupDetails = [];
|
||||
const allCookies = [];
|
||||
@@ -145,72 +112,5 @@
|
||||
});
|
||||
}
|
||||
|
||||
function cookiesGetAll(details) {
|
||||
if (runtimeApi.cookies.getAll.length <= 1) {
|
||||
return runtimeApi.cookies.getAll(details);
|
||||
return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
runtimeApi.cookies.getAll(details, (cookies) => {
|
||||
const lastError = runtimeApi.runtime.lastError;
|
||||
|
||||
if (lastError) {
|
||||
reject(new Error(lastError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cookies);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cookiesGet(details) {
|
||||
const cleanDetails = removeUndefinedValues(details);
|
||||
|
||||
if (runtimeApi.cookies.get.length <= 1) {
|
||||
return runtimeApi.cookies.get(cleanDetails);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
runtimeApi.cookies.get(cleanDetails, (cookie) => {
|
||||
const lastError = runtimeApi.runtime.lastError;
|
||||
|
||||
if (lastError) {
|
||||
reject(new Error(lastError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cookie);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cookiesGetAllCookieStores() {
|
||||
if (!runtimeApi.cookies.getAllCookieStores) {
|
||||
return Promise.resolve([{ id: undefined }]);
|
||||
}
|
||||
|
||||
if (runtimeApi.cookies.getAllCookieStores.length === 0) {
|
||||
return runtimeApi.cookies.getAllCookieStores();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
runtimeApi.cookies.getAllCookieStores((stores) => {
|
||||
const lastError = runtimeApi.runtime.lastError;
|
||||
|
||||
if (lastError) {
|
||||
reject(new Error(lastError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(stores);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeUndefinedValues(details) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(details).filter((entry) => entry[1] !== undefined)
|
||||
);
|
||||
}
|
||||
})();
|
||||
84
src/auth/xsrf-recovery.js
Normal file
84
src/auth/xsrf-recovery.js
Normal file
@@ -0,0 +1,84 @@
|
||||
function createAuthXsrfRecovery({
|
||||
COOKIE_REFRESH_POLL_MS,
|
||||
COOKIE_REFRESH_TIMEOUT_MS,
|
||||
OPPORTUNITIES_LIST_URL,
|
||||
getXsrfTokenCookie,
|
||||
supportsFirefoxCookieStoreTabs,
|
||||
tabsCreate,
|
||||
tabsRemove,
|
||||
wait
|
||||
}) {
|
||||
async function refreshXsrfCookie(sender) {
|
||||
const startedAt = Date.now();
|
||||
const previous = (await getXsrfTokenCookie()).cookie;
|
||||
const createProperties = {
|
||||
url: OPPORTUNITIES_LIST_URL,
|
||||
active: false
|
||||
};
|
||||
|
||||
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
|
||||
createProperties.windowId = sender.tab.windowId;
|
||||
}
|
||||
|
||||
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
|
||||
createProperties.cookieStoreId = sender.tab.cookieStoreId;
|
||||
}
|
||||
|
||||
let refreshTab;
|
||||
|
||||
try {
|
||||
refreshTab = await tabsCreate(createProperties);
|
||||
const current = await waitForXsrfCookieChange(previous);
|
||||
const refreshed = hasCookieChanged(previous, current);
|
||||
|
||||
return {
|
||||
ok: Boolean(current),
|
||||
refreshed,
|
||||
cookieName: current ? current.name : "",
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
|
||||
};
|
||||
} finally {
|
||||
if (refreshTab && Number.isInteger(refreshTab.id)) {
|
||||
try {
|
||||
await tabsRemove(refreshTab.id);
|
||||
} catch (error) {
|
||||
// The temporary tab may already have been closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForXsrfCookieChange(previousCookie) {
|
||||
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
|
||||
let currentCookie = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
currentCookie = (await getXsrfTokenCookie()).cookie;
|
||||
|
||||
if (hasCookieChanged(previousCookie, currentCookie)) {
|
||||
return currentCookie;
|
||||
}
|
||||
|
||||
await wait(COOKIE_REFRESH_POLL_MS);
|
||||
}
|
||||
|
||||
return currentCookie;
|
||||
}
|
||||
|
||||
function hasCookieChanged(previousCookie, currentCookie) {
|
||||
if (!currentCookie || !currentCookie.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!previousCookie) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return previousCookie.name !== currentCookie.name
|
||||
|| previousCookie.value !== currentCookie.value
|
||||
|| previousCookie.expirationDate !== currentCookie.expirationDate;
|
||||
}
|
||||
|
||||
return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged };
|
||||
}
|
||||
220
src/background/application.js
Normal file
220
src/background/application.js
Normal file
@@ -0,0 +1,220 @@
|
||||
function createBackgroundApplication(overrides = {}) {
|
||||
const environment = {
|
||||
indexedDB: globalThis.indexedDB,
|
||||
fetch: globalThis.fetch,
|
||||
browser: globalThis.browser,
|
||||
chrome: globalThis.chrome,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
...overrides
|
||||
};
|
||||
const messageTypes = createMessageTypes();
|
||||
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
|
||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||
const contracts = createBackgroundContracts();
|
||||
const spaPageClient = createSpaPageClient({
|
||||
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
|
||||
getHttp: () => httpBackgroundJson
|
||||
});
|
||||
const spaCurrentUser = createSpaCurrentUser({
|
||||
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
|
||||
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
|
||||
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
|
||||
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
|
||||
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
|
||||
});
|
||||
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
|
||||
const productsCacheState = createProductsCacheState();
|
||||
const authConsumerSession = createAuthConsumerSession({
|
||||
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
||||
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
|
||||
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
|
||||
CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL,
|
||||
CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL,
|
||||
extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
||||
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
|
||||
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
|
||||
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
|
||||
inspectPage: tab => spaPageClient.inspect(tab),
|
||||
setTimeout: environment.setTimeout,
|
||||
clearTimeout: environment.clearTimeout,
|
||||
wait: (...args) => platformBackgroundApi.wait(...args)
|
||||
});
|
||||
const authSpaCredentials = createAuthSpaCredentials({
|
||||
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args)
|
||||
});
|
||||
const spaAccounts = createSpaAccounts({
|
||||
ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD,
|
||||
ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL,
|
||||
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||
extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaInstallBase = createSpaInstallBase({
|
||||
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
|
||||
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaConsumption = createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaResponseShapes = createSpaResponseShapes();
|
||||
const httpBackgroundJson = createHttpBackgroundJson({
|
||||
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
|
||||
previewDebugBody: createDebugBodyPreview,
|
||||
environment
|
||||
});
|
||||
const productsBackgroundJobs = createProductsBackgroundJobs({
|
||||
messageTypes,
|
||||
OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY,
|
||||
getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args),
|
||||
normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args),
|
||||
tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args)
|
||||
});
|
||||
const productsCache = createProductsCache({
|
||||
OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY,
|
||||
fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args),
|
||||
productsCacheState,
|
||||
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||
});
|
||||
const fusionProducts = createFusionProducts({
|
||||
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
|
||||
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
|
||||
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
|
||||
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
|
||||
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
||||
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
||||
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
|
||||
environment
|
||||
});
|
||||
const authXsrfRecovery = createAuthXsrfRecovery({
|
||||
COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS,
|
||||
COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS,
|
||||
OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL,
|
||||
getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args),
|
||||
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
||||
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||
wait: (...args) => platformBackgroundApi.wait(...args)
|
||||
});
|
||||
const authXsrfCookies = createAuthXsrfCookies({
|
||||
ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS,
|
||||
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
|
||||
XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME,
|
||||
XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX,
|
||||
cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args),
|
||||
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
|
||||
cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args),
|
||||
removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args)
|
||||
});
|
||||
const platformBackgroundApi = createPlatformBackgroundApi({
|
||||
runtimeApi: environment.browser || environment.chrome,
|
||||
environment
|
||||
});
|
||||
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const workloadsCache = createWorkloadsCache({
|
||||
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
|
||||
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||
});
|
||||
const spaWorkloads = createSpaWorkloads({
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
getCachedWorkloads: (...args) => workloadsCache.get(...args),
|
||||
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
|
||||
});
|
||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
|
||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||
const fusionSrCreate = createFusionSrCreate({
|
||||
runtimeApi,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
|
||||
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
|
||||
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
|
||||
});
|
||||
const fusionSrDetails = createFusionSrDetails({
|
||||
cookieChanges: runtimeApi?.cookies?.onChanged,
|
||||
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
|
||||
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
|
||||
wait: (...args) => platformBackgroundApi.wait(...args),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
|
||||
});
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
loadOrganizationChart: message => orgChartStorage.load(message),
|
||||
saveOrganizationChart: message => orgChartStorage.save(message),
|
||||
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
|
||||
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
|
||||
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
|
||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
|
||||
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
|
||||
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
|
||||
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
|
||||
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
productsCacheState,
|
||||
authConsumerSession,
|
||||
authSpaCredentials,
|
||||
spaAccounts,
|
||||
spaInstallBase,
|
||||
spaCustomerServiceRequests,
|
||||
spaConsumption,
|
||||
spaResponseShapes,
|
||||
httpBackgroundJson,
|
||||
productsBackgroundJobs,
|
||||
productsCache,
|
||||
workloadsCache,
|
||||
spaWorkloads,
|
||||
fusionProducts,
|
||||
authXsrfRecovery,
|
||||
authXsrfCookies,
|
||||
platformBackgroundApi
|
||||
};
|
||||
}
|
||||
69
src/background/contracts.js
Normal file
69
src/background/contracts.js
Normal file
@@ -0,0 +1,69 @@
|
||||
function createBackgroundContracts() {
|
||||
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
|
||||
const ORACLE_COOKIE_URLS = [
|
||||
"https://eeho.fa.us2.oraclecloud.com/",
|
||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
|
||||
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
|
||||
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
|
||||
];
|
||||
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
|
||||
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
|
||||
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
|
||||
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
|
||||
const COOKIE_REFRESH_POLL_MS = 250;
|
||||
const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
|
||||
const OPPTY_PRODUCTS_PAGE_LIMIT = 15;
|
||||
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
||||
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
||||
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
|
||||
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/";
|
||||
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
|
||||
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
|
||||
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
|
||||
const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary";
|
||||
const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans";
|
||||
const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
|
||||
const CONSUMPTION_TREND_REQUESTS = {
|
||||
quarterly: "requestConsumptionServiceQuarterly",
|
||||
monthly: "requestConsumptionServiceMonthly",
|
||||
weekly: "requestConsumptionServiceWeekly",
|
||||
daily: "requestConsumptionServiceDaily"
|
||||
};
|
||||
const ACCOUNT_PLANS_PAYLOAD = {
|
||||
hierarchy: "single",
|
||||
products: "all",
|
||||
language: "english",
|
||||
planStatus: [
|
||||
"active",
|
||||
"awaiting_provisioning",
|
||||
"awaiting_provisioning_error",
|
||||
"draft",
|
||||
"expired",
|
||||
"final_billing",
|
||||
"onhold",
|
||||
"provisioned",
|
||||
"signed",
|
||||
"superseded",
|
||||
"superseded_pending_final_billing",
|
||||
"suspended",
|
||||
"terminated",
|
||||
"terminated_pending_final_billing"
|
||||
]
|
||||
};
|
||||
const DEBUG_BODY_LIMIT = 100000;
|
||||
const OPPTY_PRODUCTS_FIELDS = [
|
||||
"ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag",
|
||||
"Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode",
|
||||
"EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId",
|
||||
"OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual",
|
||||
"RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId",
|
||||
"RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount",
|
||||
"UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c",
|
||||
"CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c",
|
||||
"ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c",
|
||||
"CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c",
|
||||
"PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c",
|
||||
"WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c"
|
||||
];
|
||||
return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS };
|
||||
}
|
||||
3
src/background/main.js
Normal file
3
src/background/main.js
Normal file
@@ -0,0 +1,3 @@
|
||||
function startBackgroundApplication(dependencies) {
|
||||
registerBackgroundMessages(dependencies);
|
||||
}
|
||||
275
src/background/messages.js
Normal file
275
src/background/messages.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
loadOrganizationChart,
|
||||
saveOrganizationChart,
|
||||
messageTypes,
|
||||
requestWeekDetails,
|
||||
requestTimeEntries,
|
||||
requestTimeEntriesSave,
|
||||
requestTimeEntriesDelete,
|
||||
requestLookupTaskType,
|
||||
requestServiceRequestExpand,
|
||||
requestResourceUser,
|
||||
requestActiveServiceRequests,
|
||||
invalidateProductsCache,
|
||||
invalidateResourceUser = async () => {},
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestWorkloads,
|
||||
requestWorkloadForecastUpdate,
|
||||
invalidateWorkloadsCache,
|
||||
requestActionTeamMembers,
|
||||
requestActionFindLookup,
|
||||
requestMgmtAction,
|
||||
requestPostServiceRequest,
|
||||
requestPostInternalServiceRequest,
|
||||
requestInternalServiceRequest,
|
||||
requestSrLookupPrimaryPillar,
|
||||
requestSrLookupWithSession,
|
||||
prepareSrSession,
|
||||
requestSrLookupLanguage,
|
||||
requestSrLookupTechPlatform,
|
||||
requestLookupTypePlatform,
|
||||
requestInternalSrServices,
|
||||
requestSrLookupOpportunityBusiness,
|
||||
requestCurrentUser,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestConsumptionPlanDetail,
|
||||
requestRatecardByPlan,
|
||||
requestOpptyProducts,
|
||||
runtimeApi
|
||||
}) {
|
||||
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestLookupTaskType) {
|
||||
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestTimeEntriesSave) {
|
||||
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestTimeEntriesDelete) {
|
||||
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
||||
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestPostServiceRequest) {
|
||||
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.prepareSrSession) {
|
||||
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
const srLookups = {
|
||||
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
|
||||
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
|
||||
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
|
||||
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
|
||||
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
|
||||
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
|
||||
};
|
||||
if (Object.hasOwn(srLookups, message.type)) {
|
||||
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
cookieName: result.cookie ? result.cookie.name : "",
|
||||
token: result.cookie ? result.cookie.value : "",
|
||||
matchedCookieNames: result.matchedCookieNames,
|
||||
lookupDetails: result.lookupDetails
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
cookieName: "",
|
||||
token: "",
|
||||
matchedCookieNames: [],
|
||||
lookupDetails: [],
|
||||
error: error.message || "Unable to read cookies."
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.refreshXsrfCookie) {
|
||||
refreshXsrfCookie(sender)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
refreshed: false,
|
||||
cookieName: "",
|
||||
durationMs: 0,
|
||||
error: error.message || "Unable to refresh XSRF cookie."
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestOpptyProducts) {
|
||||
requestOpptyProducts(message, sender)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
requestId: message.requestId || "",
|
||||
error: error.message || "Unable to load opportunity products."
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestConsumerTokenService) {
|
||||
requestConsumerTokenService(sender)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error.message || "Unable to load consumer token service.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestCurrentUser) {
|
||||
requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load current user.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||
request(message, sender).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
|
||||
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
|
||||
request(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestWlActions) {
|
||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workload actions.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestCustomerServiceRequest) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestInstallBase) {
|
||||
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load installed base.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestAccountPlans) {
|
||||
requestAccountPlans(message)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error.message || "Unable to load account plans.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestRatecardByPlan) {
|
||||
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load rate card.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestConsumptionPlanDetail) {
|
||||
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load consumption plan details.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestConsumptionTrend) {
|
||||
requestConsumptionTrend(message)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error.message || "Unable to load consumption trend.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
3708
src/content.js
3708
src/content.js
File diff suppressed because it is too large
Load Diff
901
src/content/application.js
Normal file
901
src/content/application.js
Normal file
@@ -0,0 +1,901 @@
|
||||
function createContentApplication(overrides = {}) {
|
||||
const environment = {
|
||||
document: globalThis.document,
|
||||
window: globalThis.window,
|
||||
navigator: globalThis.navigator,
|
||||
crypto: globalThis.crypto,
|
||||
get localStorage() { return globalThis.localStorage; },
|
||||
fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
|
||||
browser: globalThis.browser,
|
||||
chrome: globalThis.chrome,
|
||||
MutationObserver: globalThis.MutationObserver,
|
||||
Node: globalThis.Node,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
performance: globalThis.performance,
|
||||
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||
setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
|
||||
clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
|
||||
AbortController: globalThis.AbortController,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
...overrides
|
||||
};
|
||||
const messageTypes = createMessageTypes();
|
||||
const currentUser = createCurrentUser({
|
||||
environment,
|
||||
messageTypes,
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
|
||||
});
|
||||
const contracts = createContentContracts();
|
||||
const searchableSelect = createSearchableSelect({ environment });
|
||||
const authSessionState = createAuthSessionState(contracts);
|
||||
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||
const tableSorting = createTableSorting({ environment });
|
||||
const orgChartIcons = createOrgChartIcons({ environment });
|
||||
const accountContact = createFusionAccountContact({ environment,
|
||||
getToken: () => authContentSession.requestTokenRelayOnce(),
|
||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args)
|
||||
});
|
||||
const orgChart = createOrgChartView({ environment, tableSorting, model: createOrgChartModel(),
|
||||
requestQueryAccountContact: (...args) => accountContact.requestQueryAccountContact(...args),
|
||||
requestPersistContact: (...args) => accountContact.requestPersistContact(...args),
|
||||
requestOpportunitiesByContact: (...args) => accountContact.requestOpportunitiesByContact(...args),
|
||||
loadLayout: loadOrganizationLayout, icon: orgChartIcons.icon, searchableSelect, modalId: contracts.MODAL_ID,
|
||||
getPrincipal: () => currentUser.getUserId(), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
async request(operation, data) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ ...data,
|
||||
type: operation === "load" ? messageTypes.loadOrganizationChart : messageTypes.saveOrganizationChart });
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to access local organization charts.");
|
||||
return result.chart;
|
||||
}
|
||||
});
|
||||
const opportunitiesTableState = createOpportunitiesTableState();
|
||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||
const preferencesState = createPreferencesState(contracts);
|
||||
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||
const productsContentState = createProductsContentState();
|
||||
const consumptionPlansState = createConsumptionPlansState();
|
||||
const consumptionTrendState = createConsumptionTrendState();
|
||||
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
|
||||
const debugState = createDebugState(environment);
|
||||
const columns = createOpportunitiesColumns({
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
|
||||
formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args),
|
||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||
getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args)
|
||||
});
|
||||
const contentFusionPage = createContentFusionPage({
|
||||
ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR,
|
||||
ALLOWED_PATHS: contracts.ALLOWED_PATHS,
|
||||
SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR,
|
||||
TILE_ID: contracts.TILE_ID,
|
||||
TILE_LABEL_ID: contracts.TILE_LABEL_ID,
|
||||
appendPath: (...args) => svgPaths.appendPath(...args),
|
||||
openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args),
|
||||
environment
|
||||
});
|
||||
const contentModal = createContentModal({
|
||||
createManageTimeButton: () => manageTime.createButton(),
|
||||
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
|
||||
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
|
||||
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
|
||||
ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY,
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
||||
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
|
||||
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
|
||||
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
|
||||
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
|
||||
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
|
||||
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
|
||||
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
|
||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
|
||||
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
|
||||
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
|
||||
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
|
||||
TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID,
|
||||
applyTheme: (...args) => contentTheme.applyTheme(...args),
|
||||
authSessionState,
|
||||
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
||||
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
||||
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
|
||||
closeOrganizationChart: () => orgChart.close(),
|
||||
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
|
||||
debugState,
|
||||
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
|
||||
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
|
||||
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
|
||||
getSavedTheme: (...args) => contentTheme.getSavedTheme(...args),
|
||||
openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args),
|
||||
opportunitiesFilterState,
|
||||
opportunitiesQueryState,
|
||||
preferencesState,
|
||||
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
|
||||
renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args),
|
||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
|
||||
renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args),
|
||||
renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args),
|
||||
renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args),
|
||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
||||
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
||||
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
||||
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
||||
toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args),
|
||||
toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args),
|
||||
environment
|
||||
});
|
||||
const contentTheme = createContentTheme({
|
||||
THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY,
|
||||
createThemeIcon: (...args) => contentIcons.createThemeIcon(...args),
|
||||
environment
|
||||
});
|
||||
const contentIcons = createContentIcons({
|
||||
appendPath: (...args) => svgPaths.appendPath(...args),
|
||||
environment
|
||||
});
|
||||
const svgPaths = createSvgPaths({ environment });
|
||||
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, tableSorting, searchableSelect, dates: createTimeDates(),
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
async requestDelete(id) {
|
||||
await srSessionPreparation.ensure();
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to delete time entry.");
|
||||
return result.result;
|
||||
},
|
||||
async requestSave(method, items) {
|
||||
await srSessionPreparation.ensure();
|
||||
await currentUser.ensure();
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to save time entries.");
|
||||
return result.result;
|
||||
},
|
||||
async requestEntries(weekId) {
|
||||
await srSessionPreparation.ensure();
|
||||
await currentUser.ensure();
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load time entries.");
|
||||
return result.items;
|
||||
},
|
||||
async requestTaskTypes() {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
|
||||
return result.items;
|
||||
},
|
||||
async requestExpand(srNumbers) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
|
||||
return result.items;
|
||||
},
|
||||
async requestServices(weekId) {
|
||||
await srSessionPreparation.ensure();
|
||||
await currentUser.ensure();
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Requests.");
|
||||
return result.items;
|
||||
},
|
||||
async requestWeek(dateReference) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
|
||||
return result.items;
|
||||
}
|
||||
});
|
||||
const contentStyles = createContentStyles({
|
||||
STYLE_ID: contracts.STYLE_ID,
|
||||
redwoodStyles,
|
||||
environment
|
||||
});
|
||||
const authContentSession = createAuthContentSession({
|
||||
messageTypes,
|
||||
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
authSessionState,
|
||||
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
|
||||
});
|
||||
const authTokenRelay = createAuthTokenRelay({
|
||||
authSessionState,
|
||||
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||
createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
|
||||
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
|
||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||
maskToken: (...args) => debugContentLog.maskToken(...args),
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||
refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args),
|
||||
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args),
|
||||
environment
|
||||
});
|
||||
const authXsrfContent = createAuthXsrfContent({
|
||||
getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args),
|
||||
environment
|
||||
});
|
||||
const platformContentMessages = createPlatformContentMessages({
|
||||
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
|
||||
messageTypes,
|
||||
environment
|
||||
});
|
||||
const opportunitiesController = createOpportunitiesController({
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||
authSessionState,
|
||||
consumptionPlansState,
|
||||
consumptionTrendState,
|
||||
getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args),
|
||||
opportunitiesDashboardState,
|
||||
opportunitiesFilterState,
|
||||
opportunitiesQueryState,
|
||||
opportunitiesTableState,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
||||
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
||||
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
||||
});
|
||||
const fusionOpportunities = createFusionOpportunities({
|
||||
resetDebugRequests: () => debugState.reset(),
|
||||
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
||||
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||
maskToken: (...args) => debugContentLog.maskToken(...args),
|
||||
getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues,
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||
environment
|
||||
});
|
||||
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
|
||||
const fusionCrmServiceRequests = createFusionCrmServiceRequests({
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
||||
});
|
||||
const internalSrSession = createInternalSrSession({
|
||||
environment,
|
||||
query: async (srId, srNumber) => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
const crmSrPopover = createCrmSrPopover({
|
||||
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||
});
|
||||
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
|
||||
async function requestSrFormLookup(type, fields = {}) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
|
||||
return result.items;
|
||||
}
|
||||
const internalSrForm = createInternalSrForm({
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
requestSave: async payload => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostInternalServiceRequest, payload, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to create Internal Service Request.");
|
||||
return result.result;
|
||||
},
|
||||
getOpportunity: number => number == null ? null : opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||
MODAL_ID: contracts.MODAL_ID, environment, searchableSelect,
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
requestPlatform: () => requestSrFormLookup(messageTypes.requestLookupTypePlatform),
|
||||
requestServices: parentServiceId => requestSrFormLookup(messageTypes.requestInternalSrServices, { parentServiceId, emailAddress: currentUser.getUserId() })
|
||||
});
|
||||
const crmSrForm = createCrmSrForm({
|
||||
requestSave: async fields => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostServiceRequest, ...fields, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to save Service Request.");
|
||||
return result;
|
||||
},
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
|
||||
requestPlatform: () => requestSrFormLookup(messageTypes.requestSrLookupTechPlatform),
|
||||
requestServices: () => requestSrFormLookup(messageTypes.requestSrLookupOpportunityBusiness, { emailAddress: currentUser.getUserId() }),
|
||||
searchableSelect,
|
||||
MODAL_ID: contracts.MODAL_ID, environment,
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
isMissing: number => opportunityCrmServiceRequests.getSummary(number)?.state === "missing",
|
||||
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
|
||||
});
|
||||
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
createSrButton: number => crmSrForm.createButton(number),
|
||||
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
activityCache,
|
||||
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||
closePopover: (...args) => crmSrPopover.close(...args),
|
||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||
});
|
||||
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
|
||||
const state = opportunitiesTableState.opportunitiesTableState;
|
||||
const items = opportunitiesFilters.getSortedOpportunities();
|
||||
const filter = opportunitiesFilterState;
|
||||
const selectedLabels = (options, selected, allLabel) => {
|
||||
const matching = options.filter(option => selected.has(option.key));
|
||||
if (options.length && matching.length === options.length) return allLabel;
|
||||
return matching.map(option => option.label).join(", ") || "None";
|
||||
};
|
||||
const root = environment.document.getElementById(contracts.MODAL_ID);
|
||||
const filters = [
|
||||
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
|
||||
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
|
||||
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
|
||||
["Search", filter.tableSearch || "All"],
|
||||
["Status", [...filter.selectedStatuses].join(", ")],
|
||||
["Stage", [...filter.selectedStages].join(", ")],
|
||||
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
|
||||
];
|
||||
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
|
||||
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
|
||||
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
|
||||
}
|
||||
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
|
||||
for (const stage of contracts.STAGE_OPTIONS) {
|
||||
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
|
||||
const amount = stageItems.reduce((total, item) => {
|
||||
const value = Number(item.PrimaryRevenue?.RevnAmount);
|
||||
return total + (Number.isFinite(value) ? value : 0);
|
||||
}, 0);
|
||||
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
|
||||
}
|
||||
return { status: state.status, filters, totals, columns,
|
||||
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
|
||||
} });
|
||||
const opportunityCalendar = createOpportunityCalendar({
|
||||
createPrintButton: () => opportunitiesPrint.createButton(),
|
||||
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
|
||||
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||
createViewIcon: view => contentIcons.createViewIcon(view),
|
||||
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
|
||||
getState: () => opportunitiesTableState.opportunitiesTableState,
|
||||
getItems: () => opportunitiesFilters.getSortedOpportunities(),
|
||||
getVersion: () => opportunitiesQueryState.periodRequestVersion,
|
||||
getRange: () => opportunitiesQueryState.dateRange,
|
||||
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
|
||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||
createStageBadge: value => opportunitiesLinks.createStageBadge(value),
|
||||
createStatusBadge: value => opportunitiesLinks.createStatusBadge(value),
|
||||
opportunityUrl: contracts.OPPORTUNITY_DETAIL_URL, modalId: contracts.MODAL_ID,
|
||||
closeSrPopover: () => crmSrPopover.close()
|
||||
});
|
||||
const opportunitiesTable = createOpportunitiesTable({
|
||||
tableSorting,
|
||||
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
||||
renderActivityFilters: () => activityFilters.render(),
|
||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
||||
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
|
||||
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
||||
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
||||
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
|
||||
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
|
||||
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
||||
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
|
||||
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
|
||||
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
||||
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
||||
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
|
||||
displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args),
|
||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||
getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args),
|
||||
opportunitiesTableState,
|
||||
productsContentState,
|
||||
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
|
||||
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
|
||||
renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args),
|
||||
resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args),
|
||||
resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args),
|
||||
environment
|
||||
});
|
||||
const opportunitiesDashboard = createOpportunitiesDashboard({
|
||||
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
|
||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||
opportunitiesDashboardState,
|
||||
environment
|
||||
});
|
||||
const opportunitiesFilters = createOpportunitiesFilters({
|
||||
matchesProductType: item => {
|
||||
const selected = opportunitiesFilterState.selectedProductTypes;
|
||||
if (selected.size === 2) return true;
|
||||
const result = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||
if (!result || result.error) return false;
|
||||
return selected.has(productsDetails.isBooking(result.items) ? "Booking" : "Workload");
|
||||
},
|
||||
matchesActivities: item => activityFilters.matches(item),
|
||||
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
|
||||
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
|
||||
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
|
||||
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
|
||||
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
|
||||
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
|
||||
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
|
||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
|
||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||
getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args),
|
||||
getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args),
|
||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||
getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args),
|
||||
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
|
||||
opportunitiesFilterState,
|
||||
opportunitiesTableState,
|
||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||
stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args),
|
||||
environment
|
||||
});
|
||||
const opportunitiesFormatters = createOpportunitiesFormatters({
|
||||
opportunitiesTableState
|
||||
});
|
||||
const opportunitiesLinks = createOpportunitiesLinks({
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
environment
|
||||
});
|
||||
const preferencesStorage = createPreferencesStorage({
|
||||
DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES,
|
||||
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||
FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY,
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
|
||||
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
|
||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||
opportunitiesFilterState,
|
||||
environment
|
||||
});
|
||||
const preferencesView = createPreferencesView({
|
||||
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
|
||||
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
|
||||
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
|
||||
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
|
||||
preferencesState,
|
||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||
environment
|
||||
});
|
||||
const refreshAll = createRefreshAll({
|
||||
createRefreshIcon: () => contentIcons.createRefreshIcon(),
|
||||
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
|
||||
consumptionPlansState, consumptionTrendState, environment,
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
resetRelatedData: () => {
|
||||
opportunityCrmServiceRequests.reset();
|
||||
opportunityWorkloadActions.reset();
|
||||
productWorkloads.reset();
|
||||
rampProjectionView.close(false);
|
||||
consumptionPlansView.closeAccountPlansModal();
|
||||
installBaseView.closeInstallBaseModal();
|
||||
serviceRequestsView.closeServiceRequestsModal();
|
||||
installBaseAction.resetRequests();
|
||||
serviceRequestsAction.resetRequests();
|
||||
ratecardController.resetRequests();
|
||||
consumptionPlanDetailController.resetRequests();
|
||||
productsContentJobs.renderProductsProgress();
|
||||
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
|
||||
}
|
||||
});
|
||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment,
|
||||
retrySession: () => srSessionPreparation.retry() });
|
||||
const srSessionPreparation = createSrSessionPreparation({
|
||||
async onReady() {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
|
||||
},
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
|
||||
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
|
||||
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
|
||||
});
|
||||
const activityFilters = createActivityFilters({
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
|
||||
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
|
||||
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||
environment
|
||||
});
|
||||
const productActivities = createProductActivities({
|
||||
productsContentState, opportunitiesQueryState,
|
||||
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
|
||||
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
||||
blockActions: products => opportunityWorkloadActions.blockProducts(products),
|
||||
render: () => {
|
||||
productsContentJobs.renderProductsProgress();
|
||||
productsContentJobs.queueProductsTableRender();
|
||||
}
|
||||
});
|
||||
const productsContentJobs = createProductsContentJobs({
|
||||
loadWorkloads: (...args) => productWorkloads.load(...args),
|
||||
joinWorkloads: number => productWorkloads.joinProducts(number),
|
||||
beginProductActivities: (...args) => productActivities.begin(...args),
|
||||
completeProductActivities: (...args) => productActivities.complete(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
messageTypes,
|
||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
|
||||
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
environment
|
||||
});
|
||||
const productWorkloads = createProductWorkloads({
|
||||
productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
|
||||
requestConsumerTokenServiceOnce: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
getPrincipal: () => currentUser.getUserId() || authSessionState.principal,
|
||||
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||
});
|
||||
const workloadActionOptions = createWorkloadActionOptions({
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
messageTypes
|
||||
});
|
||||
const workloadActionForm = createWorkloadActionForm({
|
||||
searchableSelect,
|
||||
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
|
||||
saveAction: (...args) => workloadActionOptions.save(...args),
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
getCurrentUserId: () => currentUser.getUserId(),
|
||||
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
|
||||
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
|
||||
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
|
||||
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
|
||||
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||
environment
|
||||
});
|
||||
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
createActionButton: number => workloadActionForm.createButton(number),
|
||||
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||
activityCache,
|
||||
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
authSessionState,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender(),
|
||||
productsContentState, messageTypes, environment
|
||||
});
|
||||
const productsDetails = createProductsDetails({
|
||||
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
|
||||
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
productsContentState,
|
||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||
environment
|
||||
});
|
||||
const rampProjection = createRampProjection();
|
||||
const workloadForecastUpdate = createWorkloadForecastUpdate({
|
||||
compare: product => rampProjection.compare(product),
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
getCurrentUserId: () => currentUser.getUserId(),
|
||||
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
messageTypes,
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
productsContentState,
|
||||
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||
});
|
||||
const rampProjectionView = createRampProjectionView({
|
||||
tableSorting,
|
||||
saveEstimates: product => workloadForecastUpdate.save(product),
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
compare: product => rampProjection.compare(product),
|
||||
expandProducts: number => productsDetails.expandProducts(number),
|
||||
createAlertIcon: () => contentIcons.createAlertIcon(),
|
||||
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||
formatAmount: (...args) => productsDetails.formatProductAmount(...args),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
environment
|
||||
});
|
||||
const customerTableSearch = createCustomerTableSearch({ environment });
|
||||
const installBaseView = createInstallBaseView({
|
||||
tableSorting,
|
||||
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
environment
|
||||
});
|
||||
const installBaseAction = createInstallBaseAction({
|
||||
messageTypes,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
|
||||
environment
|
||||
});
|
||||
const serviceRequestsView = createServiceRequestsView({
|
||||
tableSorting,
|
||||
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
environment
|
||||
});
|
||||
const serviceRequestsAction = createServiceRequestsAction({
|
||||
messageTypes,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
openServiceRequestsModal: (...args) => serviceRequestsView.openServiceRequestsModal(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionPlansAction = createConsumptionPlansAction({
|
||||
messageTypes,
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
consumptionPlansState,
|
||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||
openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args),
|
||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionPlanDetailView = createConsumptionPlanDetailView({
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionPlanDetailController = createConsumptionPlanDetailController({
|
||||
messageTypes,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
|
||||
environment
|
||||
});
|
||||
const ratecardModel = createRatecardModel();
|
||||
const consumptionHeaderView = createConsumptionHeaderView({
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
environment
|
||||
});
|
||||
const ratecardView = createRatecardView({
|
||||
tableSorting,
|
||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||
columns: ratecardModel.columns,
|
||||
formatValue: (...args) => ratecardModel.formatValue(...args),
|
||||
selectItems: (...args) => ratecardModel.selectItems(...args),
|
||||
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
|
||||
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
|
||||
environment
|
||||
});
|
||||
const ratecardController = createRatecardController({
|
||||
messageTypes,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
openRatecardDrawer: (...args) => ratecardView.openRatecardDrawer(...args),
|
||||
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionPlansView = createConsumptionPlansView({
|
||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
|
||||
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
|
||||
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
|
||||
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
|
||||
createRatecardButton: (...args) => ratecardController.createRatecardButton(...args),
|
||||
closeRatecardDrawer: (...args) => ratecardView.closeRatecardDrawer(...args),
|
||||
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
|
||||
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
|
||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionTrendController = createConsumptionTrendController({
|
||||
createPlanDetailSection: (...args) => consumptionPlanDetailController.createPlanDetailSection(...args),
|
||||
messageTypes,
|
||||
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
consumptionTrendState,
|
||||
renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args),
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionChart = createConsumptionChart({
|
||||
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
|
||||
CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES,
|
||||
SVG_NAMESPACE: contracts.SVG_NAMESPACE,
|
||||
calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args),
|
||||
consumptionTrendState,
|
||||
createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args),
|
||||
createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args),
|
||||
formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args),
|
||||
formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args),
|
||||
formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args),
|
||||
formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args),
|
||||
normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionModel = createConsumptionModel({
|
||||
|
||||
});
|
||||
const debugContentLog = createDebugContentLog({
|
||||
previewDebugBody: createDebugBodyPreview,
|
||||
debugState,
|
||||
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
||||
});
|
||||
const debugContentView = createDebugContentView({
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
debugState,
|
||||
formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args),
|
||||
formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args),
|
||||
formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args),
|
||||
environment
|
||||
});
|
||||
function start() {
|
||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||
}
|
||||
return {
|
||||
opportunityCalendar,
|
||||
orgChart,
|
||||
workloadActionForm,
|
||||
activityFilters,
|
||||
start, contracts,
|
||||
opportunityWorkloadActions,
|
||||
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||
authSessionState,
|
||||
opportunitiesQueryState,
|
||||
opportunitiesTableState,
|
||||
opportunitiesFilterState,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
consumptionPlansState,
|
||||
consumptionTrendState,
|
||||
opportunitiesDashboardState,
|
||||
debugState,
|
||||
contentFusionPage,
|
||||
contentModal,
|
||||
contentTheme,
|
||||
contentIcons,
|
||||
contentStyles,
|
||||
authContentSession,
|
||||
currentUser,
|
||||
authTokenRelay,
|
||||
authXsrfContent,
|
||||
platformContentMessages,
|
||||
opportunitiesController,
|
||||
fusionOpportunities,
|
||||
opportunitiesFiscalPeriods,
|
||||
opportunitiesTable,
|
||||
opportunitiesDashboard,
|
||||
opportunitiesFilters,
|
||||
opportunitiesFormatters,
|
||||
opportunitiesLinks,
|
||||
preferencesStorage,
|
||||
preferencesView,
|
||||
productsContentJobs,
|
||||
productWorkloads,
|
||||
productActivities,
|
||||
activityProgress,
|
||||
srSessionPreparation,
|
||||
refreshAll,
|
||||
activityCache,
|
||||
productsDetails,
|
||||
rampProjection,
|
||||
workloadForecastUpdate,
|
||||
rampProjectionView,
|
||||
consumptionPlansAction,
|
||||
installBaseAction,
|
||||
installBaseView,
|
||||
serviceRequestsAction,
|
||||
serviceRequestsView,
|
||||
consumptionPlansView,
|
||||
consumptionPlanDetailController,
|
||||
consumptionPlanDetailView,
|
||||
ratecardModel,
|
||||
ratecardView,
|
||||
ratecardController,
|
||||
consumptionTrendController,
|
||||
consumptionChart,
|
||||
consumptionModel,
|
||||
debugContentLog,
|
||||
debugContentView
|
||||
};
|
||||
}
|
||||
99
src/content/contracts.js
Normal file
99
src/content/contracts.js
Normal file
@@ -0,0 +1,99 @@
|
||||
function createContentContracts() {
|
||||
const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5";
|
||||
const TILE_LABEL_ID = `${TILE_ID}_0`;
|
||||
const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales";
|
||||
const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon";
|
||||
const MODAL_ID = "opportunities-extension-modal";
|
||||
const STYLE_ID = "opportunities-extension-redwood-styles";
|
||||
const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener";
|
||||
const DEBUG_PANEL_ID = "opportunities-extension-debug-panel";
|
||||
const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table";
|
||||
const STAGE_FILTERS_ID = "opportunities-extension-stage-filters";
|
||||
const STATUS_FILTERS_ID = "opportunities-extension-status-filters";
|
||||
const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button";
|
||||
const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel";
|
||||
const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search";
|
||||
const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all";
|
||||
const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list";
|
||||
const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button";
|
||||
const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel";
|
||||
const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search";
|
||||
const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all";
|
||||
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
||||
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
||||
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
|
||||
const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress";
|
||||
const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp";
|
||||
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
|
||||
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
||||
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
|
||||
const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal";
|
||||
const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener";
|
||||
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
|
||||
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
|
||||
const DEBUG_BODY_LIMIT = 12000;
|
||||
const OPPORTUNITIES_PAGE_LIMIT = 15;
|
||||
const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24;
|
||||
const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay";
|
||||
const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
|
||||
const AUTH_STATUS = {
|
||||
idle: "idle",
|
||||
pending: "pending",
|
||||
authenticated: "authenticated",
|
||||
unauthenticated: "unauthenticated"
|
||||
};
|
||||
const CONSUMPTION_TREND_PERIODS = [
|
||||
{ value: "quarterly", label: "Quarterly", windowSize: 8 },
|
||||
{ value: "monthly", label: "Monthly", windowSize: 13 },
|
||||
{ value: "weekly", label: "Weekly", windowSize: 13 },
|
||||
{ value: "daily", label: "Daily", windowSize: 30 }
|
||||
];
|
||||
const CONSUMPTION_TREND_SERIES = [
|
||||
{ value: "funded", label: "Funded Allocation" },
|
||||
{ value: "overage", label: "Overage" },
|
||||
{ value: "average", label: "Average Consumption" }
|
||||
];
|
||||
const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly";
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
|
||||
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
|
||||
const OPPORTUNITY_TYPE_OPTIONS = [
|
||||
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
|
||||
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
|
||||
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
|
||||
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
|
||||
{ label: "I own", value: "ORA_MYOPTIES" },
|
||||
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
|
||||
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
|
||||
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
|
||||
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
|
||||
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
|
||||
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
|
||||
];
|
||||
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
|
||||
"ORA_MYSALESTEAMOPTIES",
|
||||
"ORA_MYASSGTERROPTIES",
|
||||
"ORA_CREDITRECEIVER_ISME"
|
||||
];
|
||||
const OPPTY_PRODUCTS_CACHE_OPTIONS = [
|
||||
{ label: "1 hour", value: 1 },
|
||||
{ label: "6 hours", value: 6 },
|
||||
{ label: "12 hours", value: 12 },
|
||||
{ label: "24 hours (default)", value: 24 },
|
||||
{ label: "48 hours", value: 48 },
|
||||
{ label: "7 days", value: 168 }
|
||||
];
|
||||
const PERIOD_OPTIONS = [
|
||||
"Current Quarter",
|
||||
"Next Quarter",
|
||||
"Previous Quarter",
|
||||
"Current Fiscal Year",
|
||||
"4 Rolling Quarters (CQ + 3)"
|
||||
];
|
||||
const ALLOWED_PATHS = [
|
||||
"/hcmUI/faces/FuseWelcome",
|
||||
"/fscmUI/faces/FuseWelcome"
|
||||
];
|
||||
return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS };
|
||||
}
|
||||
107
src/content/fusion-page.js
Normal file
107
src/content/fusion-page.js
Normal file
@@ -0,0 +1,107 @@
|
||||
function createContentFusionPage({
|
||||
ADD_ICON_SELECTOR,
|
||||
ALLOWED_PATHS,
|
||||
SALES_GROUP_SELECTOR,
|
||||
TILE_ID,
|
||||
TILE_LABEL_ID,
|
||||
appendPath,
|
||||
openOpportunitiesModal,
|
||||
environment
|
||||
}) {
|
||||
const { Node, document, window } = environment;
|
||||
function createOpportunitiesTile() {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "flat-grid-cell";
|
||||
|
||||
const item = document.createElement("div");
|
||||
item.id = TILE_ID;
|
||||
item.className = "app-nav-item opportunities-extension-tile";
|
||||
item.setAttribute("filmstrip", "Opportunities Extension");
|
||||
item.setAttribute("page", "undefined");
|
||||
item.setAttribute("index", "0");
|
||||
item.setAttribute("type", "subcluster");
|
||||
item.setAttribute("title", "Opportunities Extension");
|
||||
item.setAttribute("group", "groupNode_tools");
|
||||
item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138");
|
||||
item.setAttribute("targetframe", "_blank");
|
||||
item.setAttribute("isdesturlexist", "true");
|
||||
item.setAttribute("role", "presentation");
|
||||
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 48 48");
|
||||
svg.setAttribute("style", "fill:currentColor");
|
||||
svg.setAttribute("class", "svg-nav suiicon svg-bkgd09");
|
||||
svg.setAttribute("data-icon", "navi_reportsearch");
|
||||
svg.setAttribute("role", "presentation");
|
||||
svg.setAttribute("focusable", "false");
|
||||
|
||||
appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z");
|
||||
appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z");
|
||||
appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z");
|
||||
appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z");
|
||||
appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z");
|
||||
appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z");
|
||||
appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40");
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.id = TILE_LABEL_ID;
|
||||
link.className = "app-nav-label flat-grid-nav-label";
|
||||
link.href = "#";
|
||||
link.textContent = "Opportunities Extension";
|
||||
|
||||
item.append(svg, link);
|
||||
wrapper.append(item);
|
||||
wrapper.addEventListener("click", handleTileClick);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function handleTileClick(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openOpportunitiesModal();
|
||||
}
|
||||
|
||||
function isAllowedPage() {
|
||||
if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ALLOWED_PATHS.some((path) => {
|
||||
return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`);
|
||||
});
|
||||
}
|
||||
|
||||
function findInsertionPoint(salesGroup) {
|
||||
const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR);
|
||||
|
||||
if (addIconInsideGroup) {
|
||||
return addIconInsideGroup;
|
||||
}
|
||||
|
||||
const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body;
|
||||
const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR));
|
||||
|
||||
return addIconCells.find((cell) => {
|
||||
return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function insertTile() {
|
||||
if (document.getElementById(TILE_ID)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const salesGroup = document.querySelector(SALES_GROUP_SELECTOR);
|
||||
const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null;
|
||||
|
||||
if (!salesGroup || !addIconCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
addIconCell.before(createOpportunitiesTile());
|
||||
return true;
|
||||
}
|
||||
|
||||
return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile };
|
||||
}
|
||||
151
src/content/icons.js
Normal file
151
src/content/icons.js
Normal file
@@ -0,0 +1,151 @@
|
||||
function createContentIcons({
|
||||
appendPath,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createThemeIcon(iconName) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
|
||||
if (iconName === "sun") {
|
||||
path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z");
|
||||
} else {
|
||||
path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z");
|
||||
}
|
||||
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function createSettingsIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("class", "opportunities-extension-settings-icon");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
appendPath(svg, "", "M4 7h16M4 17h16");
|
||||
|
||||
[upperControl, lowerControl].forEach((control, index) => {
|
||||
control.setAttribute("cx", index === 0 ? "9" : "15");
|
||||
control.setAttribute("cy", index === 0 ? "7" : "17");
|
||||
control.setAttribute("r", "2.5");
|
||||
control.setAttribute("fill", "currentColor");
|
||||
control.setAttribute("stroke", "currentColor");
|
||||
control.setAttribute("stroke-width", "1.8");
|
||||
});
|
||||
|
||||
svg.querySelector("path").setAttribute("fill", "none");
|
||||
svg.querySelector("path").setAttribute("stroke", "currentColor");
|
||||
svg.querySelector("path").setAttribute("stroke-linecap", "round");
|
||||
svg.querySelector("path").setAttribute("stroke-width", "1.8");
|
||||
svg.append(upperControl, lowerControl);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function createCloseIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function createRefreshIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
path.setAttribute("d", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
function createViewIcon(view) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const calendarFrame = "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z";
|
||||
path.setAttribute("d", view === "today" ? `${calendarFrame}M9 16l2 2 4-4`
|
||||
: view === "week" ? `${calendarFrame}M6 14h12v4H6Z M10 14v4m4-4v4`
|
||||
: view === "calendar"
|
||||
? "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM7 14h2m6 0h2m-10 4h2m6 0h2"
|
||||
: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM3 9h18M9 9v12M3 15h18");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
function createAlertIcon() {
|
||||
/* Lucide triangle-alert (https://lucide.dev/icons/triangle-alert), ISC License.
|
||||
* Copyright (c) 2026 Lucide Icons and Contributors
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*
|
||||
* Derived from Feather alert-triangle, MIT License.
|
||||
* Copyright (c) 2013-present Cole Bemis
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
for (const [name, value] of Object.entries({ viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false",
|
||||
fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" })) svg.setAttribute(name, value);
|
||||
for (const d of ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon, createAlertIcon };
|
||||
}
|
||||
9
src/content/main.js
Normal file
9
src/content/main.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** Preserve the existing document_idle initialization and observer behavior. */
|
||||
function startContentApplication({ environment, page, styles, products }) {
|
||||
if (!page.isAllowedPage()) return;
|
||||
styles.ensureExtensionStyles();
|
||||
products.ensureProductsBackgroundMessageHandler();
|
||||
if (page.insertTile()) return;
|
||||
const observer = new environment.MutationObserver(() => page.insertTile());
|
||||
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
584
src/content/modal.js
Normal file
584
src/content/modal.js
Normal file
@@ -0,0 +1,584 @@
|
||||
function createContentModal({
|
||||
ACCOUNT_PLANS_MODAL_ID,
|
||||
AUTH_STATUS,
|
||||
CUSTOMER_FILTER_BUTTON_ID,
|
||||
CUSTOMER_FILTER_LIST_ID,
|
||||
CUSTOMER_FILTER_PANEL_ID,
|
||||
CUSTOMER_FILTER_SEARCH_ID,
|
||||
CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||
DEBUG_PANEL_ID,
|
||||
ESCAPE_LISTENER_KEY,
|
||||
MODAL_ID,
|
||||
OPPORTUNITIES_TABLE_ID,
|
||||
OWNER_FILTER_BUTTON_ID,
|
||||
OWNER_FILTER_LIST_ID,
|
||||
OWNER_FILTER_PANEL_ID,
|
||||
OWNER_FILTER_SEARCH_ID,
|
||||
OWNER_FILTER_SELECT_ALL_ID,
|
||||
PERIOD_OPTIONS,
|
||||
PREFERENCES_MODAL_ID,
|
||||
PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||
PRODUCTS_PROGRESS_ID,
|
||||
STAGE_DASHBOARD_ID,
|
||||
STAGE_FILTERS_ID,
|
||||
STATUS_FILTERS_ID,
|
||||
TABLE_SEARCH_ID,
|
||||
applyTheme,
|
||||
authSessionState,
|
||||
closeAccountPlansModal,
|
||||
closeInstallBaseModal,
|
||||
closeServiceRequestsModal,
|
||||
closeOrganizationChart,
|
||||
closeActionForm,
|
||||
closeCustomerFilterPanel,
|
||||
closeOwnerFilterPanel,
|
||||
closePreferencesModal,
|
||||
createSettingsIcon,
|
||||
createRefreshAllButton,
|
||||
createManageTimeButton,
|
||||
createActivityFilterControls,
|
||||
mountOpportunityViews,
|
||||
debugState,
|
||||
ensureExtensionStyles,
|
||||
getCustomerOptions,
|
||||
getOwnerOptions,
|
||||
getSavedOpportunityTypeValues,
|
||||
getSavedOpptyProductsCacheHours,
|
||||
getSavedTheme,
|
||||
openPreferencesModal,
|
||||
opportunitiesFilterState,
|
||||
opportunitiesQueryState,
|
||||
preferencesState,
|
||||
renderCustomerFilter,
|
||||
renderCustomerFilterList,
|
||||
renderOpportunitiesTable,
|
||||
renderOwnerFilter,
|
||||
renderOwnerFilterList,
|
||||
renderStageFilterButtons,
|
||||
renderStatusFilterButtons,
|
||||
requestOpportunitiesForPeriod,
|
||||
closeCrmSrPopover,
|
||||
requestTokenRelayOnce,
|
||||
ensureCurrentUser,
|
||||
prepareSrSession,
|
||||
resetCustomerAndOwnerFiltersForPeriod,
|
||||
resetOpportunitiesTable,
|
||||
restoreFilterPreferences,
|
||||
saveFilterPreferences,
|
||||
toggleCustomerFilterPanel,
|
||||
toggleDebugPanel,
|
||||
toggleOwnerFilterPanel,
|
||||
environment
|
||||
}) {
|
||||
const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
let authCompletion = null;
|
||||
function resetAuthCompletion() {
|
||||
if (!authCompletion) return;
|
||||
clearTimeout(authCompletion.waitTimer);
|
||||
clearTimeout(authCompletion.hideTimer);
|
||||
authCompletion.badge.setAttribute("data-fading", "false");
|
||||
authCompletion = null;
|
||||
}
|
||||
function openOpportunitiesModal() {
|
||||
ensureExtensionStyles();
|
||||
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
|
||||
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
|
||||
debugState.debugSecretsVisible = false;
|
||||
|
||||
const existingModal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (existingModal) {
|
||||
existingModal.hidden = false;
|
||||
prepareSrSession?.();
|
||||
existingModal.querySelector("select").focus();
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
ensureEscapeKeyHandler();
|
||||
ensureCurrentUser().then(() => {
|
||||
if (existingModal.hidden) return;
|
||||
return requestTokenRelayOnce();
|
||||
})
|
||||
.then(() => renderOpportunitiesTable()).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("lang", "en");
|
||||
authSessionState.tokenRelayRequest = null;
|
||||
authSessionState.consumerTokenServiceRequest = null;
|
||||
authSessionState.accessToken = "";
|
||||
authSessionState.principal = "";
|
||||
opportunitiesQueryState.periodRequestVersion = 0;
|
||||
authSessionState.authStatus = AUTH_STATUS.idle;
|
||||
resetOpportunitiesTable();
|
||||
preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
||||
preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
|
||||
overlay.id = MODAL_ID;
|
||||
overlay.className = "opportunities-extension-modal";
|
||||
overlay.setAttribute("data-theme", getSavedTheme());
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||
|
||||
const shell = document.createElement("section");
|
||||
shell.className = "opportunities-extension-shell";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-header";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.id = "opportunities-extension-title";
|
||||
title.textContent = "Opportunities Extension";
|
||||
|
||||
const authBadge = document.createElement("span");
|
||||
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||
authBadge.textContent = "Authenticating";
|
||||
|
||||
const subtitleRow = document.createElement("div");
|
||||
subtitleRow.className = "opportunities-extension-subtitle-row";
|
||||
|
||||
const productsProgressBadge = document.createElement("span");
|
||||
productsProgressBadge.id = PRODUCTS_PROGRESS_ID;
|
||||
productsProgressBadge.className = "opportunities-extension-products-progress";
|
||||
productsProgressBadge.setAttribute("role", "status");
|
||||
productsProgressBadge.hidden = true;
|
||||
|
||||
const productsCacheTimestamp = document.createElement("span");
|
||||
productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID;
|
||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||
productsCacheTimestamp.setAttribute("role", "status");
|
||||
productsCacheTimestamp.hidden = true;
|
||||
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||
|
||||
const titleBlock = document.createElement("div");
|
||||
titleBlock.className = "opportunities-extension-title-row";
|
||||
titleBlock.append(title, subtitleRow);
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
closeButton.setAttribute("aria-label", "Close");
|
||||
closeButton.textContent = "X";
|
||||
closeButton.addEventListener("click", closeOpportunitiesModal);
|
||||
|
||||
const themeButton = document.createElement("button");
|
||||
themeButton.type = "button";
|
||||
themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle";
|
||||
themeButton.addEventListener("click", () => {
|
||||
const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||||
applyTheme(overlay, themeButton, nextTheme);
|
||||
});
|
||||
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
|
||||
|
||||
const preferencesButton = document.createElement("button");
|
||||
preferencesButton.type = "button";
|
||||
preferencesButton.className = "opportunities-extension-icon-button";
|
||||
preferencesButton.setAttribute("aria-label", "Open preferences");
|
||||
preferencesButton.title = "Preferences";
|
||||
preferencesButton.append(createSettingsIcon());
|
||||
preferencesButton.addEventListener("click", openPreferencesModal);
|
||||
|
||||
const headerActions = document.createElement("div");
|
||||
headerActions.className = "opportunities-extension-header-actions";
|
||||
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
|
||||
|
||||
header.append(titleBlock, headerActions);
|
||||
|
||||
const body = document.createElement("main");
|
||||
body.className = "opportunities-extension-body";
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.className = "opportunities-extension-form";
|
||||
|
||||
const field = document.createElement("label");
|
||||
field.className = "opportunities-extension-field";
|
||||
|
||||
const labelText = document.createElement("span");
|
||||
labelText.textContent = "Reference";
|
||||
|
||||
const selectWrap = document.createElement("span");
|
||||
selectWrap.className = "opportunities-extension-select-wrap";
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.name = "opportunityPeriod";
|
||||
|
||||
PERIOD_OPTIONS.forEach((optionLabel) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = optionLabel;
|
||||
option.textContent = optionLabel;
|
||||
select.append(option);
|
||||
});
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
resetCustomerAndOwnerFiltersForPeriod();
|
||||
saveFilterPreferences(select.value);
|
||||
requestOpportunitiesForPeriod(select.value);
|
||||
});
|
||||
|
||||
selectWrap.append(select);
|
||||
field.append(labelText, selectWrap);
|
||||
restoreFilterPreferences(select);
|
||||
|
||||
const stageFilter = document.createElement("section");
|
||||
stageFilter.className = "opportunities-extension-stage-filter";
|
||||
|
||||
const stageFilterLabel = document.createElement("span");
|
||||
stageFilterLabel.className = "opportunities-extension-stage-filter-label";
|
||||
stageFilterLabel.textContent = "Stage";
|
||||
|
||||
const stageFilters = document.createElement("div");
|
||||
stageFilters.id = STAGE_FILTERS_ID;
|
||||
stageFilters.className = "opportunities-extension-stage-filter-controls";
|
||||
stageFilters.setAttribute("role", "group");
|
||||
stageFilters.setAttribute("aria-label", "Filter by stage");
|
||||
|
||||
stageFilter.append(stageFilterLabel, stageFilters);
|
||||
|
||||
const customerFilter = document.createElement("section");
|
||||
customerFilter.className = "opportunities-extension-customer-filter";
|
||||
|
||||
const customerFilterLabel = document.createElement("span");
|
||||
customerFilterLabel.className = "opportunities-extension-customer-filter-label";
|
||||
customerFilterLabel.textContent = "Customer";
|
||||
|
||||
const customerFilterButton = document.createElement("button");
|
||||
customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID;
|
||||
customerFilterButton.type = "button";
|
||||
customerFilterButton.className = "opportunities-extension-customer-filter-trigger";
|
||||
customerFilterButton.setAttribute("aria-expanded", "false");
|
||||
customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID);
|
||||
customerFilterButton.addEventListener("click", toggleCustomerFilterPanel);
|
||||
|
||||
const customerFilterPanel = document.createElement("section");
|
||||
customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID;
|
||||
customerFilterPanel.className = "opportunities-extension-customer-filter-panel";
|
||||
customerFilterPanel.hidden = true;
|
||||
|
||||
const customerSearchInput = document.createElement("input");
|
||||
customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID;
|
||||
customerSearchInput.className = "opportunities-extension-customer-filter-search";
|
||||
customerSearchInput.type = "search";
|
||||
customerSearchInput.placeholder = "Search customers";
|
||||
customerSearchInput.setAttribute("aria-label", "Search customers");
|
||||
customerSearchInput.addEventListener("input", () => {
|
||||
opportunitiesFilterState.customerSearch = customerSearchInput.value;
|
||||
saveFilterPreferences();
|
||||
renderCustomerFilterList();
|
||||
});
|
||||
|
||||
const selectAllLabel = document.createElement("label");
|
||||
selectAllLabel.className = "opportunities-extension-customer-filter-select-all";
|
||||
|
||||
const selectAllCheckbox = document.createElement("input");
|
||||
selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID;
|
||||
selectAllCheckbox.type = "checkbox";
|
||||
selectAllCheckbox.addEventListener("change", () => {
|
||||
const customerOptions = getCustomerOptions();
|
||||
opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked
|
||||
? new Set(customerOptions.map((customer) => customer.key))
|
||||
: new Set();
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderCustomerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
const selectAllText = document.createElement("span");
|
||||
selectAllText.textContent = "Select all";
|
||||
selectAllLabel.append(selectAllCheckbox, selectAllText);
|
||||
|
||||
const customerFilterList = document.createElement("div");
|
||||
customerFilterList.id = CUSTOMER_FILTER_LIST_ID;
|
||||
customerFilterList.className = "opportunities-extension-customer-filter-list";
|
||||
|
||||
customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList);
|
||||
customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel);
|
||||
|
||||
const ownerFilter = document.createElement("section");
|
||||
ownerFilter.className = "opportunities-extension-customer-filter";
|
||||
|
||||
const ownerFilterLabel = document.createElement("span");
|
||||
ownerFilterLabel.className = "opportunities-extension-customer-filter-label";
|
||||
ownerFilterLabel.textContent = "Owner";
|
||||
|
||||
const ownerFilterButton = document.createElement("button");
|
||||
ownerFilterButton.id = OWNER_FILTER_BUTTON_ID;
|
||||
ownerFilterButton.type = "button";
|
||||
ownerFilterButton.className = "opportunities-extension-customer-filter-trigger";
|
||||
ownerFilterButton.setAttribute("aria-expanded", "false");
|
||||
ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID);
|
||||
ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel);
|
||||
|
||||
const ownerFilterPanel = document.createElement("section");
|
||||
ownerFilterPanel.id = OWNER_FILTER_PANEL_ID;
|
||||
ownerFilterPanel.className = "opportunities-extension-customer-filter-panel";
|
||||
ownerFilterPanel.hidden = true;
|
||||
|
||||
const ownerSearchInput = document.createElement("input");
|
||||
ownerSearchInput.id = OWNER_FILTER_SEARCH_ID;
|
||||
ownerSearchInput.className = "opportunities-extension-customer-filter-search";
|
||||
ownerSearchInput.type = "search";
|
||||
ownerSearchInput.placeholder = "Search owners";
|
||||
ownerSearchInput.setAttribute("aria-label", "Search owners");
|
||||
ownerSearchInput.addEventListener("input", () => {
|
||||
opportunitiesFilterState.ownerSearch = ownerSearchInput.value;
|
||||
saveFilterPreferences();
|
||||
renderOwnerFilterList();
|
||||
});
|
||||
|
||||
const ownerSelectAllLabel = document.createElement("label");
|
||||
ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all";
|
||||
|
||||
const ownerSelectAllCheckbox = document.createElement("input");
|
||||
ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID;
|
||||
ownerSelectAllCheckbox.type = "checkbox";
|
||||
ownerSelectAllCheckbox.addEventListener("change", () => {
|
||||
const ownerOptions = getOwnerOptions();
|
||||
opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked
|
||||
? new Set(ownerOptions.map((owner) => owner.key))
|
||||
: new Set();
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderOwnerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
const ownerSelectAllText = document.createElement("span");
|
||||
ownerSelectAllText.textContent = "Select all";
|
||||
ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText);
|
||||
|
||||
const ownerFilterList = document.createElement("div");
|
||||
ownerFilterList.id = OWNER_FILTER_LIST_ID;
|
||||
ownerFilterList.className = "opportunities-extension-customer-filter-list";
|
||||
|
||||
ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList);
|
||||
ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel);
|
||||
|
||||
const tableSearchField = document.createElement("label");
|
||||
tableSearchField.className = "opportunities-extension-table-search-field";
|
||||
|
||||
const tableSearchLabel = document.createElement("span");
|
||||
tableSearchLabel.textContent = "Search";
|
||||
|
||||
const tableSearchInput = document.createElement("input");
|
||||
tableSearchInput.id = TABLE_SEARCH_ID;
|
||||
tableSearchInput.type = "search";
|
||||
tableSearchInput.placeholder = "Search opportunities";
|
||||
tableSearchInput.setAttribute("aria-label", "Search opportunities");
|
||||
tableSearchInput.value = opportunitiesFilterState.tableSearch;
|
||||
tableSearchInput.addEventListener("input", () => {
|
||||
opportunitiesFilterState.tableSearch = tableSearchInput.value;
|
||||
saveFilterPreferences();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
tableSearchField.append(tableSearchLabel, tableSearchInput);
|
||||
|
||||
const statusFilter = document.createElement("section");
|
||||
statusFilter.className = "opportunities-extension-status-filter";
|
||||
|
||||
const statusFilterLabel = document.createElement("span");
|
||||
statusFilterLabel.className = "opportunities-extension-status-filter-label";
|
||||
statusFilterLabel.textContent = "Status";
|
||||
|
||||
const statusFilters = document.createElement("div");
|
||||
statusFilters.id = STATUS_FILTERS_ID;
|
||||
statusFilters.className = "opportunities-extension-status-filter-controls";
|
||||
statusFilters.setAttribute("role", "group");
|
||||
statusFilters.setAttribute("aria-label", "Filter by status");
|
||||
|
||||
statusFilter.append(statusFilterLabel, statusFilters);
|
||||
form.addEventListener("submit", (event) => event.preventDefault());
|
||||
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
|
||||
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
|
||||
body.append(form);
|
||||
|
||||
const stageDashboard = document.createElement("section");
|
||||
stageDashboard.id = STAGE_DASHBOARD_ID;
|
||||
stageDashboard.className = "opportunities-extension-stage-dashboard";
|
||||
stageDashboard.setAttribute("aria-label", "Opportunity amount by stage");
|
||||
body.append(stageDashboard);
|
||||
|
||||
const tableSurface = document.createElement("section");
|
||||
tableSurface.className = "opportunities-extension-table-surface";
|
||||
|
||||
const opportunitiesTable = document.createElement("table");
|
||||
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
|
||||
opportunitiesTable.className = "opportunities-extension-table";
|
||||
opportunitiesTable.setAttribute("data-table-layout", "standard");
|
||||
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
||||
tableSurface.append(opportunitiesTable);
|
||||
|
||||
mountOpportunityViews(body, tableSurface);
|
||||
|
||||
const debugPanel = document.createElement("aside");
|
||||
debugPanel.id = DEBUG_PANEL_ID;
|
||||
debugPanel.className = "opportunities-extension-debug-panel";
|
||||
debugPanel.hidden = true;
|
||||
body.append(debugPanel);
|
||||
|
||||
shell.append(header, body);
|
||||
overlay.append(shell);
|
||||
document.body.append(overlay);
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
ensureEscapeKeyHandler();
|
||||
renderStageFilterButtons();
|
||||
renderStatusFilterButtons();
|
||||
renderCustomerFilter();
|
||||
renderOwnerFilter();
|
||||
renderOpportunitiesTable();
|
||||
requestOpportunitiesForPeriod(select.value);
|
||||
prepareSrSession?.();
|
||||
select.focus();
|
||||
}
|
||||
|
||||
function updateAuthBadge(status) {
|
||||
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
|
||||
|
||||
if (!badge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated
|
||||
? status
|
||||
: AUTH_STATUS.pending;
|
||||
|
||||
if (authCompletion && (authCompletion.badge !== badge || badgeStatus !== AUTH_STATUS.authenticated)) resetAuthCompletion();
|
||||
badge.hidden = Boolean(authCompletion?.hidden);
|
||||
if (badgeStatus === AUTH_STATUS.authenticated && !authCompletion) {
|
||||
const current = { badge, hidden: false, waitTimer: null, hideTimer: null };
|
||||
authCompletion = current;
|
||||
badge.setAttribute("data-fading", "false");
|
||||
current.waitTimer = setTimeout(() => {
|
||||
if (authCompletion !== current) return;
|
||||
badge.setAttribute("data-fading", "true");
|
||||
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
current.hideTimer = setTimeout(() => {
|
||||
if (authCompletion !== current) return;
|
||||
current.hidden = true;
|
||||
badge.hidden = true;
|
||||
}, reducedMotion ? 0 : 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
badge.setAttribute("data-auth-status", badgeStatus);
|
||||
badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
|
||||
badge.textContent = badgeStatus === AUTH_STATUS.authenticated
|
||||
? "Authenticated"
|
||||
: badgeStatus === AUTH_STATUS.unauthenticated
|
||||
? "Unauthenticated"
|
||||
: "Authenticating";
|
||||
}
|
||||
|
||||
function closeOpportunitiesModal() {
|
||||
if (closeOrganizationChart()) return;
|
||||
resetAuthCompletion();
|
||||
closeCrmSrPopover();
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (modal) {
|
||||
closePreferencesModal();
|
||||
closeAccountPlansModal();
|
||||
closeInstallBaseModal();
|
||||
closeServiceRequestsModal();
|
||||
closeActionForm(false);
|
||||
modal.hidden = true;
|
||||
}
|
||||
|
||||
debugState.debugSecretsVisible = false;
|
||||
document.documentElement.classList.remove("opportunities-extension-scroll-lock");
|
||||
}
|
||||
|
||||
function ensureEscapeKeyHandler() {
|
||||
if (window[ESCAPE_LISTENER_KEY]) {
|
||||
return;
|
||||
}
|
||||
|
||||
window[ESCAPE_LISTENER_KEY] = true;
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (!modal || modal.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "d") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleDebugPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (closeOrganizationChart()) { event.preventDefault(); return; }
|
||||
if (closeActionForm()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (closeServiceRequestsModal()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (closeInstallBaseModal()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
|
||||
if (accountPlansModal && !accountPlansModal.hidden) {
|
||||
closeAccountPlansModal();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||
|
||||
if (preferencesModal && !preferencesModal.hidden) {
|
||||
closePreferencesModal();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeCustomerFilterPanel()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeOwnerFilterPanel()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
closeOpportunitiesModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||
const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||
|
||||
if (!modal || modal.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel && !panel.hidden) {
|
||||
const customerFilter = panel.closest(".opportunities-extension-customer-filter");
|
||||
|
||||
if (customerFilter && !customerFilter.contains(event.target)) {
|
||||
closeCustomerFilterPanel(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerPanel && !ownerPanel.hidden) {
|
||||
const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter");
|
||||
|
||||
if (ownerFilter && !ownerFilter.contains(event.target)) {
|
||||
closeOwnerFilterPanel(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
|
||||
}
|
||||
20
src/content/styles.js
Normal file
20
src/content/styles.js
Normal file
@@ -0,0 +1,20 @@
|
||||
function createContentStyles({
|
||||
STYLE_ID,
|
||||
redwoodStyles,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function ensureExtensionStyles() {
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = redwoodStyles;
|
||||
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
return { ensureExtensionStyles };
|
||||
}
|
||||
90
src/content/styles/dialogs.css
Normal file
90
src/content/styles/dialogs.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* Modal titles follow the customer Service Requests heading. Keep section and
|
||||
calendar headings independent from dialog typography. */
|
||||
.opportunities-extension-modal :is(
|
||||
.opportunities-extension-header,
|
||||
.opportunities-extension-account-plans-header,
|
||||
.opportunities-extension-preferences-header
|
||||
) {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
/* The main heading, status badges and toolbar share one vertical center. */
|
||||
.opportunities-extension-modal .opportunities-extension-header {
|
||||
align-items: center;
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-title-row,
|
||||
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row,
|
||||
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-header-actions {
|
||||
align-items: center;
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row {
|
||||
margin-block: 0;
|
||||
}
|
||||
/* These headers live in panels that already supply the outer inset. */
|
||||
.opportunities-extension-modal :is(.oe-org-dialog.oe-contact-dialog,
|
||||
.oe-org-confirm-panel, .opportunities-extension-projection-confirm,
|
||||
.opportunities-extension-debug-panel) { padding: 16px; }
|
||||
.opportunities-extension-modal :is(.oe-org-editor-header,
|
||||
.opportunities-extension-debug-panel-header) {
|
||||
padding: 0;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.opportunities-extension-modal :is(
|
||||
.opportunities-extension-account-plans-header,
|
||||
.opportunities-extension-preferences-header,
|
||||
.opportunities-extension-debug-panel-header
|
||||
) p {
|
||||
margin: 5px 0 0;
|
||||
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
letter-spacing: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Search controls share the 16px inset of their table frames. */
|
||||
.opportunities-extension-modal :is(.opportunities-extension-customer-table-toolbar,
|
||||
.opportunities-extension-ratecard-toolbar,
|
||||
.opportunities-extension-ratecard-plan-details) { padding-inline: 16px; }
|
||||
.opportunities-extension-modal .opportunities-extension-customer-table-toolbar {
|
||||
padding-block: 12px;
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-customer-table-search {
|
||||
width: min(420px, 100%);
|
||||
height: 38px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.opportunities-extension-modal :is(
|
||||
.opportunities-extension-header,
|
||||
.opportunities-extension-account-plans-header,
|
||||
.opportunities-extension-preferences-header,
|
||||
.opportunities-extension-debug-panel-header,
|
||||
.oe-org-editor-header
|
||||
) :is(h1, h2, h3),
|
||||
.opportunities-extension-modal .opportunities-extension-projection-confirm h2,
|
||||
.opportunities-extension-modal .oe-org-confirm-panel h3 {
|
||||
margin: 0;
|
||||
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
letter-spacing: normal;
|
||||
color: #312d2a;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.opportunities-extension-modal[data-theme="dark"] :is(
|
||||
.opportunities-extension-header,
|
||||
.opportunities-extension-account-plans-header,
|
||||
.opportunities-extension-preferences-header,
|
||||
.opportunities-extension-debug-panel-header,
|
||||
.oe-org-editor-header
|
||||
) :is(h1, h2, h3),
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-confirm h2,
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel h3 {
|
||||
color: #f6f4f2;
|
||||
}
|
||||
3081
src/content/styles/redwood.css
Normal file
3081
src/content/styles/redwood.css
Normal file
File diff suppressed because it is too large
Load Diff
138
src/content/styles/tables.css
Normal file
138
src/content/styles/tables.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/* Shared data-table presentation. Calendar grids are intentionally not data tables. */
|
||||
.opportunities-extension-modal :is(
|
||||
.opportunities-extension-table-surface,
|
||||
.opportunities-extension-install-base-content,
|
||||
.opportunities-extension-service-requests-content,
|
||||
.opportunities-extension-ratecard-viewport,
|
||||
.opportunities-extension-projection-viewport,
|
||||
.oe-time-grid,
|
||||
.oe-contact-opportunities-table
|
||||
) {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--oe-table-heading-border);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
/* Unpadded dialogs need an inset on the scrolling frame itself. */
|
||||
.opportunities-extension-modal :is(
|
||||
.opportunities-extension-install-base-content,
|
||||
.opportunities-extension-service-requests-content,
|
||||
.opportunities-extension-ratecard-viewport
|
||||
) { margin: 16px; }
|
||||
/* Other frames already sit inside a padded body: keep a single gutter. */
|
||||
.opportunities-extension-modal :is(
|
||||
.opportunities-extension-body,
|
||||
.opportunities-extension-projection-body,
|
||||
.opportunities-extension-products-detail,
|
||||
.oe-time-body,
|
||||
.oe-org-dialog.oe-contact-opportunities
|
||||
) { padding: 16px; }
|
||||
.opportunities-extension-modal table.opportunities-extension-products-table {
|
||||
border: 1px solid var(--oe-table-heading-border);
|
||||
}
|
||||
.opportunities-extension-modal {
|
||||
--oe-table-heading: #faf9f8;
|
||||
--oe-table-text: #312d2a;
|
||||
--oe-table-border: #ebe8e5;
|
||||
--oe-table-heading-border: #b8b2ad;
|
||||
--oe-table-hover: #f5f4f2;
|
||||
}
|
||||
.opportunities-extension-modal[data-theme="dark"] {
|
||||
--oe-table-heading: #333130;
|
||||
--oe-table-text: #f6f4f2;
|
||||
--oe-table-border: #3f3c39;
|
||||
--oe-table-heading-border: #4e4a46;
|
||||
--oe-table-hover: #35322f;
|
||||
}
|
||||
.opportunities-extension-modal table[data-table-layout="standard"]:not(.fc-scrollgrid) {
|
||||
width: 100%; border-collapse: collapse; border-spacing: 0; table-layout: auto;
|
||||
color: var(--oe-table-text); font-size: 12px; line-height: 1.35;
|
||||
}
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th {
|
||||
padding: 10px 12px; height: 44px; vertical-align: middle; text-align: left;
|
||||
white-space: nowrap; overflow-wrap: normal; word-break: normal;
|
||||
font-size: 12px; font-weight: 700; color: var(--oe-table-text);
|
||||
background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-heading-border);
|
||||
}
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > td {
|
||||
padding: 10px 12px; vertical-align: middle; text-align: left;
|
||||
border-bottom: 1px solid var(--oe-table-border);
|
||||
}
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr:hover { background: var(--oe-table-hover); }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button,
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort {
|
||||
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
|
||||
width: 100%; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0;
|
||||
border: 0; border-radius: 0; background: transparent; color: inherit;
|
||||
text-align: left; white-space: nowrap; overflow-wrap: normal; font-weight: 700;
|
||||
}
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button::after { position: absolute; right: 0; left: auto; font-size: 12px; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort-icon { margin-left: auto; }
|
||||
/* Currency columns keep their heading and value on the same right edge. */
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] :is(th, td)[data-align="money"] { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button,
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort { justify-content: flex-end; text-align: right; padding: 4px 0 4px 18px; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button::after { left: 0; right: auto; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
|
||||
/* Preserve room for Overview badges and the compact daily entry controls. */
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
|
||||
position: static;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
|
||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
|
||||
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
|
||||
/* Overview reserves nearly half its width for the two primary descriptions. */
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column],
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column] :is(a, span, button) {
|
||||
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] { table-layout: fixed; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="name"] { width: 24%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: 24%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 7%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 7%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 8%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 7%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="status"] { width: 5%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 4%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 6%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 8%; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-inline: 6px; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button { min-width: 0; gap: 4px; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] .opportunities-extension-sort-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th,
|
||||
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
|
||||
white-space: normal;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
hyphens: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
|
||||
padding: 4px 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-ratecard-sort > span:first-child { min-width: 0; }
|
||||
.opportunities-extension-modal .opportunities-extension-ratecard-sort-icon { flex: 0 0 12px; }
|
||||
10
src/content/svg.js
Normal file
10
src/content/svg.js
Normal file
@@ -0,0 +1,10 @@
|
||||
function createSvgPaths({ environment }) {
|
||||
const { document } = environment;
|
||||
function appendPath(svg, className, d) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("class", className);
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return { appendPath };
|
||||
}
|
||||
32
src/content/theme.js
Normal file
32
src/content/theme.js
Normal file
@@ -0,0 +1,32 @@
|
||||
function createContentTheme({
|
||||
THEME_STORAGE_KEY,
|
||||
createThemeIcon,
|
||||
environment
|
||||
}) {
|
||||
const getLocalStorage = () => environment.localStorage;
|
||||
function getSavedTheme() {
|
||||
try {
|
||||
return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
|
||||
} catch (error) {
|
||||
return "light";
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(modal, button, theme) {
|
||||
const isDarkTheme = theme === "dark";
|
||||
|
||||
modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light");
|
||||
button.textContent = "";
|
||||
button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro");
|
||||
button.title = isDarkTheme ? "Light mode" : "Dark mode";
|
||||
button.append(createThemeIcon(isDarkTheme ? "sun" : "moon"));
|
||||
|
||||
try {
|
||||
getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light");
|
||||
} catch (error) {
|
||||
// Continue with the selected theme when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return { getSavedTheme, applyTheme };
|
||||
}
|
||||
383
src/features/consumption/chart.js
Normal file
383
src/features/consumption/chart.js
Normal file
@@ -0,0 +1,383 @@
|
||||
function createConsumptionChart({
|
||||
CONSUMPTION_TREND_PERIODS,
|
||||
CONSUMPTION_TREND_SERIES,
|
||||
SVG_NAMESPACE,
|
||||
calculateAverageConsumption,
|
||||
consumptionTrendState,
|
||||
createConsumptionTrendKey,
|
||||
createNiceConsumptionMaximum,
|
||||
formatCompactConsumptionNumber,
|
||||
formatConsumptionCurrency,
|
||||
formatConsumptionPointLabel,
|
||||
formatConsumptionRange,
|
||||
normalizeConsumptionTrendItems,
|
||||
environment
|
||||
}) {
|
||||
const { document, window } = environment;
|
||||
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
|
||||
chart.textContent = "";
|
||||
chart.setAttribute("aria-busy", "false");
|
||||
const points = normalizeConsumptionTrendItems(items);
|
||||
|
||||
if (points.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-consumption-chart-empty";
|
||||
empty.textContent = "No consumption data found for this period.";
|
||||
chart.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
|
||||
const windowSize = option ? option.windowSize : points.length;
|
||||
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
|
||||
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
|
||||
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
|
||||
const end = Math.max(0, points.length - (offset * windowSize));
|
||||
const start = Math.max(0, end - windowSize);
|
||||
const visiblePoints = points.slice(start, end);
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
|
||||
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "opportunities-extension-consumption-navigation";
|
||||
const olderButton = createConsumptionNavigationButton("older", start === 0);
|
||||
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
|
||||
const range = document.createElement("strong");
|
||||
range.textContent = formatConsumptionRange(visiblePoints, period);
|
||||
olderButton.addEventListener("click", () => {
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
});
|
||||
newerButton.addEventListener("click", () => {
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
});
|
||||
navigation.append(olderButton, range, newerButton);
|
||||
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "opportunities-extension-consumption-figure";
|
||||
const visual = document.createElement("div");
|
||||
visual.className = "opportunities-extension-consumption-visual";
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.className = "opportunities-extension-consumption-tooltip";
|
||||
tooltip.hidden = true;
|
||||
tooltip.setAttribute("role", "tooltip");
|
||||
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
|
||||
const svg = createConsumptionSvg(
|
||||
visiblePoints,
|
||||
period,
|
||||
end === points.length,
|
||||
activeSeries,
|
||||
tooltip
|
||||
);
|
||||
const legend = createConsumptionLegend(
|
||||
section,
|
||||
chart,
|
||||
items,
|
||||
period,
|
||||
subPlanNum,
|
||||
activeSeries
|
||||
);
|
||||
visual.append(svg, legend);
|
||||
figure.append(visual, tooltip);
|
||||
chart.append(navigation, figure);
|
||||
}
|
||||
|
||||
function getConsumptionTrendSeriesFilter(subPlanNum) {
|
||||
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
|
||||
consumptionTrendState.consumptionTrendSeriesFilters.set(
|
||||
subPlanNum,
|
||||
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
|
||||
);
|
||||
}
|
||||
|
||||
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
|
||||
}
|
||||
|
||||
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "opportunities-extension-consumption-legend";
|
||||
legend.setAttribute("role", "group");
|
||||
legend.setAttribute("aria-label", "Chart series filters");
|
||||
|
||||
CONSUMPTION_TREND_SERIES.forEach((series) => {
|
||||
const button = document.createElement("button");
|
||||
const swatch = document.createElement("span");
|
||||
const label = document.createElement("span");
|
||||
button.type = "button";
|
||||
button.dataset.series = series.value;
|
||||
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
|
||||
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
|
||||
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
|
||||
swatch.setAttribute("aria-hidden", "true");
|
||||
label.textContent = series.label;
|
||||
button.append(swatch, label);
|
||||
button.addEventListener("click", () => {
|
||||
if (activeSeries.has(series.value)) {
|
||||
activeSeries.delete(series.value);
|
||||
} else {
|
||||
activeSeries.add(series.value);
|
||||
}
|
||||
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
|
||||
});
|
||||
legend.append(button);
|
||||
});
|
||||
|
||||
return legend;
|
||||
}
|
||||
|
||||
function createConsumptionNavigationButton(direction, disabled) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-consumption-navigation-button";
|
||||
button.disabled = disabled;
|
||||
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
|
||||
button.title = direction === "older" ? "Older" : "Newer";
|
||||
const icon = createSvgElement("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
"aria-hidden": "true",
|
||||
focusable: "false"
|
||||
});
|
||||
icon.append(createSvgElement("path", {
|
||||
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
}));
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
|
||||
const width = 1000;
|
||||
const height = 350;
|
||||
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
|
||||
const plotWidth = plot.right - plot.left;
|
||||
const plotHeight = plot.bottom - plot.top;
|
||||
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
|
||||
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
|
||||
const svg = createSvgElement("svg", {
|
||||
viewBox: `0 0 ${width} ${height}`,
|
||||
role: "img",
|
||||
"aria-label": `${period} cloud consumption chart`
|
||||
});
|
||||
svg.classList.add("opportunities-extension-consumption-svg");
|
||||
|
||||
for (let tick = 0; tick <= 5; tick += 1) {
|
||||
const value = (maximum / 5) * tick;
|
||||
const y = plot.bottom - ((value / maximum) * plotHeight);
|
||||
svg.append(
|
||||
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
|
||||
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
|
||||
);
|
||||
}
|
||||
|
||||
const step = plotWidth / Math.max(points.length, 1);
|
||||
const barWidth = Math.min(44, Math.max(10, step * 0.58));
|
||||
|
||||
points.forEach((point, index) => {
|
||||
const x = plot.left + (step * index) + ((step - barWidth) / 2);
|
||||
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
|
||||
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
|
||||
const visibleTotal = fundedAmount + overageAmount;
|
||||
const fundedHeight = (fundedAmount / maximum) * plotHeight;
|
||||
const overageHeight = (overageAmount / maximum) * plotHeight;
|
||||
const group = createSvgElement("g", {
|
||||
class: "consumption-bar-group",
|
||||
tabindex: "0",
|
||||
role: "img",
|
||||
"aria-label": createConsumptionPointAriaLabel(point, period)
|
||||
});
|
||||
|
||||
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
|
||||
group.addEventListener(eventName, (event) => {
|
||||
showConsumptionTooltip(tooltip, point, period, event);
|
||||
});
|
||||
});
|
||||
["pointerleave", "blur"].forEach((eventName) => {
|
||||
group.addEventListener(eventName, () => {
|
||||
tooltip.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
if (fundedHeight > 0) {
|
||||
const fundedBar = createSvgElement("rect", {
|
||||
x,
|
||||
y: plot.bottom - fundedHeight,
|
||||
width: barWidth,
|
||||
height: fundedHeight,
|
||||
class: "consumption-bar-funded"
|
||||
});
|
||||
appendConsumptionBarAnimation(
|
||||
fundedBar,
|
||||
plot.bottom,
|
||||
plot.bottom - fundedHeight,
|
||||
fundedHeight,
|
||||
index * 34
|
||||
);
|
||||
group.append(fundedBar);
|
||||
}
|
||||
|
||||
if (overageHeight > 0) {
|
||||
const overageTop = plot.bottom - fundedHeight - overageHeight;
|
||||
const overageBar = createSvgElement("rect", {
|
||||
x,
|
||||
y: overageTop,
|
||||
width: barWidth,
|
||||
height: overageHeight,
|
||||
class: "consumption-bar-overage"
|
||||
});
|
||||
appendConsumptionBarAnimation(
|
||||
overageBar,
|
||||
plot.bottom - fundedHeight,
|
||||
overageTop,
|
||||
overageHeight,
|
||||
(index * 34) + 90
|
||||
);
|
||||
group.append(overageBar);
|
||||
}
|
||||
|
||||
const top = plot.bottom - fundedHeight - overageHeight;
|
||||
|
||||
if (visibleTotal > 0) {
|
||||
const valueLabel = createSvgText(
|
||||
x + (barWidth / 2),
|
||||
Math.max(plot.top + 11, top - 7),
|
||||
formatCompactConsumptionNumber(visibleTotal),
|
||||
"consumption-value-label consumption-animated-label",
|
||||
"middle"
|
||||
);
|
||||
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
|
||||
group.append(valueLabel);
|
||||
}
|
||||
|
||||
const label = createSvgText(
|
||||
x + (barWidth / 2),
|
||||
plot.bottom + 18,
|
||||
formatConsumptionPointLabel(point, period),
|
||||
"consumption-x-label",
|
||||
period === "monthly" || period === "quarterly" ? "middle" : "end"
|
||||
);
|
||||
|
||||
if (period === "weekly" || period === "daily") {
|
||||
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
|
||||
}
|
||||
|
||||
group.append(label);
|
||||
svg.append(group);
|
||||
});
|
||||
|
||||
if (activeSeries.has("average")) {
|
||||
const averageY = plot.bottom - ((average / maximum) * plotHeight);
|
||||
const averageLine = createSvgElement("line", {
|
||||
x1: plot.left,
|
||||
y1: averageY,
|
||||
x2: plot.right,
|
||||
y2: averageY,
|
||||
class: "consumption-average-line consumption-animated-average"
|
||||
});
|
||||
svg.append(averageLine);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
|
||||
if (prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.append(
|
||||
createSvgElement("animate", {
|
||||
attributeName: "y",
|
||||
from: fromY,
|
||||
to: toY,
|
||||
dur: "0.52s",
|
||||
begin: `${delayMs}ms`,
|
||||
fill: "freeze",
|
||||
calcMode: "spline",
|
||||
keyTimes: "0;1",
|
||||
keySplines: "0.2 0.8 0.2 1"
|
||||
}),
|
||||
createSvgElement("animate", {
|
||||
attributeName: "height",
|
||||
from: "0",
|
||||
to: height,
|
||||
dur: "0.52s",
|
||||
begin: `${delayMs}ms`,
|
||||
fill: "freeze",
|
||||
calcMode: "spline",
|
||||
keyTimes: "0;1",
|
||||
keySplines: "0.2 0.8 0.2 1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function createConsumptionPointAriaLabel(point, period) {
|
||||
return `${formatConsumptionPointLabel(point, period)}. `
|
||||
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
|
||||
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
|
||||
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
|
||||
}
|
||||
|
||||
function showConsumptionTooltip(tooltip, point, period, event) {
|
||||
const figure = tooltip.parentElement;
|
||||
|
||||
if (!figure) {
|
||||
return;
|
||||
}
|
||||
|
||||
tooltip.textContent = "";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = formatConsumptionPointLabel(point, period);
|
||||
const details = document.createElement("span");
|
||||
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
|
||||
const overage = document.createElement("span");
|
||||
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
|
||||
const total = document.createElement("span");
|
||||
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
|
||||
tooltip.append(title, details, overage, total);
|
||||
tooltip.hidden = false;
|
||||
|
||||
const figureBounds = figure.getBoundingClientRect();
|
||||
const targetBounds = event.currentTarget.getBoundingClientRect();
|
||||
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
|
||||
? event.clientX
|
||||
: targetBounds.left + (targetBounds.width / 2);
|
||||
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
|
||||
? event.clientY
|
||||
: targetBounds.top;
|
||||
const left = Math.min(
|
||||
Math.max(8, pointerX - figureBounds.left + 12),
|
||||
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
|
||||
);
|
||||
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function createSvgElement(name, attributes = {}) {
|
||||
const element = document.createElementNS(SVG_NAMESPACE, name);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element.setAttribute(key, String(value));
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
function createSvgText(x, y, value, className, anchor) {
|
||||
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
|
||||
text.textContent = value;
|
||||
return text;
|
||||
}
|
||||
|
||||
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
|
||||
}
|
||||
54
src/features/consumption/header-view.js
Normal file
54
src/features/consumption/header-view.js
Normal file
@@ -0,0 +1,54 @@
|
||||
function createConsumptionHeaderView({ createCloseIcon, formatOracleResponseDate, environment }) {
|
||||
const { document } = environment;
|
||||
function createHeader({ titleId, customerLabel, closeLabel, onClose, titleText = "Cloud Consumption" }) {
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-account-plans-header";
|
||||
const heading = document.createElement("div");
|
||||
const title = document.createElement("h2");
|
||||
title.id = titleId;
|
||||
title.textContent = titleText;
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.textContent = customerLabel;
|
||||
heading.append(title, subtitle);
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
closeButton.setAttribute("aria-label", closeLabel);
|
||||
closeButton.title = "Close";
|
||||
closeButton.append(createCloseIcon());
|
||||
closeButton.addEventListener("click", onClose);
|
||||
header.append(heading, closeButton);
|
||||
return header;
|
||||
}
|
||||
function createDetails(plan, { contractAction = null } = {}) {
|
||||
const details = document.createElement("dl");
|
||||
details.className = "opportunities-extension-account-plan-details";
|
||||
const fields = [
|
||||
{ label: "Booking Customer", value: plan.bookingCustomer },
|
||||
{ label: "Sold To Party Name", value: plan.soldToPartyName },
|
||||
{ label: "Contract ID", value: plan.contractId },
|
||||
{ label: "Header Status", value: plan.hdrStatusCode },
|
||||
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
|
||||
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
|
||||
];
|
||||
fields.forEach(field => {
|
||||
const group = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const description = document.createElement("dd");
|
||||
term.textContent = field.label;
|
||||
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
|
||||
if (field.label === "Contract ID" && contractAction) {
|
||||
group.className = "opportunities-extension-contract-row";
|
||||
const value = document.createElement("span");
|
||||
value.textContent = description.textContent;
|
||||
description.textContent = "";
|
||||
description.className = "opportunities-extension-contract-ratecard";
|
||||
description.append(value, contractAction);
|
||||
}
|
||||
group.append(term, description);
|
||||
details.append(group);
|
||||
});
|
||||
return details;
|
||||
}
|
||||
return { createHeader, createDetails };
|
||||
}
|
||||
155
src/features/consumption/model.js
Normal file
155
src/features/consumption/model.js
Normal file
@@ -0,0 +1,155 @@
|
||||
function createConsumptionModel() {
|
||||
function normalizeConsumptionTrendItems(items) {
|
||||
const points = new Map();
|
||||
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : ""));
|
||||
|
||||
if (!dateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!points.has(dateKey)) {
|
||||
points.set(dateKey, {
|
||||
dateKey,
|
||||
date: parseConsumptionDate(item.fyDate),
|
||||
fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [],
|
||||
fiscalQtr: item.fiscalQtr || "",
|
||||
funded: 0,
|
||||
overage: 0,
|
||||
arr: 0,
|
||||
currencyCode: item.currencyCode || "USD"
|
||||
});
|
||||
}
|
||||
|
||||
const point = points.get(dateKey);
|
||||
const amount = toFiniteNumber(item.usedAmount);
|
||||
const category = String(item.usageCategory || "Funded Allocation").toUpperCase();
|
||||
|
||||
if (category === "OVERAGE") {
|
||||
point.overage += amount;
|
||||
} else {
|
||||
point.funded += amount;
|
||||
}
|
||||
|
||||
point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd));
|
||||
});
|
||||
|
||||
return Array.from(points.values())
|
||||
.map((point) => ({ ...point, total: point.funded + point.overage }))
|
||||
.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
|
||||
}
|
||||
|
||||
function getConsumptionPointTotal(point, activeSeries) {
|
||||
const selectedSeries = activeSeries && typeof activeSeries.has === "function"
|
||||
? activeSeries
|
||||
: new Set(["funded", "overage"]);
|
||||
return (selectedSeries.has("funded") ? point.funded : 0)
|
||||
+ (selectedSeries.has("overage") ? point.overage : 0);
|
||||
}
|
||||
|
||||
function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) {
|
||||
const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points;
|
||||
return completePoints.reduce(
|
||||
(sum, point) => sum + getConsumptionPointTotal(point, activeSeries),
|
||||
0
|
||||
) / Math.max(completePoints.length, 1);
|
||||
}
|
||||
|
||||
function createNiceConsumptionMaximum(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / magnitude;
|
||||
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return step * magnitude;
|
||||
}
|
||||
|
||||
function formatConsumptionPointLabel(point, period) {
|
||||
if (period === "quarterly") {
|
||||
return point.fiscalTime[0] || point.fiscalQtr || point.dateKey;
|
||||
}
|
||||
|
||||
if (period === "monthly") {
|
||||
return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" });
|
||||
}
|
||||
|
||||
return point.fiscalTime[0] || formatConsumptionDate(point.date, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function formatConsumptionRange(points, period) {
|
||||
if (points.length === 0) {
|
||||
return "No data";
|
||||
}
|
||||
|
||||
if (period === "quarterly") {
|
||||
return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`;
|
||||
}
|
||||
|
||||
if (period === "monthly") {
|
||||
const options = { month: "short", year: "numeric" };
|
||||
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
|
||||
}
|
||||
|
||||
const options = { day: "2-digit", month: "short" };
|
||||
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
|
||||
}
|
||||
|
||||
function parseConsumptionDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = String(value).split("-").map(Number);
|
||||
return parts.length === 3 && parts.every(Number.isFinite)
|
||||
? new Date(parts[0], parts[1] - 1, parts[2])
|
||||
: null;
|
||||
}
|
||||
|
||||
function formatConsumptionDate(date, options) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", "");
|
||||
}
|
||||
|
||||
function formatCompactConsumptionNumber(value) {
|
||||
const amount = toFiniteNumber(value);
|
||||
|
||||
if (Math.abs(amount) >= 1000000) {
|
||||
return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`;
|
||||
}
|
||||
|
||||
if (Math.abs(amount) >= 1000) {
|
||||
return `${Math.round(amount / 1000)}K`;
|
||||
}
|
||||
|
||||
return String(Math.round(amount));
|
||||
}
|
||||
|
||||
function formatConsumptionCurrency(value, currencyCode) {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currencyCode || "USD",
|
||||
maximumFractionDigits: 0
|
||||
}).format(toFiniteNumber(value));
|
||||
} catch (error) {
|
||||
return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber };
|
||||
}
|
||||
66
src/features/consumption/plan-detail-controller.js
Normal file
66
src/features/consumption/plan-detail-controller.js
Normal file
@@ -0,0 +1,66 @@
|
||||
function createConsumptionPlanDetailController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||
appendBackgroundRequestLog, renderPlanDetailBadges, environment }) {
|
||||
const { document } = environment;
|
||||
const states = new WeakMap();
|
||||
const pending = new Map();
|
||||
|
||||
function createPlanDetailSection(plan) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "opportunities-extension-plan-detail-badges";
|
||||
section.dataset.subPlanNum = String(plan.subPlanNum || "").trim();
|
||||
section.setAttribute("aria-live", "polite");
|
||||
states.set(section, { status: "idle" });
|
||||
return section;
|
||||
}
|
||||
async function request(subPlanNum) {
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.requestConsumptionPlanDetail, subPlanNum,
|
||||
authorization: authorizations && authorizations.accountPlans });
|
||||
appendBackgroundRequestLog(response?.debugRequests);
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to load consumption plan details.");
|
||||
return response.items;
|
||||
}
|
||||
function loadPlanDetailForPanel(panel, plan) {
|
||||
const section = panel?.querySelector(".opportunities-extension-plan-detail-badges");
|
||||
if (!section) return Promise.resolve();
|
||||
const state = states.get(section);
|
||||
if (state?.status === "ready") return Promise.resolve();
|
||||
if (state?.status === "loading") return state.promise;
|
||||
const subPlanNum = String(plan.subPlanNum || "").trim();
|
||||
section.textContent = "";
|
||||
section.setAttribute("aria-busy", "true");
|
||||
const skeleton = document.createElement("span");
|
||||
skeleton.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-plan-detail-skeleton";
|
||||
skeleton.setAttribute("role", "status");
|
||||
skeleton.setAttribute("aria-label", "Loading plan projection");
|
||||
section.append(skeleton);
|
||||
const current = { status: "loading", promise: null };
|
||||
states.set(section, current);
|
||||
current.promise = (async () => {
|
||||
try {
|
||||
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
||||
if (!pending.has(subPlanNum)) {
|
||||
const promise = request(subPlanNum).finally(() => { if (pending.get(subPlanNum) === promise) pending.delete(subPlanNum); });
|
||||
pending.set(subPlanNum, promise);
|
||||
}
|
||||
const items = await pending.get(subPlanNum);
|
||||
if (!section.isConnected || states.get(section) !== current) return;
|
||||
current.status = "ready";
|
||||
renderPlanDetailBadges(section, items);
|
||||
} catch (error) {
|
||||
if (!section.isConnected || states.get(section) !== current) return;
|
||||
current.status = "error";
|
||||
section.textContent = "";
|
||||
const notice = document.createElement("span");
|
||||
notice.className = "opportunities-extension-plan-detail-error";
|
||||
notice.setAttribute("role", "alert");
|
||||
notice.textContent = error.message || "Unable to load plan details.";
|
||||
section.append(notice);
|
||||
} finally {
|
||||
if (states.get(section) === current) section.setAttribute("aria-busy", "false");
|
||||
}
|
||||
})();
|
||||
return current.promise;
|
||||
}
|
||||
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
|
||||
}
|
||||
160
src/features/consumption/plan-detail-view.js
Normal file
160
src/features/consumption/plan-detail-view.js
Normal file
@@ -0,0 +1,160 @@
|
||||
function createConsumptionPlanDetailView({ createCloseIcon, environment }) {
|
||||
const { document, window, setTimeout, clearTimeout } = environment;
|
||||
let active = null;
|
||||
let sequence = 0;
|
||||
const fields = [
|
||||
["pricingModel", "Pricing model"], ["lineStatus", "Line status"],
|
||||
["startDate", "Start date"], ["endDate", "End date"],
|
||||
["committedCredits", "Committed credits", true], ["consumed", "Consumed", true],
|
||||
["consumptionL30Days", "Consumption last 30 days", true], ["monthsRemaining", "Months remaining"],
|
||||
["projectedConsumption", "Projected consumption", true], ["projectedDelta", "Projected delta", true],
|
||||
["orderId", "Order number"]
|
||||
];
|
||||
function node(tag, className, text) {
|
||||
const result = document.createElement(tag);
|
||||
if (className) result.className = className;
|
||||
if (text !== undefined) result.textContent = text;
|
||||
return result;
|
||||
}
|
||||
function statusBadge(record, tag = "span") {
|
||||
const text = String(record.projectedStatus || "Unknown");
|
||||
const badge = node(tag, "opportunities-extension-projection-badge", text);
|
||||
badge.dataset.status = text.trim().toLowerCase();
|
||||
return badge;
|
||||
}
|
||||
function orderLink(record, text) {
|
||||
try {
|
||||
const url = new URL(record.orderTrackerURL);
|
||||
if (url.origin !== "https://accountdashboard.oracle.com" || url.username || url.password) return null;
|
||||
const link = node("a", "", text);
|
||||
link.href = url.href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
return link;
|
||||
} catch { return null; }
|
||||
}
|
||||
function createLineDetails(record) {
|
||||
const section = node("section", "opportunities-extension-projection-line");
|
||||
const heading = node("h3");
|
||||
heading.append(statusBadge(record));
|
||||
section.append(heading);
|
||||
const details = node("dl", "opportunities-extension-projection-fields");
|
||||
fields.forEach(([key, label, numeric]) => {
|
||||
const raw = record[key];
|
||||
let text = raw === null || raw === undefined || raw === "" ? "-" : String(raw);
|
||||
if (numeric && text !== "-" && Number.isFinite(Number(raw))) {
|
||||
text = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(raw));
|
||||
}
|
||||
const group = node("div");
|
||||
const value = node("dd");
|
||||
const link = key === "orderId" && text !== "-" ? orderLink(record, text) : null;
|
||||
if (link) value.append(link);
|
||||
else value.textContent = text;
|
||||
group.append(node("dt", "", label), value);
|
||||
details.append(group);
|
||||
});
|
||||
section.append(details);
|
||||
if (Array.isArray(record.clines) && record.clines.length) {
|
||||
const children = node("div", "opportunities-extension-projection-children");
|
||||
record.clines.forEach(child => children.append(createLineDetails(child)));
|
||||
section.append(children);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
function closePlanDetailPopover(restoreFocus = false) {
|
||||
if (!active) return;
|
||||
const previous = active;
|
||||
active = null;
|
||||
clearTimeout(previous.timer);
|
||||
previous.popover.hidden = true;
|
||||
previous.button.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("pointerdown", previous.outside, true);
|
||||
window.removeEventListener("resize", previous.resize);
|
||||
if (restoreFocus && previous.button.isConnected) {
|
||||
previous.suppressFocus = true;
|
||||
previous.button.focus();
|
||||
previous.suppressFocus = false;
|
||||
}
|
||||
}
|
||||
function positionPopover(entry) {
|
||||
const rect = entry.button.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.min(560, viewportWidth - 24);
|
||||
const height = Math.min(520, viewportHeight - 24);
|
||||
entry.popover.style.width = `${width}px`;
|
||||
entry.popover.style.maxHeight = `${height}px`;
|
||||
const below = viewportHeight - rect.bottom - 12;
|
||||
const actualHeight = Math.min(entry.popover.getBoundingClientRect().height, height);
|
||||
entry.popover.style.left = `${Math.max(12, Math.min(rect.left, viewportWidth - width - 12))}px`;
|
||||
entry.popover.style.top = `${Math.max(12, below >= actualHeight ? rect.bottom + 4 : Math.min(rect.top - actualHeight - 4, viewportHeight - actualHeight - 12))}px`;
|
||||
}
|
||||
function createInteractiveBadge(record) {
|
||||
const wrapper = node("div", "opportunities-extension-projection-wrapper");
|
||||
const button = statusBadge(record, "button");
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-label", `Projected status: ${button.textContent}. View plan details`);
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
const popover = node("div", "opportunities-extension-projection-popover");
|
||||
popover.id = `opportunities-extension-projection-${++sequence}`;
|
||||
popover.hidden = true;
|
||||
popover.setAttribute("role", "dialog");
|
||||
popover.setAttribute("aria-label", "Consumption plan details");
|
||||
popover.tabIndex = -1;
|
||||
button.setAttribute("aria-controls", popover.id);
|
||||
const dismiss = node("button", "opportunities-extension-projection-dismiss");
|
||||
dismiss.type = "button";
|
||||
dismiss.setAttribute("aria-label", "Close plan details");
|
||||
dismiss.title = "Close plan details";
|
||||
dismiss.append(createCloseIcon());
|
||||
dismiss.addEventListener("click", () => closePlanDetailPopover(true));
|
||||
popover.append(dismiss, createLineDetails(record));
|
||||
const entry = { button, popover, wrapper, pinned: false, timer: null, suppressFocus: false,
|
||||
outside: event => { if (!wrapper.contains(event.target)) closePlanDetailPopover(); },
|
||||
resize: () => positionPopover(entry) };
|
||||
function open() {
|
||||
if (active !== entry) {
|
||||
closePlanDetailPopover();
|
||||
entry.pinned = false;
|
||||
active = entry;
|
||||
document.addEventListener("pointerdown", entry.outside, true);
|
||||
window.addEventListener("resize", entry.resize);
|
||||
}
|
||||
clearTimeout(entry.timer);
|
||||
popover.hidden = false;
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
positionPopover(entry);
|
||||
}
|
||||
wrapper.addEventListener("pointerenter", open);
|
||||
wrapper.addEventListener("pointerleave", () => {
|
||||
if (entry.pinned || wrapper.contains(document.activeElement)) return;
|
||||
entry.timer = setTimeout(() => { if (active === entry) closePlanDetailPopover(); }, 180);
|
||||
});
|
||||
wrapper.addEventListener("focusin", () => { if (!entry.suppressFocus) open(); });
|
||||
wrapper.addEventListener("focusout", event => { if (!wrapper.contains(event.relatedTarget) && active === entry) closePlanDetailPopover(); });
|
||||
button.addEventListener("click", () => {
|
||||
if (active === entry && entry.pinned) closePlanDetailPopover();
|
||||
else { open(); entry.pinned = true; }
|
||||
});
|
||||
wrapper.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && active === entry) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closePlanDetailPopover(true);
|
||||
}
|
||||
});
|
||||
wrapper.append(button, popover);
|
||||
return wrapper;
|
||||
}
|
||||
function renderPlanDetailBadges(section, items) {
|
||||
if (active && section.contains(active.button)) closePlanDetailPopover();
|
||||
section.textContent = "";
|
||||
if (!items.length) {
|
||||
section.append(node("span", "opportunities-extension-projection-empty", "No projection data"));
|
||||
return;
|
||||
}
|
||||
items.forEach(record => section.append(createInteractiveBadge(record)));
|
||||
}
|
||||
return { renderPlanDetailBadges, createLineDetails, closePlanDetailPopover };
|
||||
}
|
||||
79
src/features/consumption/plan-detail.css
Normal file
79
src/features/consumption/plan-detail.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* Scope sizing to Cloud Consumption, not other dialogs sharing its components. */
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plans-header { padding: 10px 12px; gap: 12px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plans-header h2 { font-size: 16px; line-height: 22px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plans-header p { font-size: 12px; line-height: 16px; margin-top: 3px; overflow-wrap: anywhere; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-icon-button { width: 28px; height: 28px; min-width: 28px; min-height: 28px; padding: 4px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plans-tabs { padding: 4px 12px 0; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-tab { min-height: 30px; padding: 4px 8px; font-size: 12px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-panel { padding: 8px 12px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details { gap: 0 12px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details > div { grid-template-columns: minmax(85px, 35%) minmax(0, 1fr); min-height: 32px; padding: 4px 0; gap: 6px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details dt { font-size: 11px; line-height: 16px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details dd { font-size: 12px; line-height: 16px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-trend { margin-top: 8px; padding-top: 6px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-periods button { min-height: 28px; padding: 4px 8px; font-size: 12px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-navigation { grid-template-columns: 28px minmax(120px, auto) 28px; min-height: 30px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-navigation strong { font-size: 12px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-navigation-button { width: 28px; height: 28px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-visual { grid-template-columns: minmax(0, 1fr) 150px; gap: 8px; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-svg { height: 100%; max-height: 100%; min-width: 0; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-legend button { font-size: 11px; padding: 4px; gap: 5px; }
|
||||
|
||||
/* Small viewports must scroll rather than clip details or collapse the chart. */
|
||||
@media (max-height: 560px), (max-width: 700px) {
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plans-panels { overflow: auto; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-panel { height: auto; min-height: 100%; grid-template-rows: auto minmax(280px, 1fr); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details { grid-template-columns: minmax(0, 1fr); }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-visual { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
|
||||
[data-consumption-layout="compact"] .opportunities-extension-consumption-legend { display: flex; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
.opportunities-extension-plan-detail-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
.opportunities-extension-consumption-trend-header { align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.opportunities-extension-consumption-trend-header > .opportunities-extension-plan-detail-badges { min-width: 0; }
|
||||
.opportunities-extension-plan-detail-skeleton { width: 64px; height: 20px; }
|
||||
.opportunities-extension-projection-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center; border: 1px solid transparent;
|
||||
padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; line-height: 1.3;
|
||||
color: #5f5a55; background: #eceae8; white-space: nowrap;
|
||||
}
|
||||
button.opportunities-extension-projection-badge { cursor: pointer; }
|
||||
.opportunities-extension-projection-badge[data-status="forfeit"] { color: #a12424; background: #fbe5e5; }
|
||||
.opportunities-extension-projection-badge[data-status="overage"] { color: #185a9d; background: #e0edfc; }
|
||||
.opportunities-extension-projection-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-projection-popover {
|
||||
position: fixed; z-index: 8; box-sizing: border-box; padding: 14px; overflow: auto; overscroll-behavior: contain;
|
||||
border: 1px solid #b9b4ae; border-radius: 4px; background: #ffffff; color: #312d2a;
|
||||
box-shadow: 0 4px 16px #0003; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere;
|
||||
}
|
||||
.opportunities-extension-projection-popover[hidden] { display: none; }
|
||||
.opportunities-extension-projection-popover .opportunities-extension-projection-dismiss {
|
||||
display: grid; place-items: center; width: 24px; height: 24px; margin-left: auto; margin-bottom: 8px; padding: 4px; border: 1px solid #b9b4ae;
|
||||
border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.opportunities-extension-projection-dismiss svg { width: 14px; height: 14px; }
|
||||
.opportunities-extension-projection-line h3 { margin: 0 0 8px; font-size: 12px; }
|
||||
.opportunities-extension-projection-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; margin: 0; }
|
||||
.opportunities-extension-projection-fields > div { min-width: 0; }
|
||||
.opportunities-extension-projection-fields dt { font-weight: 600; color: #5f5a55; font-size: 11px; }
|
||||
.opportunities-extension-projection-fields dd { margin: 2px 0 0; font-variant-numeric: tabular-nums; }
|
||||
.opportunities-extension-projection-fields a { color: #006d7a; text-decoration: underline; }
|
||||
.opportunities-extension-projection-children { margin-top: 14px; padding-left: 12px; border-left: 2px solid #dedbd7; }
|
||||
.opportunities-extension-projection-children > section + section { margin-top: 16px; }
|
||||
.opportunities-extension-plan-detail-error { color: #a12424; font-size: 12px; }
|
||||
.opportunities-extension-projection-empty { color: #5f5a55; font-size: 12px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-popover { color: #f1efec; background: #302e2b; border-color: #716b64; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge { color: #dedbd7; background: #484541; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="forfeit"] { color: #ffb9b9; background: #682e32; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="overage"] { color: #c5dfff; background: #224b76; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields dt,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-empty { color: #cec9c2; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields a { color: #76dbea; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-plan-detail-error { color: #ffb9b9; }
|
||||
@media (max-width: 480px) { .opportunities-extension-projection-fields { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 480px) {
|
||||
.opportunities-extension-consumption-trend-header > .opportunities-extension-consumption-periods { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .opportunities-extension-plan-detail-skeleton { animation: none; } }
|
||||
96
src/features/consumption/plans-action.js
Normal file
96
src/features/consumption/plans-action.js
Normal file
@@ -0,0 +1,96 @@
|
||||
function createConsumptionPlansAction({
|
||||
messageTypes,
|
||||
appendBackgroundRequestLog,
|
||||
consumptionPlansState,
|
||||
getNestedValue,
|
||||
openAccountPlansModal,
|
||||
renderOpportunitiesTable,
|
||||
requestConsumerTokenServiceOnce,
|
||||
sendRuntimeMessage,
|
||||
showCopyConfirmation,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createCloudConsumptionButton(opportunity) {
|
||||
const button = document.createElement("button");
|
||||
const optyNumber = String(opportunity.OptyNumber || "");
|
||||
const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]);
|
||||
const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber);
|
||||
const isLoading = requestState && requestState.status === "loading";
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-action-button";
|
||||
button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`);
|
||||
button.title = isLoading ? "Loading customer consumption" : "View customer consumption";
|
||||
button.disabled = Boolean(isLoading);
|
||||
button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading));
|
||||
button.append(createCloudConsumptionIcon());
|
||||
button.addEventListener("click", () => {
|
||||
if (!partyNumber) {
|
||||
showCopyConfirmation("Customer Party Number is unavailable.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRequests = consumptionPlansState.accountPlansRequestState;
|
||||
currentRequests.set(optyNumber, { status: "loading" });
|
||||
openAccountPlansModal(opportunity, [], "loading");
|
||||
renderOpportunitiesTable();
|
||||
requestConsumerTokenServiceOnce()
|
||||
.then((authorizations) => sendRuntimeMessage({
|
||||
type: messageTypes.requestAccountPlans,
|
||||
partyNumber: String(partyNumber),
|
||||
authorizations
|
||||
}))
|
||||
.then((response) => {
|
||||
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||
appendBackgroundRequestLog(response && response.debugRequests);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(response && response.error ? response.error : "Unable to load cloud consumption.");
|
||||
}
|
||||
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||
status: "ready",
|
||||
plans: Array.isArray(response.plans) ? response.plans : []
|
||||
});
|
||||
renderOpportunitiesTable();
|
||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||
}).catch((error) => {
|
||||
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||
status: "error",
|
||||
error: errorMessage
|
||||
});
|
||||
renderOpportunitiesTable();
|
||||
openAccountPlansModal(opportunity, [], "error", errorMessage);
|
||||
showCopyConfirmation(errorMessage, true);
|
||||
});
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function createCloudConsumptionIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const usage = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z");
|
||||
usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9");
|
||||
|
||||
[cloud, usage].forEach((path) => {
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
path.setAttribute("stroke-width", "1.7");
|
||||
});
|
||||
svg.append(cloud, usage);
|
||||
return svg;
|
||||
}
|
||||
|
||||
return { createCloudConsumptionButton, createCloudConsumptionIcon };
|
||||
}
|
||||
5
src/features/consumption/plans-state.js
Normal file
5
src/features/consumption/plans-state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createConsumptionPlansState() {
|
||||
return {
|
||||
accountPlansRequestState: new Map()
|
||||
};
|
||||
}
|
||||
338
src/features/consumption/plans-view.js
Normal file
338
src/features/consumption/plans-view.js
Normal file
@@ -0,0 +1,338 @@
|
||||
function createConsumptionPlansView({
|
||||
ACCOUNT_PLANS_MODAL_ID,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
MODAL_ID,
|
||||
createConsumptionHeader,
|
||||
createConsumptionTrendSection,
|
||||
loadPlanDetailForPanel,
|
||||
closePlanDetailPopover,
|
||||
createRatecardButton,
|
||||
closeRatecardDrawer,
|
||||
createSvgElement,
|
||||
createPlanDetails,
|
||||
getNestedValue,
|
||||
loadConsumptionTrendForPanel,
|
||||
environment
|
||||
}) {
|
||||
const { document, requestAnimationFrame, setTimeout, window } = environment;
|
||||
function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") {
|
||||
const overlay = document.getElementById(MODAL_ID);
|
||||
const optyNumber = String(opportunity.OptyNumber || "");
|
||||
const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== "loading" && (
|
||||
!existingModal
|
||||
|| existingModal.hidden
|
||||
|| existingModal.dataset.optyNumber !== optyNumber
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closePlanDetailPopover();
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
existingModal?.remove();
|
||||
|
||||
const modal = document.createElement("section");
|
||||
modal.id = ACCOUNT_PLANS_MODAL_ID;
|
||||
modal.className = "opportunities-extension-account-plans-modal";
|
||||
modal.dataset.optyNumber = optyNumber;
|
||||
modal.dataset.customerLabel = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
|
||||
modal.setAttribute("aria-busy", String(state === "loading"));
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "opportunities-extension-account-plans-dialog";
|
||||
dialog.setAttribute("data-consumption-layout", "compact");
|
||||
|
||||
const header = createConsumptionHeader({
|
||||
titleId: "opportunities-extension-account-plans-title",
|
||||
customerLabel: modal.dataset.customerLabel,
|
||||
closeLabel: "Close cloud consumption",
|
||||
onClose: closeAccountPlansModal
|
||||
});
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "opportunities-extension-account-plans-content";
|
||||
|
||||
if (state === "loading") {
|
||||
content.append(createAccountPlansSkeleton());
|
||||
} else if (state === "error") {
|
||||
const error = document.createElement("p");
|
||||
error.className = "opportunities-extension-account-plans-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.textContent = errorMessage || "Unable to load cloud consumption.";
|
||||
content.append(error);
|
||||
} else if (!Array.isArray(plans) || plans.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-account-plans-empty";
|
||||
empty.textContent = "No account plans found for this customer.";
|
||||
content.append(empty);
|
||||
} else {
|
||||
const tabList = document.createElement("div");
|
||||
tabList.className = "opportunities-extension-account-plans-tabs";
|
||||
tabList.setAttribute("role", "tablist");
|
||||
tabList.setAttribute("aria-label", "Account plans");
|
||||
|
||||
const tabsNavigation = createAccountPlansTabsNavigation(tabList);
|
||||
|
||||
const panels = document.createElement("div");
|
||||
panels.className = "opportunities-extension-account-plans-panels";
|
||||
|
||||
plans.forEach((plan, index) => {
|
||||
const tabId = `opportunities-extension-account-plan-tab-${index}`;
|
||||
const panelId = `opportunities-extension-account-plan-panel-${index}`;
|
||||
const tab = document.createElement("button");
|
||||
tab.id = tabId;
|
||||
tab.type = "button";
|
||||
tab.className = "opportunities-extension-account-plan-tab";
|
||||
tab.setAttribute("role", "tab");
|
||||
tab.setAttribute("aria-controls", panelId);
|
||||
tab.setAttribute("aria-selected", String(index === 0));
|
||||
tab.tabIndex = index === 0 ? 0 : -1;
|
||||
const planLabel = [plan.planType, plan.subPlanNum]
|
||||
.filter((value) => value !== null && value !== undefined && value !== "")
|
||||
.join(" ") || `Plan ${index + 1}`;
|
||||
const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === ""
|
||||
? "Unknown"
|
||||
: String(plan.hdrStatusCode);
|
||||
tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`);
|
||||
tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel));
|
||||
|
||||
const panel = document.createElement("section");
|
||||
panel.id = panelId;
|
||||
panel.className = "opportunities-extension-account-plan-panel";
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
panel.setAttribute("aria-labelledby", tabId);
|
||||
panel.hidden = index !== 0;
|
||||
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
|
||||
|
||||
tab.addEventListener("click", () => {
|
||||
closePlanDetailPopover();
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
|
||||
const isSelected = candidate === tab;
|
||||
candidate.setAttribute("aria-selected", String(isSelected));
|
||||
candidate.tabIndex = isSelected ? 0 : -1;
|
||||
});
|
||||
panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => {
|
||||
candidate.hidden = candidate !== panel;
|
||||
});
|
||||
const trendSection = panel.querySelector(".opportunities-extension-consumption-trend");
|
||||
loadConsumptionTrendForPanel(
|
||||
panel,
|
||||
plan,
|
||||
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
|
||||
);
|
||||
loadPlanDetailForPanel(panel, plan);
|
||||
});
|
||||
tabList.append(tab);
|
||||
panels.append(panel);
|
||||
});
|
||||
|
||||
content.append(tabsNavigation, panels);
|
||||
}
|
||||
|
||||
dialog.append(header, content);
|
||||
modal.append(dialog);
|
||||
overlay.append(modal);
|
||||
modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus();
|
||||
|
||||
if (state === "ready" && Array.isArray(plans) && plans.length > 0) {
|
||||
const firstPanel = modal.querySelector("[role='tabpanel']");
|
||||
|
||||
if (firstPanel) {
|
||||
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
|
||||
loadPlanDetailForPanel(firstPanel, plans[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountPlansTabsNavigation(tabList) {
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "opportunities-extension-account-plans-tabs-navigation";
|
||||
|
||||
const previousButton = createAccountPlansTabNavigationButton("previous");
|
||||
const nextButton = createAccountPlansTabNavigationButton("next");
|
||||
const updateNavigation = () => {
|
||||
const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth);
|
||||
const tabs = Array.from(tabList.querySelectorAll("[role='tab']"));
|
||||
const viewportStart = tabList.scrollLeft;
|
||||
const viewportEnd = viewportStart + tabList.clientWidth;
|
||||
const hasContentBefore = tabList.scrollLeft > 1
|
||||
|| tabs.some((tab) => tab.offsetLeft < viewportStart - 1);
|
||||
const hasContentAfter = tabList.scrollLeft < maximumScroll - 1
|
||||
|| tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1);
|
||||
previousButton.hidden = !hasContentBefore;
|
||||
nextButton.hidden = !hasContentAfter;
|
||||
};
|
||||
const scrollTabs = (direction) => {
|
||||
tabList.scrollBy({
|
||||
left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)),
|
||||
behavior: "smooth"
|
||||
});
|
||||
};
|
||||
|
||||
previousButton.addEventListener("click", () => scrollTabs(-1));
|
||||
nextButton.addEventListener("click", () => scrollTabs(1));
|
||||
tabList.addEventListener("scroll", updateNavigation, { passive: true });
|
||||
const handleResize = () => {
|
||||
if (!navigation.isConnected) {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
return;
|
||||
}
|
||||
updateNavigation();
|
||||
};
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
requestAnimationFrame(() => requestAnimationFrame(updateNavigation));
|
||||
setTimeout(updateNavigation, 0);
|
||||
|
||||
navigation.append(previousButton, tabList, nextButton);
|
||||
return navigation;
|
||||
}
|
||||
|
||||
function createAccountPlansTabNavigationButton(direction) {
|
||||
const isPrevious = direction === "previous";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-account-plans-tab-navigation-button";
|
||||
button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans");
|
||||
button.title = isPrevious ? "Previous plans" : "Next plans";
|
||||
|
||||
const icon = createSvgElement("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
"aria-hidden": "true",
|
||||
focusable: "false"
|
||||
});
|
||||
icon.append(createSvgElement("path", {
|
||||
d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
}));
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createAccountPlansSkeleton() {
|
||||
const skeleton = document.createElement("div");
|
||||
skeleton.className = "opportunities-extension-account-plans-skeleton";
|
||||
skeleton.setAttribute("role", "status");
|
||||
|
||||
const announcement = document.createElement("span");
|
||||
announcement.className = "opportunities-extension-visually-hidden";
|
||||
announcement.textContent = "Loading cloud consumption";
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "opportunities-extension-account-plans-skeleton-tabs";
|
||||
const tab = document.createElement("span");
|
||||
tab.className = "opportunities-extension-account-plans-skeleton-tab";
|
||||
const status = document.createElement("span");
|
||||
status.className = "opportunities-extension-account-plans-skeleton-status";
|
||||
const tabLabel = document.createElement("span");
|
||||
tabLabel.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
tabLabel.style.width = "104px";
|
||||
tab.append(status, tabLabel);
|
||||
tabs.append(tab);
|
||||
|
||||
const details = document.createElement("div");
|
||||
details.className = "opportunities-extension-account-plans-skeleton-details";
|
||||
[72, 88, 64, 78, 58, 68].forEach((width) => {
|
||||
const row = document.createElement("div");
|
||||
const label = document.createElement("span");
|
||||
const value = document.createElement("span");
|
||||
label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label";
|
||||
value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value";
|
||||
value.style.width = `${width}%`;
|
||||
row.append(label, value);
|
||||
details.append(row);
|
||||
});
|
||||
|
||||
const trend = document.createElement("div");
|
||||
trend.className = "opportunities-extension-account-plans-skeleton-trend";
|
||||
|
||||
const trendHeader = document.createElement("div");
|
||||
trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header";
|
||||
const periods = document.createElement("div");
|
||||
periods.className = "opportunities-extension-account-plans-skeleton-periods";
|
||||
[58, 52, 56, 38].forEach((width, index) => {
|
||||
const period = document.createElement("span");
|
||||
period.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
period.style.width = `${width}px`;
|
||||
if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active");
|
||||
periods.append(period);
|
||||
});
|
||||
trendHeader.append(periods);
|
||||
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "opportunities-extension-account-plans-skeleton-navigation";
|
||||
[32, 124, 32].forEach((width) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
item.style.width = `${width}px`;
|
||||
navigation.append(item);
|
||||
});
|
||||
|
||||
const chart = document.createElement("div");
|
||||
chart.className = "opportunities-extension-account-plans-skeleton-chart";
|
||||
const plot = document.createElement("div");
|
||||
plot.className = "opportunities-extension-account-plans-skeleton-plot";
|
||||
[34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => {
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
bar.style.height = `${height}%`;
|
||||
plot.append(bar);
|
||||
});
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "opportunities-extension-account-plans-skeleton-legend";
|
||||
[112, 68, 132].forEach((width) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
item.style.width = `${width}px`;
|
||||
legend.append(item);
|
||||
});
|
||||
chart.append(plot, legend);
|
||||
trend.append(trendHeader, navigation, chart);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel";
|
||||
panel.append(details, trend);
|
||||
|
||||
skeleton.append(announcement, tabs, panel);
|
||||
return skeleton;
|
||||
}
|
||||
|
||||
function createAccountPlanStatusIndicator(statusValue) {
|
||||
const indicator = document.createElement("span");
|
||||
const isActive = String(statusValue).toUpperCase() === "ACTIVE";
|
||||
|
||||
indicator.className = "opportunities-extension-account-plan-status-indicator";
|
||||
indicator.dataset.active = String(isActive);
|
||||
indicator.setAttribute("aria-hidden", "true");
|
||||
indicator.title = `Header Status: ${statusValue}`;
|
||||
return indicator;
|
||||
}
|
||||
|
||||
function createAccountPlanDetails(plan) {
|
||||
return createPlanDetails(plan, { contractAction: createRatecardButton(plan) });
|
||||
}
|
||||
|
||||
function closeAccountPlansModal() {
|
||||
closePlanDetailPopover();
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal };
|
||||
}
|
||||
172
src/features/consumption/trend-controller.js
Normal file
172
src/features/consumption/trend-controller.js
Normal file
@@ -0,0 +1,172 @@
|
||||
function createConsumptionTrendController({
|
||||
messageTypes,
|
||||
CONSUMPTION_TREND_PERIODS,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
appendBackgroundRequestLog,
|
||||
consumptionTrendState,
|
||||
createPlanDetailSection,
|
||||
renderConsumptionTrendChart,
|
||||
requestConsumerTokenServiceOnce,
|
||||
sendRuntimeMessage,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createConsumptionTrendSection(plan) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "opportunities-extension-consumption-trend";
|
||||
section.dataset.subPlanNum = String(plan.subPlanNum || "");
|
||||
section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD;
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-consumption-trend-header";
|
||||
|
||||
const periodControls = document.createElement("div");
|
||||
periodControls.className = "opportunities-extension-consumption-periods";
|
||||
periodControls.setAttribute("role", "group");
|
||||
periodControls.setAttribute("aria-label", "Consumption period");
|
||||
|
||||
CONSUMPTION_TREND_PERIODS.forEach((periodOption) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.dataset.period = periodOption.value;
|
||||
button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD));
|
||||
button.textContent = periodOption.label;
|
||||
button.addEventListener("click", () => {
|
||||
section.dataset.period = periodOption.value;
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(
|
||||
createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value),
|
||||
0
|
||||
);
|
||||
loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value);
|
||||
});
|
||||
periodControls.append(button);
|
||||
});
|
||||
|
||||
const chart = document.createElement("div");
|
||||
chart.className = "opportunities-extension-consumption-chart";
|
||||
chart.setAttribute("aria-live", "polite");
|
||||
header.append(createPlanDetailSection(plan), periodControls);
|
||||
section.append(header, chart);
|
||||
return section;
|
||||
}
|
||||
|
||||
function loadConsumptionTrendForPanel(panel, plan, period) {
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = panel.querySelector(".opportunities-extension-consumption-trend");
|
||||
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subPlanNum = String(plan.subPlanNum || "").trim();
|
||||
const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period)
|
||||
? period
|
||||
: DEFAULT_CONSUMPTION_TREND_PERIOD;
|
||||
const key = createConsumptionTrendKey(subPlanNum, selectedPeriod);
|
||||
const chart = section.querySelector(".opportunities-extension-consumption-chart");
|
||||
section.dataset.period = selectedPeriod;
|
||||
chart.dataset.requestKey = key;
|
||||
updateConsumptionPeriodButtons(section, selectedPeriod);
|
||||
|
||||
if (!subPlanNum) {
|
||||
renderConsumptionTrendError(chart, "Sub plan number is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = consumptionTrendState.consumptionTrendCache.get(key);
|
||||
|
||||
renderConsumptionTrendSkeleton(chart);
|
||||
|
||||
if (cached && cached.status === "loading") {
|
||||
cached.promise
|
||||
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
|
||||
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = requestConsumerTokenServiceOnce()
|
||||
.then((authorizations) => sendRuntimeMessage({
|
||||
type: messageTypes.requestConsumptionTrend,
|
||||
subPlanNum,
|
||||
period: selectedPeriod,
|
||||
authorization: authorizations && authorizations.accountPlans
|
||||
}))
|
||||
.then((response) => {
|
||||
appendBackgroundRequestLog(response && response.debugRequests);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(response && response.error ? response.error : "Unable to load consumption trend.");
|
||||
}
|
||||
|
||||
return Array.isArray(response.items) ? response.items : [];
|
||||
})
|
||||
.finally(() => {
|
||||
if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) {
|
||||
consumptionTrendState.consumptionTrendCache.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request });
|
||||
request
|
||||
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
|
||||
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
|
||||
}
|
||||
|
||||
function createConsumptionTrendKey(subPlanNum, period) {
|
||||
return `${subPlanNum}:${period}`;
|
||||
}
|
||||
|
||||
function updateConsumptionPeriodButtons(section, period) {
|
||||
section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.period === period));
|
||||
});
|
||||
}
|
||||
|
||||
function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) {
|
||||
if (chart.dataset.requestKey === key) {
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
}
|
||||
}
|
||||
|
||||
function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) {
|
||||
if (chart.dataset.requestKey === key) {
|
||||
renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderConsumptionTrendSkeleton(chart) {
|
||||
chart.textContent = "";
|
||||
chart.setAttribute("aria-busy", "true");
|
||||
const skeleton = document.createElement("div");
|
||||
skeleton.className = "opportunities-extension-consumption-chart-skeleton";
|
||||
const announcement = document.createElement("span");
|
||||
announcement.className = "opportunities-extension-visually-hidden";
|
||||
announcement.textContent = "Loading consumption trend";
|
||||
const plot = document.createElement("div");
|
||||
plot.className = "opportunities-extension-consumption-chart-skeleton-plot";
|
||||
|
||||
for (let index = 0; index < 13; index += 1) {
|
||||
const bar = document.createElement("span");
|
||||
bar.style.height = `${28 + ((index * 17) % 58)}%`;
|
||||
plot.append(bar);
|
||||
}
|
||||
|
||||
skeleton.append(announcement, plot);
|
||||
chart.append(skeleton);
|
||||
}
|
||||
|
||||
function renderConsumptionTrendError(chart, errorMessage) {
|
||||
chart.textContent = "";
|
||||
chart.setAttribute("aria-busy", "false");
|
||||
const error = document.createElement("p");
|
||||
error.className = "opportunities-extension-consumption-chart-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.textContent = errorMessage;
|
||||
chart.append(error);
|
||||
}
|
||||
|
||||
return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError };
|
||||
}
|
||||
7
src/features/consumption/trend-state.js
Normal file
7
src/features/consumption/trend-state.js
Normal file
@@ -0,0 +1,7 @@
|
||||
function createConsumptionTrendState() {
|
||||
return {
|
||||
consumptionTrendCache: new Map(),
|
||||
consumptionTrendWindowOffsets: new Map(),
|
||||
consumptionTrendSeriesFilters: new Map()
|
||||
};
|
||||
}
|
||||
208
src/features/debug/content-log.js
Normal file
208
src/features/debug/content-log.js
Normal file
@@ -0,0 +1,208 @@
|
||||
function createDebugContentLog({
|
||||
previewDebugBody,
|
||||
debugState,
|
||||
renderDebugPanel
|
||||
}) {
|
||||
function logRequestStart(details) {
|
||||
const entry = {
|
||||
id: debugState.requestLog.length + 1,
|
||||
name: details.name,
|
||||
method: details.method,
|
||||
url: details.url,
|
||||
headers: details.headers || {},
|
||||
status: "pending",
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
httpStatus: null,
|
||||
statusText: "",
|
||||
requestHeaders: details.headers || {},
|
||||
requestBody: details.body || "",
|
||||
metadata: details.metadata || {},
|
||||
responseHeaders: {},
|
||||
responseBody: "",
|
||||
responseBodyTruncated: false,
|
||||
error: ""
|
||||
};
|
||||
|
||||
debugState.requestLog.push(entry);
|
||||
debugState.persist(entry);
|
||||
renderDebugPanel();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function logRequestSuccess(entry, response, responseBody) {
|
||||
if (!debugState.requestLog.includes(entry)) return;
|
||||
entry.status = response.ok ? "success" : "failed";
|
||||
entry.completedAt = new Date();
|
||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||
entry.httpStatus = response.status;
|
||||
entry.statusText = response.statusText || "";
|
||||
entry.responseHeaders = headersToObject(response.headers);
|
||||
entry.responseBody = responseBody.value;
|
||||
entry.responseBodyTruncated = responseBody.truncated;
|
||||
debugState.persist(entry);
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
function logRequestFailure(entry, errorMessage) {
|
||||
if (!debugState.requestLog.includes(entry)) return;
|
||||
entry.status = "failed";
|
||||
entry.completedAt = new Date();
|
||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||
entry.error = errorMessage;
|
||||
debugState.persist(entry);
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
function appendBackgroundRequestLog(entries) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const existing = entry.debugKey && debugState.requestLog.find(item => item.debugKey === entry.debugKey);
|
||||
if (existing && existing.status !== "pending" && entry.status === "pending") return;
|
||||
const normalized = {
|
||||
id: existing ? existing.id : debugState.requestLog.length + 1,
|
||||
debugKey: entry.debugKey,
|
||||
name: entry.name || "backgroundRequest",
|
||||
method: entry.method || "GET",
|
||||
url: entry.url || "",
|
||||
headers: entry.requestHeaders || {},
|
||||
status: entry.status || "failed",
|
||||
startedAt: new Date(entry.startedAt),
|
||||
completedAt: entry.completedAt ? new Date(entry.completedAt) : null,
|
||||
durationMs: entry.durationMs,
|
||||
httpStatus: entry.httpStatus,
|
||||
statusText: entry.statusText || "",
|
||||
requestHeaders: entry.requestHeaders || {},
|
||||
requestBody: entry.requestBody || "",
|
||||
metadata: entry.metadata || { executionContext: "background" },
|
||||
responseHeaders: entry.responseHeaders || {},
|
||||
responseBody: entry.responseBody || "",
|
||||
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
|
||||
error: entry.error || ""
|
||||
};
|
||||
if (existing) Object.assign(existing, normalized);
|
||||
else debugState.requestLog.push(normalized);
|
||||
debugState.persist(existing || normalized);
|
||||
});
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
async function readResponseBodyForDebug(response) {
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
return createDebugBody(text);
|
||||
} catch (error) {
|
||||
return {
|
||||
value: `Unable to read response body: ${error.message || "unknown error"}`,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createDebugBody(text) {
|
||||
return previewDebugBody(text);
|
||||
}
|
||||
|
||||
function headersToObject(headers) {
|
||||
const headerMap = {};
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
headerMap[key] = value;
|
||||
});
|
||||
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
function formatHeaderBlock(headers) {
|
||||
const entries = Object.entries(headers || {});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key)
|
||||
? value
|
||||
: formatHiddenDebugValue(value)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatDebugBody(body, requestName = "") {
|
||||
const text = body || "(none)";
|
||||
try {
|
||||
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
|
||||
.replace(/^\)\]\}',?\s*/, "")
|
||||
.replace(/^while\s*\(1\);\s*/, "");
|
||||
let value = JSON.parse(normalized);
|
||||
// Some services serialize their JSON document inside a JSON string.
|
||||
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
|
||||
try { value = JSON.parse(value); } catch { break; }
|
||||
}
|
||||
if (!debugState.debugSecretsVisible && requestName === "requestSrTokenCatalog") {
|
||||
value = value && typeof value === "object" && !Array.isArray(value)
|
||||
? { ...value, ...(Object.hasOwn(value, "result") ? { result: formatHiddenDebugValue(value.result) } : {}) }
|
||||
: formatHiddenDebugValue(value);
|
||||
}
|
||||
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
|
||||
} catch (error) {
|
||||
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function maskSensitiveDebugValue(value, parentKey = "") {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => maskSensitiveDebugValue(item, parentKey));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
||||
const isSensitive = isSensitiveDebugKey(key)
|
||||
|| (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey));
|
||||
return [key, isSensitive
|
||||
? formatHiddenDebugValue(child)
|
||||
: maskSensitiveDebugValue(child, key)];
|
||||
}));
|
||||
}
|
||||
|
||||
function isSensitiveDebugKey(key) {
|
||||
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$|x-appbuilder-client-id/i.test(key);
|
||||
}
|
||||
|
||||
function formatHiddenDebugValue(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "(missing)";
|
||||
}
|
||||
|
||||
return `(present, ${String(value).length} characters)`;
|
||||
}
|
||||
|
||||
function formatMetadataBlock(metadata) {
|
||||
const entries = Object.entries(metadata || {});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${value ?? "(none)"}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function maskToken(token) {
|
||||
if (token.length <= 10) {
|
||||
return "(present)";
|
||||
}
|
||||
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken };
|
||||
}
|
||||
382
src/features/debug/content-view.js
Normal file
382
src/features/debug/content-view.js
Normal file
@@ -0,0 +1,382 @@
|
||||
function createDebugContentView({
|
||||
DEBUG_PANEL_ID,
|
||||
MODAL_ID,
|
||||
debugState,
|
||||
formatDebugBody,
|
||||
formatHeaderBlock,
|
||||
formatMetadataBlock,
|
||||
createCloseIcon,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
const categories = [
|
||||
["all", "All"], ["authentication", "Authentication"],
|
||||
["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
|
||||
["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
|
||||
];
|
||||
let selectedCategory = "all";
|
||||
const expandedIds = new Set();
|
||||
let returnFocus = null;
|
||||
let generation = -1;
|
||||
|
||||
function getRequestCategory(entry) {
|
||||
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||
if (["requestOpportunities", "requestOpportunitiesSavedSearch", "requestOpportunitiesByContact", "resolveContactForOpportunities"].includes(entry.name)) return "opportunities";
|
||||
if (["requestQueryAccountContact", "requestPersistContact"].includes(entry.name)) return "contacts";
|
||||
if (entry.name === "requestOpptyProducts") return "products";
|
||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getChronologicalRequests() {
|
||||
return [...debugState.requestLog].sort((a, b) =>
|
||||
(new Date(a.startedAt).getTime() || 0) - (new Date(b.startedAt).getTime() || 0) || a.id - b.id);
|
||||
}
|
||||
function toggleDebugPanel() {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (!modal || modal.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!panel.hidden) {
|
||||
closeDebugPanel();
|
||||
return;
|
||||
}
|
||||
debugState.reload();
|
||||
returnFocus = document.activeElement;
|
||||
// Escape nested stacking contexts and inert containers created by child modals.
|
||||
modal.append(panel);
|
||||
panel.inert = false;
|
||||
panel.hidden = false;
|
||||
renderDebugPanel();
|
||||
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closeDebugPanel() {
|
||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||
if (!panel) return;
|
||||
panel.hidden = true;
|
||||
panel.textContent = "";
|
||||
panel.onkeydown = null;
|
||||
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
||||
returnFocus = null;
|
||||
}
|
||||
|
||||
function renderDebugPanel() {
|
||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||
|
||||
if (!panel || panel.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation !== debugState.generation) { expandedIds.clear(); selectedCategory = "all"; generation = debugState.generation; }
|
||||
for (const item of panel.querySelectorAll("details[data-request-id]")) {
|
||||
const id = item.getAttribute("data-request-id");
|
||||
if (item.open) expandedIds.add(id);
|
||||
else expandedIds.delete(id);
|
||||
}
|
||||
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category");
|
||||
const focusedControl = document.activeElement && document.activeElement.getAttribute("data-debug-focus");
|
||||
const listScrollTop = panel.querySelectorAll(".opportunities-extension-debug-list")[0]?.scrollTop || 0;
|
||||
const scrollTop = panel.scrollTop;
|
||||
panel.textContent = "";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
panel.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-title`);
|
||||
const focusTargets = new Map();
|
||||
|
||||
const panelHeader = document.createElement("header");
|
||||
panelHeader.className = "opportunities-extension-debug-panel-header";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
const title = document.createElement("h2");
|
||||
title.id = `${DEBUG_PANEL_ID}-title`;
|
||||
title.textContent = "Debug Requests";
|
||||
|
||||
const summary = document.createElement("p");
|
||||
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} in the current query history.`;
|
||||
if (debugState.storageError) summary.textContent += ` ${debugState.storageError}`;
|
||||
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
|
||||
if (running) summary.textContent += ` ${running} running.`;
|
||||
heading.append(title, summary);
|
||||
|
||||
const visibilityButton = document.createElement("button");
|
||||
visibilityButton.type = "button";
|
||||
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
|
||||
visibilityButton.setAttribute("data-debug-focus", "visibility");
|
||||
focusTargets.set("visibility", visibilityButton);
|
||||
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
|
||||
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
|
||||
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
|
||||
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
|
||||
visibilityButton.addEventListener("click", () => {
|
||||
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
|
||||
renderDebugPanel();
|
||||
});
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "opportunities-extension-icon-button opportunities-extension-debug-close-button";
|
||||
closeButton.title = "Close debug";
|
||||
closeButton.setAttribute("aria-label", "Close debug");
|
||||
closeButton.setAttribute("data-debug-focus", "close");
|
||||
closeButton.append(createCloseIcon());
|
||||
closeButton.addEventListener("click", closeDebugPanel);
|
||||
focusTargets.set("close", closeButton);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "opportunities-extension-debug-actions";
|
||||
actions.append(visibilityButton, closeButton);
|
||||
panelHeader.append(heading, actions);
|
||||
|
||||
const entries = getChronologicalRequests();
|
||||
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key));
|
||||
if (!availableCategories.some(([key]) => key === selectedCategory)) selectedCategory = "all";
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "opportunities-extension-debug-tabs";
|
||||
tabs.setAttribute("role", "tablist");
|
||||
tabs.setAttribute("aria-label", "Request categories");
|
||||
const tabButtons = [];
|
||||
availableCategories.forEach(([key, label], index) => {
|
||||
const categoryEntries = key === "all" ? entries : entries.filter(entry => getRequestCategory(entry) === key);
|
||||
const pending = categoryEntries.filter(entry => entry.status === "pending").length;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.id = `${DEBUG_PANEL_ID}-tab-${key}`;
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("data-debug-category", key);
|
||||
button.setAttribute("aria-selected", String(key === selectedCategory));
|
||||
button.setAttribute("aria-controls", `${DEBUG_PANEL_ID}-requests`);
|
||||
button.tabIndex = key === selectedCategory ? 0 : -1;
|
||||
button.textContent = `${label} (${categoryEntries.length})${pending ? ` / ${pending} running` : ""}`;
|
||||
button.addEventListener("click", () => {
|
||||
selectedCategory = key;
|
||||
renderDebugPanel();
|
||||
});
|
||||
button.addEventListener("keydown", event => {
|
||||
let nextIndex;
|
||||
if (event.key === "ArrowRight") nextIndex = (index + 1) % availableCategories.length;
|
||||
else if (event.key === "ArrowLeft") nextIndex = (index + availableCategories.length - 1) % availableCategories.length;
|
||||
else if (event.key === "Home") nextIndex = 0;
|
||||
else if (event.key === "End") nextIndex = availableCategories.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
selectedCategory = availableCategories[nextIndex][0];
|
||||
renderDebugPanel();
|
||||
});
|
||||
tabs.append(button);
|
||||
tabButtons.push(button);
|
||||
});
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "opportunities-extension-debug-list";
|
||||
list.id = `${DEBUG_PANEL_ID}-requests`;
|
||||
list.setAttribute("role", "tabpanel");
|
||||
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
|
||||
list.tabIndex = 0;
|
||||
list.setAttribute("data-debug-focus", "list");
|
||||
focusTargets.set("list", list);
|
||||
const summaries = [];
|
||||
|
||||
if (debugState.requestLog.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "opportunities-extension-debug-empty";
|
||||
empty.textContent = "No requests recorded yet.";
|
||||
list.append(empty);
|
||||
}
|
||||
|
||||
entries.filter(entry => selectedCategory === "all" || getRequestCategory(entry) === selectedCategory).forEach((entry) => {
|
||||
const item = document.createElement("details");
|
||||
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
|
||||
item.setAttribute("data-request-id", String(entry.id));
|
||||
item.open = expandedIds.has(String(entry.id));
|
||||
|
||||
const heading = document.createElement("summary");
|
||||
heading.className = "opportunities-extension-debug-heading";
|
||||
heading.setAttribute("data-debug-focus", `request-${entry.id}`);
|
||||
focusTargets.set(`request-${entry.id}`, heading);
|
||||
summaries.push(heading);
|
||||
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = `${entry.id}. ${entry.name}`;
|
||||
if (entry.name === "requestOpptyProducts" && entry.metadata.optyNumber) {
|
||||
name.textContent += ` (${entry.metadata.optyNumber}, offset ${entry.metadata.offset})`;
|
||||
}
|
||||
|
||||
const httpStatus = document.createElement("span");
|
||||
httpStatus.className = "opportunities-extension-debug-http-status";
|
||||
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
|
||||
httpStatus.setAttribute("data-http-class", entry.httpStatus ? String(Math.floor(entry.httpStatus / 100)) : "pending");
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.className = "opportunities-extension-debug-status";
|
||||
status.textContent = entry.status === "pending" ? "running" : entry.status;
|
||||
|
||||
const summaryMeta = document.createElement("span");
|
||||
summaryMeta.className = "opportunities-extension-debug-summary-meta";
|
||||
const startedTime = document.createElement("time");
|
||||
startedTime.className = "opportunities-extension-debug-time";
|
||||
startedTime.textContent = formatDebugTime(entry.startedAt);
|
||||
startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
|
||||
const method = document.createElement("span");
|
||||
method.className = "opportunities-extension-debug-method";
|
||||
method.textContent = entry.method || "GET";
|
||||
method.setAttribute("data-method", method.textContent.toUpperCase());
|
||||
const duration = document.createElement("span");
|
||||
duration.className = "opportunities-extension-debug-duration";
|
||||
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
|
||||
duration.textContent = hasDuration
|
||||
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
|
||||
: entry.status === "pending" ? "In progress" : "N/A";
|
||||
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
|
||||
summaryMeta.append(startedTime, method, httpStatus, status, duration);
|
||||
|
||||
heading.append(name, summaryMeta);
|
||||
|
||||
item.append(heading);
|
||||
const renderDetails = () => {
|
||||
if (!item.open || item.children.length > 1) return;
|
||||
const lines = [
|
||||
`${entry.method} ${entry.url}`,
|
||||
`Started: ${formatDebugTime(entry.startedAt)}`,
|
||||
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
|
||||
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
|
||||
entry.error ? `Error: ${entry.error}` : "",
|
||||
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
|
||||
].filter(Boolean);
|
||||
|
||||
const details = document.createElement("div");
|
||||
details.className = "opportunities-extension-debug-details";
|
||||
appendDebugBlock(details, "Request", lines.join("\n"));
|
||||
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
|
||||
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
||||
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
||||
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
||||
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
|
||||
|
||||
item.append(details);
|
||||
|
||||
};
|
||||
item.addEventListener("toggle", () => {
|
||||
if (item.open) { expandedIds.add(String(entry.id)); renderDetails(); }
|
||||
else {
|
||||
expandedIds.delete(String(entry.id));
|
||||
item.querySelector(".opportunities-extension-debug-details")?.remove();
|
||||
}
|
||||
});
|
||||
renderDetails();
|
||||
list.append(item);
|
||||
});
|
||||
|
||||
panel.append(panelHeader, tabs, list);
|
||||
panel.onkeydown = event => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDebugPanel();
|
||||
} else if (event.key === "Tab") {
|
||||
const controls = [visibilityButton, closeButton, ...tabButtons.filter(button => button.tabIndex === 0), list, ...summaries];
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
|
||||
event.preventDefault();
|
||||
controls[event.shiftKey ? controls.length - 1 : 0].focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (focusedCategory) tabButtons.find(button => button.getAttribute("data-debug-category") === selectedCategory).focus({ preventScroll: true });
|
||||
else if (focusedControl) focusTargets.get(focusedControl)?.focus({ preventScroll: true });
|
||||
list.scrollTop = listScrollTop;
|
||||
panel.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
function createDebugVisibilityIcon(secretsVisible) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
|
||||
pupil.setAttribute("cx", "12");
|
||||
pupil.setAttribute("cy", "12");
|
||||
pupil.setAttribute("r", "2.75");
|
||||
[eye, pupil].forEach((shape) => {
|
||||
shape.setAttribute("fill", "none");
|
||||
shape.setAttribute("stroke", "currentColor");
|
||||
shape.setAttribute("stroke-linecap", "round");
|
||||
shape.setAttribute("stroke-linejoin", "round");
|
||||
shape.setAttribute("stroke-width", "1.7");
|
||||
});
|
||||
svg.append(eye, pupil);
|
||||
|
||||
if (secretsVisible) {
|
||||
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
slash.setAttribute("d", "M4 4l16 16");
|
||||
slash.setAttribute("fill", "none");
|
||||
slash.setAttribute("stroke", "currentColor");
|
||||
slash.setAttribute("stroke-linecap", "round");
|
||||
slash.setAttribute("stroke-width", "1.9");
|
||||
svg.append(slash);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function appendDebugBlock(container, label, value) {
|
||||
const block = document.createElement("section");
|
||||
block.className = "opportunities-extension-debug-block";
|
||||
block.setAttribute("data-direction", label.startsWith("Response") ? "response" : "request");
|
||||
const heading = document.createElement("h3");
|
||||
const content = document.createElement("pre");
|
||||
|
||||
heading.textContent = label;
|
||||
appendJsonTokens(content, value);
|
||||
block.append(heading, content);
|
||||
container.append(block);
|
||||
}
|
||||
|
||||
function appendJsonTokens(content, value) {
|
||||
const text = String(value ?? "");
|
||||
try { JSON.parse(text); } catch { content.textContent = text; return; }
|
||||
// Highlight only already-masked display text; never interpret response content as HTML.
|
||||
const tokens = [...text.matchAll(/"(?:[^"\\]|\\.)*"\s*:|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g)];
|
||||
let offset = 0;
|
||||
const append = (textValue, kind) => {
|
||||
if (!textValue) return;
|
||||
const span = document.createElement("span");
|
||||
span.textContent = textValue;
|
||||
if (kind) span.className = `opportunities-extension-debug-json-${kind}`;
|
||||
content.append(span);
|
||||
};
|
||||
tokens.forEach(token => {
|
||||
append(text.slice(offset, token.index));
|
||||
const value = token[0];
|
||||
const kind = value.startsWith('"') ? value.endsWith(":") ? "key" : "string"
|
||||
: /^(true|false|null)$/.test(value) ? "literal" : "number";
|
||||
append(value, kind);
|
||||
offset = token.index + value.length;
|
||||
});
|
||||
append(text.slice(offset));
|
||||
}
|
||||
|
||||
function formatDebugTime(date) {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
return { toggleDebugPanel, closeDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
|
||||
}
|
||||
87
src/features/debug/state.js
Normal file
87
src/features/debug/state.js
Normal file
@@ -0,0 +1,87 @@
|
||||
function createDebugState(environment = {}) {
|
||||
let storage;
|
||||
let tabId;
|
||||
try {
|
||||
storage = environment.localStorage;
|
||||
const session = environment.window?.sessionStorage;
|
||||
tabId = session?.getItem("opportunities-extension-debug-tab");
|
||||
if (!tabId) {
|
||||
tabId = environment.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
session?.setItem("opportunities-extension-debug-tab", tabId);
|
||||
}
|
||||
} catch { storage = null; }
|
||||
const prefix = `opportunities-extension-debug:${tabId || "memory"}:`;
|
||||
let timer = null;
|
||||
const pending = new Map();
|
||||
const state = {
|
||||
requestLog: [],
|
||||
debugSecretsVisible: false,
|
||||
storageError: "",
|
||||
generation: 0,
|
||||
persist,
|
||||
flush,
|
||||
reload,
|
||||
reset
|
||||
};
|
||||
function persist(entry) {
|
||||
if (!state.requestLog.includes(entry)) return;
|
||||
pending.set(entry.id, entry);
|
||||
try {
|
||||
if (timer === null && environment.setTimeout) timer = environment.setTimeout(flush, 100);
|
||||
else if (!environment.setTimeout) flush();
|
||||
} catch {
|
||||
// Debug persistence must never interrupt the request being recorded.
|
||||
timer = null;
|
||||
flush();
|
||||
}
|
||||
}
|
||||
function flush() {
|
||||
if (timer !== null) environment.clearTimeout?.(timer);
|
||||
timer = null;
|
||||
if (!storage) { pending.clear(); return; }
|
||||
try {
|
||||
// Write only changed records, never stringify all response bodies per request.
|
||||
for (const [id, entry] of pending) storage.setItem(`${prefix}${id}`, JSON.stringify(entry));
|
||||
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: state.requestLog.length }));
|
||||
state.storageError = "";
|
||||
} catch {
|
||||
state.storageError = "Local storage is unavailable or full. Requests remain available in memory for this session.";
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
function reload() {
|
||||
flush();
|
||||
if (!storage || state.storageError) return;
|
||||
try {
|
||||
const index = JSON.parse(storage.getItem(`${prefix}index`) || "null");
|
||||
if (!index || index.version !== 1 || index.count !== state.requestLog.length) return;
|
||||
const records = state.requestLog.map(entry => {
|
||||
const saved = JSON.parse(storage.getItem(`${prefix}${entry.id}`) || "null");
|
||||
if (!saved || saved.id !== entry.id) throw new Error("Incomplete debug record");
|
||||
return { ...saved, startedAt: new Date(saved.startedAt), completedAt: saved.completedAt ? new Date(saved.completedAt) : null };
|
||||
});
|
||||
// Preserve the handles held by requests which are still in flight.
|
||||
records.forEach((record, i) => Object.assign(state.requestLog[i], record));
|
||||
} catch { state.storageError = "Saved debug data could not be read. Showing this session's requests from memory."; }
|
||||
}
|
||||
function reset() {
|
||||
if (timer !== null) environment.clearTimeout?.(timer);
|
||||
timer = null;
|
||||
pending.clear();
|
||||
state.requestLog = [];
|
||||
state.debugSecretsVisible = false;
|
||||
state.storageError = "";
|
||||
state.generation++;
|
||||
try {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key?.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: 0 }));
|
||||
} catch { state.storageError = "Local storage is unavailable. Requests will be kept in memory for this session."; }
|
||||
}
|
||||
return state;
|
||||
}
|
||||
223
src/features/debug/styles.css
Normal file
223
src/features/debug/styles.css
Normal file
@@ -0,0 +1,223 @@
|
||||
/* Full-page diagnostics, scoped to the extension and its current theme. */
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel {
|
||||
--debug-surface: #fff;
|
||||
--debug-code: #f5f6f7;
|
||||
--debug-text: #312d2a;
|
||||
--debug-muted: #5f5a55;
|
||||
--debug-border: #dedbd7;
|
||||
--debug-success: #22613b;
|
||||
--debug-success-bg: #e1f1e5;
|
||||
--debug-failed: #a12721;
|
||||
--debug-failed-bg: #fce8e6;
|
||||
--debug-pending: #805000;
|
||||
--debug-pending-bg: #fff1ce;
|
||||
--debug-info: #075e87;
|
||||
--debug-info-bg: #e5f1fa;
|
||||
--debug-key: #075e87;
|
||||
--debug-string: #22613b;
|
||||
--debug-number: #865000;
|
||||
--debug-literal: #73429b;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--debug-surface);
|
||||
color: var(--debug-text);
|
||||
color-scheme: light;
|
||||
}
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel {
|
||||
--debug-surface: #292827;
|
||||
--debug-code: #222120;
|
||||
--debug-text: #f6f4f2;
|
||||
--debug-muted: #c9c5c1;
|
||||
--debug-border: #514c47;
|
||||
--debug-success: #a9e3bd;
|
||||
--debug-success-bg: #214d34;
|
||||
--debug-failed: #ffb4ab;
|
||||
--debug-failed-bg: #582c2a;
|
||||
--debug-pending: #ffe099;
|
||||
--debug-pending-bg: #59441e;
|
||||
--debug-info: #a8d9ff;
|
||||
--debug-info-bg: #233f52;
|
||||
--debug-key: #8ed6f0;
|
||||
--debug-string: #a9e3bd;
|
||||
--debug-number: #ffe099;
|
||||
--debug-literal: #d6b7f1;
|
||||
color: #f6f4f2;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.opportunities-extension-debug-actions { display: flex; gap: 8px; flex: 0 0 auto; }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-list {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 2px 8px 12px 2px;
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item {
|
||||
--debug-state: var(--debug-info);
|
||||
--debug-state-bg: var(--debug-info-bg);
|
||||
border-color: var(--debug-border);
|
||||
border-left-color: var(--debug-state);
|
||||
background: var(--debug-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-success { --debug-state: var(--debug-success); --debug-state-bg: var(--debug-success-bg); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-failed { --debug-state: var(--debug-failed); --debug-state-bg: var(--debug-failed-bg); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-pending { --debug-state: var(--debug-pending); --debug-state-bg: var(--debug-pending-bg); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-status {
|
||||
color: var(--debug-state);
|
||||
background: var(--debug-state-bg);
|
||||
}
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status,
|
||||
.opportunities-extension-debug-method {
|
||||
color: var(--debug-info);
|
||||
background: var(--debug-info-bg);
|
||||
}
|
||||
.opportunities-extension-debug-method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font: 700 11px/1.2 Consolas, monospace;
|
||||
}
|
||||
.opportunities-extension-debug-method[data-method="POST"] { color: var(--debug-literal); background: var(--debug-code); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="2"] { color: var(--debug-success); background: var(--debug-success-bg); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="4"],
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="5"] { color: var(--debug-failed); background: var(--debug-failed-bg); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="pending"] { color: var(--debug-pending); background: var(--debug-pending-bg); }
|
||||
.opportunities-extension-debug-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 0 14px 16px; }
|
||||
.opportunities-extension-debug-block { min-width: 0; border-left: 2px solid var(--debug-info); padding-left: 10px; }
|
||||
.opportunities-extension-debug-block[data-direction="response"] { border-left-color: var(--debug-success); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-block h3 { margin: 0 0 8px; color: var(--debug-info); font-size: 12px; }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-block[data-direction="response"] h3 { color: var(--debug-success); }
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item pre {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 380px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--debug-text);
|
||||
background: var(--debug-code);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
.opportunities-extension-debug-json-key { color: var(--debug-key); }
|
||||
.opportunities-extension-debug-json-string { color: var(--debug-string); }
|
||||
.opportunities-extension-debug-json-number { color: var(--debug-number); }
|
||||
.opportunities-extension-debug-json-literal { color: var(--debug-literal); }
|
||||
@media (max-width: 760px) {
|
||||
.opportunities-extension-modal .opportunities-extension-debug-panel { padding: 12px; }
|
||||
.opportunities-extension-debug-details { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.opportunities-extension-debug-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #dedbd7;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-tabs button {
|
||||
max-width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-tabs button:hover {
|
||||
background: #f5f4f2;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-tabs button[aria-selected="true"] {
|
||||
border-bottom-color: #00758f;
|
||||
background: #e8f4f6;
|
||||
color: #006b84;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-tabs button:focus-visible,
|
||||
.opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
||||
outline: 2px solid #00758f;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-time {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-heading {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-heading strong {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.opportunities-extension-debug-summary-meta {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button {
|
||||
color: #f6f4f2;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading::before {
|
||||
color: #c9c5c1;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs {
|
||||
border-bottom-color: #514c47;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button:hover {
|
||||
background: #383633;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button[aria-selected="true"] {
|
||||
background: #173f48;
|
||||
border-bottom-color: #8cc8d3;
|
||||
color: #bde7ee;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button:focus-visible,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
||||
outline-color: #8cc8d3;
|
||||
}
|
||||
.opportunities-extension-debug-duration {
|
||||
color: #55514d;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-duration { color: #d6d2ce; }
|
||||
53
src/features/install-base/action.js
Normal file
53
src/features/install-base/action.js
Normal file
@@ -0,0 +1,53 @@
|
||||
function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||
appendBackgroundRequestLog, openInstallBaseModal, environment }) {
|
||||
const { document } = environment;
|
||||
const pending = new Map();
|
||||
|
||||
async function load(partyNumber) {
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
const response = await sendRuntimeMessage({
|
||||
type: messageTypes.requestInstallBase, partyNumber,
|
||||
authorizations: { accountSummary: authorizations.accountSummary, installBase: authorizations.installBase }
|
||||
});
|
||||
appendBackgroundRequestLog(response?.debugRequests);
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to load installed base.");
|
||||
return response.items;
|
||||
}
|
||||
|
||||
function createInstallBaseButton(opportunity) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-action-button";
|
||||
button.title = "View customer installed base";
|
||||
button.setAttribute("aria-label", `View installed base for ${opportunity.CustomerAccount?.PartyUniqueName || "customer"}`);
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.setAttribute("focusable", "false");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M4 3h16v7H4z M4 14h16v7H4z M7 6.5h.01 M7 17.5h.01 M11 6.5h6 M11 17.5h6 M8 10v4 M16 10v4");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.7");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
icon.append(path);
|
||||
button.append(icon);
|
||||
button.addEventListener("click", async () => {
|
||||
const view = openInstallBaseModal(opportunity, button);
|
||||
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
|
||||
try {
|
||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||
if (!pending.has(partyNumber)) {
|
||||
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||
pending.set(partyNumber, request);
|
||||
}
|
||||
view.render(await pending.get(partyNumber));
|
||||
} catch (error) {
|
||||
view.render([], error.message || "Unable to load installed base.");
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { createInstallBaseButton, resetRequests: () => pending.clear() };
|
||||
}
|
||||
81
src/features/install-base/styles.css
Normal file
81
src/features/install-base/styles.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog {
|
||||
width: min(1700px, 100%);
|
||||
}
|
||||
.opportunities-extension-install-base-dialog .opportunities-extension-account-plans-header {
|
||||
padding: 10px 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
.opportunities-extension-install-base-dialog .opportunities-extension-account-plans-header h2 {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.opportunities-extension-install-base-dialog .opportunities-extension-account-plans-header p {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.opportunities-extension-install-base-dialog .opportunities-extension-icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 4px;
|
||||
}
|
||||
.opportunities-extension-install-base-dialog .opportunities-extension-customer-table-toolbar {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.opportunities-extension-install-base-dialog .opportunities-extension-customer-table-search {
|
||||
width: min(320px, 100%);
|
||||
height: 32px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.opportunities-extension-install-base-content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.opportunities-extension-install-base-message { margin: 10px 12px; font-size: 12px; line-height: 16px; }
|
||||
.opportunities-extension-install-base-table {
|
||||
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px;
|
||||
}
|
||||
.opportunities-extension-install-base-table col { width: 7%; }
|
||||
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
|
||||
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
|
||||
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
|
||||
.opportunities-extension-install-base-table .install-base-col-quantityType { width: 8%; }
|
||||
.opportunities-extension-install-base-table th,
|
||||
.opportunities-extension-install-base-table td {
|
||||
padding: 8px 6px; text-align: left; vertical-align: middle; overflow-wrap: anywhere;
|
||||
border-bottom: 1px solid #e8e5e1; line-height: 1.4;
|
||||
}
|
||||
.opportunities-extension-install-base-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
|
||||
.opportunities-extension-install-base-table td[data-format="amount"],
|
||||
.opportunities-extension-install-base-table td[data-format="quantity"] { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.opportunities-extension-install-base-table td[data-format="date"] { white-space: nowrap; }
|
||||
.opportunities-extension-install-base-status {
|
||||
display: inline-block; padding: 2px 5px; border-radius: 12px; font-size: 10px; font-weight: 600;
|
||||
color: #5f5a55; background: #eceae8; white-space: nowrap;
|
||||
}
|
||||
.opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; }
|
||||
.opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-content,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { color: #f6f4f2; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-message { color: #c9c5c1; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-message[role="alert"] { color: #ffb4ab; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status { color: #dedbd7; background: #484541; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #b8e4c6; background: #214d34; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #ffdfa2; background: #614418; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
|
||||
}
|
||||
.opportunities-extension-install-base-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-install-base-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
|
||||
153
src/features/install-base/view.js
Normal file
153
src/features/install-base/view.js
Normal file
@@ -0,0 +1,153 @@
|
||||
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
const columns = [
|
||||
["productGroupName", "Product Group"], ["productName", "Product"],
|
||||
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
|
||||
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
|
||||
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
|
||||
["supportEndDate", "Support End Date", "date"], ["licenseValue", "License Value", "amount"],
|
||||
["shipDate", "Ship Date", "date"]
|
||||
];
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function closeInstallBaseModal() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.modal.remove();
|
||||
if (previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function formatValue(value, type) {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (type === "date") return formatOracleResponseDate(value);
|
||||
if ((type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
|
||||
}).format(Number(value));
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
const sortColumns = columns.map(([key, , type]) => ({ key, type: ["amount", "quantity"].includes(type) ? "number" : type }));
|
||||
function createTable(items, loading = false, sorting = tableSorting.create("installed-base", sortColumns), onSort) {
|
||||
const table = element("table", "opportunities-extension-install-base-table");
|
||||
table.setAttribute("data-table-layout", "standard");
|
||||
table.setAttribute("aria-label", "Installed base items");
|
||||
const head = element("thead");
|
||||
const header = element("tr");
|
||||
const group = element("colgroup");
|
||||
columns.forEach(([key, label]) => {
|
||||
const col = element("col");
|
||||
col.className = `install-base-col-${key}`;
|
||||
group.append(col);
|
||||
const th = element("th");
|
||||
th.scope = "col";
|
||||
sorting.header(th, key, label, onSort, loading);
|
||||
header.append(th);
|
||||
});
|
||||
head.append(header);
|
||||
const body = element("tbody");
|
||||
(loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
|
||||
const row = element("tr");
|
||||
columns.forEach(([key, , type]) => {
|
||||
const cell = element("td");
|
||||
if (loading) {
|
||||
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
|
||||
bar.setAttribute("aria-hidden", "true");
|
||||
cell.append(bar);
|
||||
} else {
|
||||
const value = item[key];
|
||||
const text = formatValue(value, type);
|
||||
if (type === "status") {
|
||||
const badge = element("span", "opportunities-extension-install-base-status", text);
|
||||
badge.dataset.status = text.toUpperCase();
|
||||
cell.append(badge);
|
||||
} else cell.textContent = text;
|
||||
if (type) cell.dataset.format = type;
|
||||
}
|
||||
row.append(cell);
|
||||
});
|
||||
body.append(row);
|
||||
});
|
||||
table.append(group, head, body);
|
||||
return table;
|
||||
}
|
||||
function openInstallBaseModal(opportunity, trigger) {
|
||||
closeInstallBaseModal();
|
||||
const overlay = document.getElementById(MODAL_ID);
|
||||
if (!overlay || overlay.hidden) return { render() {} };
|
||||
const modal = element("section", "opportunities-extension-account-plans-modal");
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-busy", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-install-base-title");
|
||||
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-install-base-dialog");
|
||||
const header = element("header", "opportunities-extension-account-plans-header");
|
||||
const heading = element("div");
|
||||
const title = element("h2", "", "Installed Base");
|
||||
title.id = "opportunities-extension-install-base-title";
|
||||
heading.append(title, element("p", "", opportunity.CustomerAccount?.PartyUniqueName || "Customer"));
|
||||
const close = element("button", "opportunities-extension-icon-button");
|
||||
close.type = "button";
|
||||
close.title = "Close";
|
||||
close.setAttribute("aria-label", "Close installed base");
|
||||
close.append(createCloseIcon());
|
||||
close.addEventListener("click", closeInstallBaseModal);
|
||||
header.append(heading, close);
|
||||
const content = element("div", "opportunities-extension-install-base-content");
|
||||
content.tabIndex = 0;
|
||||
content.setAttribute("role", "region");
|
||||
content.setAttribute("aria-label", "Installed base results");
|
||||
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
|
||||
status.setAttribute("role", "status");
|
||||
let loadedItems = [], loaded = false, failure = "";
|
||||
const sorting = tableSorting.create("installed-base", sortColumns);
|
||||
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
|
||||
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
|
||||
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
||||
toolbar.append(search.input);
|
||||
content.append(status, createTable([], true));
|
||||
dialog.append(header, toolbar, content);
|
||||
modal.append(dialog);
|
||||
overlay.append(modal);
|
||||
active = { modal, trigger };
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
|
||||
}
|
||||
});
|
||||
close.focus();
|
||||
function renderResults() {
|
||||
if (!loaded || active?.modal !== modal || overlay.hidden) return;
|
||||
const items = failure ? [] : search.filter(loadedItems);
|
||||
content.textContent = "";
|
||||
status.textContent = failure || (loadedItems.length
|
||||
? `${items.length} of ${loadedItems.length} installed base items${items.length ? "" : ". No matching records."}`
|
||||
: "No installed base items found for this customer.");
|
||||
status.setAttribute("role", failure ? "alert" : "status");
|
||||
content.append(status);
|
||||
if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
|
||||
renderResults();
|
||||
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
|
||||
}));
|
||||
content.scrollTop = 0;
|
||||
}
|
||||
return { render(items, error = "") {
|
||||
// A closed or replaced dialog must never receive a late response.
|
||||
if (active?.modal !== modal || overlay.hidden) return;
|
||||
modal.setAttribute("aria-busy", "false");
|
||||
loadedItems = items; failure = error; loaded = true;
|
||||
search.reset();
|
||||
renderResults();
|
||||
} };
|
||||
}
|
||||
return { openInstallBaseModal, closeInstallBaseModal, createTable };
|
||||
}
|
||||
67
src/features/manage-time/styles.css
Normal file
67
src/features/manage-time/styles.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.oe-time-dialog { --time-bg: #fff; --time-input-bg: #fff; --time-muted: #faf9f8; --time-border: #dedbd7; --time-input-border: #8f8983; --time-accent: #00758f; width: min(1600px, 100%); max-height: calc(100dvh - 24px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; line-height: 20px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog { --time-bg: #292725; --time-input-bg: #211f1d; --time-muted: #35322f; --time-border: #514c47; --time-input-border: #918a82; --time-accent: #80d6e8; }
|
||||
.oe-time-modal { grid-template-columns: minmax(0, 1fr); }
|
||||
.oe-time-dialog, .oe-time-body { min-width: 0; }
|
||||
.oe-time-dialog * { box-sizing: border-box; }
|
||||
.oe-time-dialog [hidden] { display: none !important; }
|
||||
.oe-time-header { flex-shrink: 0; }
|
||||
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
|
||||
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.oe-time-navigation { display: flex; align-items: center; gap: 8px; }
|
||||
.oe-time-ending { display: grid; gap: 4px; text-align: right; }
|
||||
.oe-time-summary { display: grid; grid-template-columns: repeat(4, 1fr); padding: 12px 0; gap: 12px; text-align: center; border-block: 1px solid var(--time-border); margin-bottom: 18px; }
|
||||
.oe-time-body button { min-width: 38px; min-height: 38px; display: inline-flex; gap: 8px; align-items: center; justify-content: center; color: inherit; background: transparent; border: 1px solid var(--time-input-border); border-radius: 4px; padding: 7px; cursor: pointer; font: inherit; }
|
||||
.oe-time-dialog button:hover:not(:disabled) { background: var(--time-muted); border-color: var(--time-accent); }
|
||||
.oe-time-dialog svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
.oe-time-dialog input, .oe-time-dialog select, .oe-time-dialog textarea { font: inherit; line-height: 20px; color: inherit; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 8px; min-height: 38px; }
|
||||
.oe-time-dialog select { height: 38px; }
|
||||
.oe-time-dialog :focus-visible { outline: 2px solid var(--time-accent); outline-offset: 2px; }
|
||||
.oe-time-dialog :disabled { opacity: .55; cursor: not-allowed; }
|
||||
.oe-time-grid { overflow-x: auto; }
|
||||
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
|
||||
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
|
||||
.oe-time-grid .opportunities-extension-sort-button { min-width: 0; min-height: 28px; padding: 4px 12px 4px 0; border: 0; border-radius: 0; gap: 0; }
|
||||
.oe-time-grid .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
|
||||
.oe-time-grid .oe-time-day-label { margin-bottom: 0; }
|
||||
.oe-time-grid th:first-child { width: 23%; }
|
||||
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
|
||||
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
|
||||
.oe-time-grid td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
|
||||
.oe-time-duplicate-row { outline: 2px solid #b3261e; outline-offset: -2px; animation: oe-time-duplicate-fade 5.5s linear forwards; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-time-duplicate-row { --time-duplicate-color: #ffaaa4; }
|
||||
@keyframes oe-time-duplicate-fade { 0%, 90.909% { outline-color: var(--time-duplicate-color, #b3261e); } 100% { outline-color: transparent; } }
|
||||
@media (prefers-reduced-motion: reduce) { .oe-time-duplicate-row { animation-timing-function: step-end; } }
|
||||
.oe-time-grid select { width: 100%; }
|
||||
.oe-time-grid input[type="number"] { width: min(100%, 60px); padding: 6px 3px; text-align: center; }
|
||||
.oe-time-grid .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
|
||||
.oe-time-grid .opportunities-extension-action-options { text-align: left; }
|
||||
.oe-time-dialog .opportunities-extension-select-tooltip { position: fixed; z-index: 100; width: min(560px, calc(100vw - 40px)); max-height: min(440px, calc(100dvh - 48px)); overflow: auto; padding: 14px; border: 1px solid var(--time-input-border); border-radius: 4px; background: var(--time-input-bg); color: inherit; box-shadow: 0 4px 16px #0003; text-align: left; }
|
||||
.oe-time-sr-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; margin: 0; }
|
||||
.oe-time-sr-details > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.oe-time-sr-details dt { font-size: 11px; opacity: .75; margin-bottom: 2px; }
|
||||
.oe-time-sr-details dd { margin: 0; white-space: pre-wrap; }
|
||||
.oe-time-sr-details .oe-time-sr-description { grid-column: 1 / -1; }
|
||||
.oe-time-day > span { display: block; }
|
||||
.oe-time-day .oe-time-day-label { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 9px; }
|
||||
.oe-time-day-total { font-size: 10px; background: var(--time-accent); color: var(--time-bg); min-width: 16px; border-radius: 8px; padding: 2px 4px; }
|
||||
.oe-time-notes textarea { width: 100%; resize: vertical; }
|
||||
.oe-time-notes-box { position: relative; text-align: left; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 14px 12px 42px; }
|
||||
.oe-time-notes-count { display: block; font-weight: 600; opacity: .8; margin-bottom: 8px; }
|
||||
.oe-time-notes-box textarea { border: 0; min-height: 128px; padding: 0; }
|
||||
.oe-time-notes-box .oe-time-notes-collapse { position: absolute; right: 0; bottom: 0; background: var(--time-muted); }
|
||||
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
|
||||
.oe-time-footer button { padding: 7px 14px; }
|
||||
.oe-time-status { padding: 12px 0; }
|
||||
.oe-time-loading-status { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.oe-time-skeleton { display: inline-block; vertical-align: middle; border-radius: 4px; background: var(--time-border); color: transparent; animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
|
||||
.oe-time-skeleton-select { display: block; width: 100%; height: 38px; }
|
||||
.oe-time-skeleton-control { width: min(100%, 60px); height: 38px; }
|
||||
.oe-time-skeleton-heading { width: 65%; height: 20px; margin: 17px 0; }
|
||||
.oe-time-skeleton-summary { min-width: 44px; height: 20px; }
|
||||
@keyframes oe-time-pulse { from { opacity: .4; } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .oe-time-skeleton { animation: none; } }
|
||||
.oe-time-status[role="alert"] { color: #a12d27; }
|
||||
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-time-status[role="alert"] { color: #ffd9d5; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||
@media (max-width: 700px) { .oe-time-modal { padding: 8px; } .oe-time-dialog { max-height: calc(100dvh - 16px); } .oe-time-body { padding: 12px; } .oe-time-summary { grid-template-columns: repeat(2, 1fr); text-align: left; } .oe-time-toolbar { flex-wrap: wrap; } .oe-time-ending { text-align: left; } }
|
||||
541
src/features/manage-time/view.js
Normal file
541
src/features/manage-time/view.js
Normal file
@@ -0,0 +1,541 @@
|
||||
function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
let ready = false;
|
||||
let launcher = null;
|
||||
const referenceDateKey = "opportunities-extension-manage-time-reference-date";
|
||||
let lastReferenceDate = "";
|
||||
function savedReferenceDate() {
|
||||
try {
|
||||
const saved = environment.localStorage?.getItem(referenceDateKey);
|
||||
if (dates.parse(saved)) lastReferenceDate = saved;
|
||||
} catch { /* Keep the in-memory selection when storage is unavailable. */ }
|
||||
return dates.parse(lastReferenceDate) ? lastReferenceDate : dates.today();
|
||||
}
|
||||
function saveReferenceDate(value) {
|
||||
if (!dates.parse(value)) return;
|
||||
lastReferenceDate = value;
|
||||
try { environment.localStorage?.setItem(referenceDateKey, value); }
|
||||
catch { /* Keep the selection for this page even if persistence is blocked. */ }
|
||||
}
|
||||
function setReady(value) {
|
||||
ready = !!value;
|
||||
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
|
||||
}
|
||||
function node(tag, className = "", text) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
function icon(name) {
|
||||
const paths = {
|
||||
clock: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 6v6l4 2",
|
||||
left: "m15 18-6-6 6-6", right: "m9 18 6-6-6-6", close: "m6 6 12 12M18 6 6 18",
|
||||
up: "m6 15 6-6 6 6",
|
||||
add: "M12 5v14M5 12h14", copy: "M9 9h12v12H9ZM5 15H3V3h12v2",
|
||||
notes: "M4 3h16v18H4ZM8 7h8M8 11h8M8 15h5",
|
||||
trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
|
||||
save: "M5 3h12l4 4v14H3V3ZM7 3v6h10V3M7 21v-8h10v8"
|
||||
};
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", paths[name]);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
function button(label, symbol, onClick, text = false) {
|
||||
const el = node("button");
|
||||
el.type = "button"; el.title = label;
|
||||
el.setAttribute("aria-label", label);
|
||||
el.append(icon(symbol));
|
||||
if (text) el.append(node("span", "", label));
|
||||
if (onClick) el.addEventListener("click", onClick);
|
||||
return el;
|
||||
}
|
||||
function createButton() {
|
||||
const trigger = button("Manage Time", "clock", () => open(trigger));
|
||||
trigger.className = "opportunities-extension-icon-button";
|
||||
launcher = trigger; setReady(ready);
|
||||
return trigger;
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
if (!active) return false;
|
||||
if (active.saving) return true;
|
||||
const previous = active; active = null;
|
||||
previous.modal.remove();
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function open(trigger) {
|
||||
if (active?.saving) return;
|
||||
if (!ready) return;
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host || host.hidden) return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal oe-time-modal");
|
||||
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "oe-time-title");
|
||||
const dialog = node("div", "opportunities-extension-action-form-dialog oe-time-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header oe-time-header");
|
||||
const title = node("h2", "", "Manage Time"); title.id = "oe-time-title";
|
||||
const closeButton = button("Close Manage Time", "close", () => close());
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
header.append(title, closeButton);
|
||||
const body = node("div", "oe-time-body");
|
||||
const toolbar = node("div", "oe-time-toolbar");
|
||||
const navigation = node("div", "oe-time-navigation");
|
||||
const date = node("input"); date.type = "date"; date.value = savedReferenceDate(); date.required = true;
|
||||
date.setAttribute("aria-label", "Reference date");
|
||||
const ending = node("div", "oe-time-ending");
|
||||
const endingValue = node("strong", "", "--");
|
||||
ending.append(node("span", "", "Week Ending"), endingValue);
|
||||
const changeDate = () => { saveReferenceDate(date.value); load(); };
|
||||
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); changeDate(); } };
|
||||
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
|
||||
date.addEventListener("change", changeDate); toolbar.append(navigation, ending);
|
||||
const summary = node("div", "oe-time-summary"), sums = {};
|
||||
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
|
||||
const item = node("div"); sums[label] = node("strong", "", "0h");
|
||||
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
|
||||
}
|
||||
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
|
||||
const retry = button("Retry", "clock", load, true); retry.hidden = true;
|
||||
const serviceStatus = node("div", "oe-time-status"); serviceStatus.setAttribute("role", "status"); serviceStatus.hidden = true;
|
||||
const serviceRetry = button("Retry Service Requests", "clock", () => loadServices(entry.initialWeekId, true), true); serviceRetry.hidden = true;
|
||||
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
|
||||
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
|
||||
const scroll = node("div", "oe-time-grid"), table = node("table");
|
||||
table.setAttribute("data-table-layout", "standard");
|
||||
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
|
||||
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
|
||||
const add = button("Add", "add", () => addRow("Service"), true);
|
||||
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
|
||||
const save = button("Save", "save", saveEntries, true); save.disabled = true;
|
||||
const saveStatus = node("div", "oe-time-status"); saveStatus.hidden = true; saveStatus.setAttribute("role", "status");
|
||||
footer.append(add, addNonService, save);
|
||||
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, saveStatus, footer);
|
||||
dialog.append(header, body); modal.append(dialog); host.append(modal);
|
||||
const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
|
||||
active = entry;
|
||||
entry.deletions = new Map();
|
||||
entry.taskTypes = [];
|
||||
entry.taskTypesReady = false;
|
||||
let combos = [];
|
||||
modal.addEventListener("scroll", event => {
|
||||
if (event.target === body || event.target === scroll) combos.forEach(combo => combo.sync());
|
||||
}, true);
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key !== "Tab") return;
|
||||
const elements = [...dialog.querySelectorAll("button:not(:disabled), input:not(:disabled), textarea:not(:disabled)")].filter(el => !el.hidden && el.getClientRects().length);
|
||||
const first = elements[0], last = elements[elements.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
});
|
||||
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
|
||||
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
|
||||
const sorting = tableSorting.create("manage-time", [
|
||||
{ key: "service", value: row => row.type === "Non Service" ? "Non Service" : `${entry.services.find(sr => sr.srNumber === row.srNumber)?.srTitle || ""} ${row.srNumber}` },
|
||||
{ key: "activity" },
|
||||
{ key: "taskType", value: row => entry.taskTypes.find(item => item.lookUpCode_c === row.taskType)?.lookUpMeaning_c || row.taskType },
|
||||
{ key: "notes" },
|
||||
...hourFields.map((key, weekday) => ({ key, type: "number", value: row => {
|
||||
const day = entry.days.find(item => dates.fromApi(item.Date).getUTCDay() === weekday);
|
||||
return day ? row.hours[day.Date] : null;
|
||||
} }))
|
||||
]);
|
||||
function rowPayload(row) {
|
||||
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
|
||||
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
|
||||
notes: row.notes, weekId: entry.days[0]?.WeekId, tag: row.type === "Non Service" ? "NON_SERVICE" : "SERVICE" };
|
||||
for (const day of entry.days) payload[hourFields[dates.fromApi(day.Date).getUTCDay()]] = Number(row.hours[day.Date]) || 0;
|
||||
return payload;
|
||||
}
|
||||
function signature(row) { return JSON.stringify(rowPayload(row)); }
|
||||
function touched(row) { return row.id || row.persisted || row.srNumber || row.activity || row.taskType || row.notes.trim() || Object.values(row.hours).some(value => value !== "") || Object.values(row.invalidHours).some(Boolean); }
|
||||
function validRow(row) {
|
||||
return !!row.taskType && (row.type === "Non Service" || (!!row.srNumber && !!row.activity))
|
||||
&& !Object.values(row.invalidHours).some(Boolean)
|
||||
&& Object.values(row.hours).every(value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 24)
|
||||
&& Object.values(row.hours).some(value => Number(value) > 0);
|
||||
}
|
||||
function changedRows() { return entry.rows.filter(row => touched(row) && signature(row) !== row.baseline); }
|
||||
function pendingDeletes() {
|
||||
const key = entry.days.map(day => day.Date).join("|");
|
||||
if (!entry.deletions.has(key)) entry.deletions.set(key, new Set());
|
||||
return entry.deletions.get(key);
|
||||
}
|
||||
function updateSave() {
|
||||
const rows = changedRows();
|
||||
const deletions = pendingDeletes();
|
||||
save.disabled = (rows.length > 0 && !requestSave) || (deletions.size > 0 && !requestDelete) || !entry.tbody || entry.saving || (!rows.length && !deletions.size)
|
||||
|| entry.rows.some(row => touched(row) && !validRow(row)) || rows.some(row => row.persisted && !row.id);
|
||||
save.title = save.disabled ? "Complete the required fields and enter hours to save changes." : "Save time entries";
|
||||
}
|
||||
async function saveEntries() {
|
||||
updateSave();
|
||||
if (save.disabled) return;
|
||||
const rows = changedRows(), knownIds = new Set(entry.rows.filter(row => row.id).map(row => String(row.id)));
|
||||
const controls = [];
|
||||
function lock(node) {
|
||||
if (["BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(String(node.tagName || node.tag).toUpperCase())) { controls.push([node, node.disabled]); node.disabled = true; }
|
||||
Array.from(node.children).forEach(lock);
|
||||
}
|
||||
entry.saving = true; lock(modal);
|
||||
saveStatus.hidden = false; saveStatus.setAttribute("role", "status"); saveStatus.textContent = "Saving time entries...";
|
||||
let savedCount = 0, deletedCount = 0;
|
||||
try {
|
||||
const deletions = pendingDeletes();
|
||||
for (const id of [...deletions]) {
|
||||
await requestDelete(id);
|
||||
deletions.delete(id);
|
||||
deletedCount++;
|
||||
}
|
||||
for (const method of ["PUT", "POST"]) {
|
||||
const group = rows.filter(row => method === "PUT" ? !!row.id : !row.id);
|
||||
if (!group.length) continue;
|
||||
const result = await requestSave(method, group.map(row => ({ ...rowPayload(row), ...(row.id ? { id: row.id } : {}) })));
|
||||
group.forEach(row => { row.persisted = true; row.baseline = signature(row); });
|
||||
savedCount += group.length;
|
||||
if (method === "POST") {
|
||||
let records = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : [];
|
||||
function assignIds() {
|
||||
const candidates = rowsFromEntries(records, entry.days);
|
||||
for (const row of group.filter(row => !row.id)) {
|
||||
const match = candidates.find(candidate => candidate.id && !knownIds.has(String(candidate.id)) && signature(candidate) === row.baseline);
|
||||
if (match) { row.id = match.id; knownIds.add(String(match.id)); }
|
||||
}
|
||||
}
|
||||
assignIds();
|
||||
if (group.some(row => !row.id)) { records = await requestEntries(entry.days[0].WeekId); assignIds(); }
|
||||
if (group.some(row => !row.id)) throw new Error("Entries saved, but their IDs could not be confirmed. Reopen Manage Time before editing these entries.");
|
||||
}
|
||||
}
|
||||
saveStatus.textContent = [savedCount ? `${savedCount} time ${savedCount === 1 ? "entry" : "entries"} saved.` : "", deletedCount ? `${deletedCount} time ${deletedCount === 1 ? "entry" : "entries"} deleted.` : ""].filter(Boolean).join(" ");
|
||||
notify(saveStatus.textContent);
|
||||
saveStatus.hidden = true;
|
||||
} catch (error) {
|
||||
saveStatus.setAttribute("role", "alert");
|
||||
saveStatus.textContent = `${savedCount ? `${savedCount} saved. ` : ""}${deletedCount ? `${deletedCount} deleted. ` : ""}${error.message || "Unable to save time entries."}`;
|
||||
notify(saveStatus.textContent, true);
|
||||
} finally {
|
||||
entry.saving = false;
|
||||
controls.forEach(([control, disabled]) => { control.disabled = disabled; });
|
||||
if (active === entry) { renderRows(); updateSave(); }
|
||||
}
|
||||
}
|
||||
function total(value) { return Number(Number(value).toFixed(2)).toString(); }
|
||||
function updateTotals() {
|
||||
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
|
||||
entry.days.forEach((day, i) => {
|
||||
let sum = 0;
|
||||
entry.rows.forEach(row => {
|
||||
const hours = Number(row.hours[day.Date]) || 0;
|
||||
sum += hours; groups[row.type] += hours; groups["Total Hours"] += hours;
|
||||
});
|
||||
entry.dayTotals[i].textContent = total(sum);
|
||||
});
|
||||
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)}h`; });
|
||||
updateSave();
|
||||
}
|
||||
function addRow(type, source) {
|
||||
if (!entry.tbody || add.disabled) return;
|
||||
entry.rows.push(source ? { ...source, id: undefined, persisted: false, baseline: undefined, duplicateHighlightAt: undefined,
|
||||
...(source.type === "Service" ? { srNumber: "", activity: "" } : {}),
|
||||
invalidHours: { ...source.invalidHours }, hours: { ...source.hours } } : blankRow(type)); renderRows();
|
||||
}
|
||||
function renderRows() {
|
||||
if (entry.saving) return;
|
||||
const focusedId = document.activeElement?.id;
|
||||
combos = [];
|
||||
entry.tbody.replaceChildren();
|
||||
sorting.select(entry.rows).forEach(row => {
|
||||
const index = entry.rows.indexOf(row);
|
||||
const tr = node("tr");
|
||||
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
|
||||
tr.className = "oe-time-duplicate-row";
|
||||
tr.setAttribute("style", `animation-delay: -${Math.max(0, Date.now() - row.duplicateHighlightAt)}ms`);
|
||||
tr.addEventListener("animationend", () => { tr.className = ""; row.duplicateHighlightAt = undefined; });
|
||||
}
|
||||
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
|
||||
const td = node("td"), select = node("select");
|
||||
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
|
||||
let change = () => {};
|
||||
select.setAttribute("aria-label", `${label}, row ${index + 1}`);
|
||||
const placeholder = node("option", "", label); placeholder.value = ""; select.append(placeholder);
|
||||
if (label === "Service Request") {
|
||||
for (const sr of entry.services) {
|
||||
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
|
||||
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
|
||||
}
|
||||
if (row.srNumber && !entry.services.some(sr => sr.srNumber === row.srNumber)) {
|
||||
const option = node("option", "", row.srNumber); option.value = row.srNumber; select.append(option);
|
||||
}
|
||||
select.value = row.srNumber;
|
||||
select.disabled = !entry.servicesReady;
|
||||
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
|
||||
change = () => {
|
||||
const duplicate = select.value && entry.rows.find(other => other !== row && other.type === "Service" && other.srNumber === select.value);
|
||||
if (duplicate) {
|
||||
duplicate.duplicateHighlightAt = Date.now();
|
||||
select.value = row.srNumber;
|
||||
notify("This Service Request is already selected in another row.", true);
|
||||
renderRows(); return;
|
||||
}
|
||||
row.srNumber = select.value; row.activity = ""; renderRows();
|
||||
};
|
||||
} else if (label === "Activity") {
|
||||
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
|
||||
const raw = String(source?.SRActivities_c || "");
|
||||
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
|
||||
if (row.activity && !activities.includes(row.activity)) activities.push(row.activity);
|
||||
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); }
|
||||
select.value = row.activity; select.disabled = !activities.length;
|
||||
select.title = activities.length ? "Activity" : "No activities available for this Service Request";
|
||||
change = () => { row.activity = select.value; updateSave(); };
|
||||
} else if (label === "Task Type") {
|
||||
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
|
||||
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
|
||||
for (const item of taskTypes) {
|
||||
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option);
|
||||
}
|
||||
if (row.taskType && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) {
|
||||
const option = node("option", "", row.taskType); option.value = row.taskType; select.append(option);
|
||||
}
|
||||
select.value = row.taskType;
|
||||
select.disabled = !entry.taskTypesReady || (!taskTypes.length && !row.taskType);
|
||||
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
|
||||
change = () => { row.taskType = select.value; updateSave(); };
|
||||
} else { select.disabled = true; select.title = "Options are not available yet."; }
|
||||
td.append(select); tr.append(td);
|
||||
select.addEventListener("change", change);
|
||||
const combo = searchableSelect(select, td, change, {
|
||||
floating: true,
|
||||
listWidth: label === "Service Request" ? 640 : 0,
|
||||
listMaxHeight: label === "Service Request" ? 360 : 180,
|
||||
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
|
||||
});
|
||||
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
|
||||
combos.push(combo);
|
||||
}
|
||||
for (const day of entry.days) {
|
||||
const td = node("td"), input = node("input");
|
||||
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
|
||||
input.value = row.hours[day.Date] ?? "";
|
||||
input.setAttribute("aria-invalid", String(!!row.invalidHours[day.Date]));
|
||||
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
|
||||
input.addEventListener("input", () => {
|
||||
const valid = !input.validity?.badInput && (input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24));
|
||||
if (!valid) {
|
||||
input.value = row.hours[day.Date] ?? "";
|
||||
notify("Enter hours between 0 and 24.", true);
|
||||
return;
|
||||
}
|
||||
row.invalidHours[day.Date] = !valid;
|
||||
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = input.value; updateTotals();
|
||||
}); td.append(input); tr.append(td);
|
||||
}
|
||||
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
|
||||
const noteCell = node("td"); noteCell.colSpan = entry.days.length + 6;
|
||||
const notesBox = node("div", "oe-time-notes-box");
|
||||
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`);
|
||||
const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
|
||||
notes.setAttribute("aria-label", `Notes, row ${index + 1}`);
|
||||
notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; updateSave(); });
|
||||
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
|
||||
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
|
||||
});
|
||||
collapse.className = "oe-time-notes-collapse";
|
||||
notesBox.append(remaining, notes, collapse); noteCell.append(notesBox); notesRow.append(noteCell);
|
||||
const notesButton = button(`Notes, row ${index + 1}`, "notes", () => {
|
||||
row.notesOpen = !row.notesOpen; notesRow.hidden = !row.notesOpen; notesButton.setAttribute("aria-expanded", String(row.notesOpen));
|
||||
if (!notesRow.hidden) notes.focus();
|
||||
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell);
|
||||
const copy = node("td"); copy.append(button(`Copy row ${index + 1}`, "copy", () => addRow(row.type, row))); tr.append(copy);
|
||||
const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => {
|
||||
if (entry.saving) return;
|
||||
if (row.persisted && !row.id) {
|
||||
saveStatus.hidden = false; saveStatus.setAttribute("role", "alert");
|
||||
saveStatus.textContent = "Reopen Manage Time to obtain the saved entry ID before deleting it."; return;
|
||||
}
|
||||
if (row.id) pendingDeletes().add(String(row.id));
|
||||
entry.rows.splice(index, 1); renderRows();
|
||||
saveStatus.hidden = false; saveStatus.setAttribute("role", "status");
|
||||
saveStatus.textContent = pendingDeletes().size ? `${pendingDeletes().size} pending ${pendingDeletes().size === 1 ? "deletion" : "deletions"}. Click Save to apply.` : "Unsaved row removed.";
|
||||
})); tr.append(remove);
|
||||
entry.tbody.append(tr, notesRow);
|
||||
}); updateTotals();
|
||||
combos.find(combo => combo.input.id === focusedId)?.input.focus();
|
||||
}
|
||||
function serviceRequestTooltip(srNumber) {
|
||||
const sr = entry.expanded.get(srNumber);
|
||||
if (!sr) return null;
|
||||
const details = node("dl", "oe-time-sr-details");
|
||||
for (const [key, label] of [
|
||||
["SrNumber", "SR number"], ["AccountPartyUniqueName", "Account"],
|
||||
["OptyName_c", "Opportunity"], ["OptyNumber_c", "Opty number"],
|
||||
["OptyOwner_c", "Opty owner"], ["OptyStatus_c", "Opty status"],
|
||||
["ServiceNew_c", "Service"], ["ReportedByPartyUniqueName", "Requested by"],
|
||||
["CountryText_c", "Country"], ["StatusCdMeaning", "Status"],
|
||||
["AssigneePersonName", "Service lead"], ["ProblemDescription", "Description"]
|
||||
]) {
|
||||
const field = node("div", key === "ProblemDescription" ? "oe-time-sr-description" : "");
|
||||
field.append(node("dt", "", label), node("dd", "", String(sr[key] ?? "-")));
|
||||
details.append(field);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
async function loadTaskTypes() {
|
||||
taskStatus.className = "oe-time-status oe-time-loading-status";
|
||||
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
|
||||
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
|
||||
try {
|
||||
const items = await requestTaskTypes();
|
||||
if (active !== entry) return;
|
||||
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
|
||||
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
|
||||
entry.taskTypesReady = true;
|
||||
taskStatus.className = "oe-time-status";
|
||||
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
|
||||
if (entry.tbody) renderRows();
|
||||
} catch (error) {
|
||||
if (active !== entry) return;
|
||||
taskStatus.className = "oe-time-status";
|
||||
taskStatus.textContent = error.message || "Unable to load Task Types.";
|
||||
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
|
||||
}
|
||||
}
|
||||
async function loadServices(weekId, retrying = false) {
|
||||
if (entry.servicesStarted && !retrying) return;
|
||||
entry.servicesStarted = true; entry.initialWeekId = weekId;
|
||||
serviceRetry.hidden = true; serviceStatus.hidden = false;
|
||||
serviceStatus.className = "oe-time-status oe-time-loading-status";
|
||||
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
|
||||
try {
|
||||
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
|
||||
if (active !== entry) return;
|
||||
entry.services = services; entry.servicesReady = true;
|
||||
serviceStatus.textContent = services.length ? "" : "No active Service Requests.";
|
||||
serviceStatus.hidden = services.length > 0;
|
||||
if (entry.tbody) renderRows();
|
||||
serviceStatus.hidden = false; serviceStatus.textContent = "Loading activities...";
|
||||
const expanded = await requestExpand(services.map(sr => sr.srNumber));
|
||||
if (active !== entry) return;
|
||||
entry.expanded = new Map(expanded.map(sr => [sr.SrNumber, sr]));
|
||||
serviceStatus.hidden = true;
|
||||
if (entry.tbody) renderRows();
|
||||
} catch (error) {
|
||||
if (active !== entry) return;
|
||||
serviceStatus.className = "oe-time-status";
|
||||
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
|
||||
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
|
||||
}
|
||||
}
|
||||
function rowsFromEntries(records, days) {
|
||||
return records.map(record => {
|
||||
const row = blankRow(record.tag === "NON_SERVICE" || record.srNumber === "non-service-sr" ? "Non Service" : "Service");
|
||||
row.id = record.id;
|
||||
row.deliveryType = record.deliveryType || "";
|
||||
row.srNumber = record.srNumber || "";
|
||||
row.activity = record.activityCode || "";
|
||||
row.taskType = record.taskType || "";
|
||||
row.notes = typeof record.notes === "string" ? record.notes : "";
|
||||
for (const day of days) {
|
||||
const value = record[hourFields[dates.fromApi(day.Date).getUTCDay()]];
|
||||
row.hours[day.Date] = value == null ? "" : String(value);
|
||||
}
|
||||
row.baseline = signature(row);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
function renderWeek(items, records) {
|
||||
entry.days = items;
|
||||
const key = items.map(day => day.Date).join("|");
|
||||
if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
|
||||
entry.rows = entry.drafts.get(key);
|
||||
const head = node("thead"), row = node("tr");
|
||||
for (const [key, label] of [["service", "Service Request"], ["activity", "Activity"], ["taskType", "Task Type"]]) {
|
||||
const th = node("th"); sorting.header(th, key, label, renderRows); row.append(th);
|
||||
}
|
||||
entry.dayTotals = [];
|
||||
for (const day of items) {
|
||||
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
|
||||
const dayButton = sorting.header(th, hourFields[parsed.getUTCDay()], ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()], renderRows);
|
||||
const dayLabel = dayButton.children[0]; dayLabel.className += " oe-time-day-label";
|
||||
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
|
||||
th.append(node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
|
||||
}
|
||||
const notesHeading = node("th"); sorting.header(notesHeading, "notes", "Notes", renderRows); row.append(notesHeading);
|
||||
for (const label of ["Copy", ""]) row.append(node("th", "", label));
|
||||
[...row.children].forEach(th => th.setAttribute("scope", "col"));
|
||||
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
|
||||
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
|
||||
}
|
||||
function renderSkeleton() {
|
||||
const head = node("thead"), heading = node("tr"), skeletonBody = node("tbody");
|
||||
for (let column = 0; column < 13; column++) {
|
||||
const cell = node("th");
|
||||
cell.append(node("span", "oe-time-skeleton oe-time-skeleton-heading")); heading.append(cell);
|
||||
}
|
||||
head.append(heading);
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const tr = node("tr", "oe-time-skeleton-row");
|
||||
for (let column = 0; column < 13; column++) {
|
||||
const cell = node("td");
|
||||
cell.append(node("span", `oe-time-skeleton ${column < 3 ? "oe-time-skeleton-select" : "oe-time-skeleton-control"}`)); tr.append(cell);
|
||||
}
|
||||
skeletonBody.append(tr);
|
||||
}
|
||||
table.setAttribute("aria-hidden", "true"); table.replaceChildren(head, skeletonBody);
|
||||
}
|
||||
function finishSkeleton() {
|
||||
table.setAttribute("aria-hidden", "false");
|
||||
endingValue.className = "";
|
||||
Object.values(sums).forEach(value => { value.className = ""; });
|
||||
status.className = "oe-time-status";
|
||||
}
|
||||
async function load() {
|
||||
if (entry.saving) return;
|
||||
finishSkeleton();
|
||||
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
|
||||
save.disabled = true; saveStatus.hidden = true;
|
||||
Object.values(sums).forEach(value => { value.textContent = "--"; });
|
||||
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
|
||||
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
|
||||
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
|
||||
status.className = "oe-time-status oe-time-loading-status";
|
||||
endingValue.className = "oe-time-skeleton oe-time-skeleton-summary";
|
||||
Object.values(sums).forEach(value => { value.className = "oe-time-skeleton oe-time-skeleton-summary"; });
|
||||
renderSkeleton();
|
||||
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const items = await requestWeek(date.value);
|
||||
if (active !== entry || entry.version !== version) return;
|
||||
status.textContent = "Loading time entries...";
|
||||
const key = items.map(day => day.Date).join("|");
|
||||
const records = entry.drafts.has(key) ? [] : await requestEntries(items[0].WeekId);
|
||||
if (active !== entry || entry.version !== version) return;
|
||||
finishSkeleton(); renderWeek(items, records); add.disabled = addNonService.disabled = false; status.hidden = true;
|
||||
loadServices(items[0].WeekId);
|
||||
} catch (error) {
|
||||
if (active !== entry || entry.version !== version) return;
|
||||
finishSkeleton(); table.replaceChildren();
|
||||
status.textContent = error.message || "Unable to load week details.";
|
||||
status.setAttribute("role", "alert"); retry.hidden = false;
|
||||
} finally {
|
||||
if (active === entry && entry.version === version) scroll.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
date.focus(); load(); loadTaskTypes();
|
||||
}
|
||||
return { createButton, open, close, setReady };
|
||||
}
|
||||
67
src/features/opportunities/activity-cache.js
Normal file
67
src/features/opportunities/activity-cache.js
Normal file
@@ -0,0 +1,67 @@
|
||||
function createActivityCache({ authSessionState, preferencesState, environment }) {
|
||||
const { browser, chrome } = environment;
|
||||
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
|
||||
async function invalidate() {
|
||||
await storage("set", { [invalidatedKey]: Date.now() });
|
||||
const entries = await storage("get", null);
|
||||
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
|
||||
if (keys.length) await storage("remove", keys);
|
||||
}
|
||||
function storage(method, argument) {
|
||||
if (browser?.storage?.local) return browser.storage.local[method](argument);
|
||||
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local[method](argument, result => {
|
||||
const error = chrome.runtime?.lastError;
|
||||
if (error) reject(new Error(error.message));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
function context(kind, id, timestamp) {
|
||||
const principal = authSessionState.principal;
|
||||
const fetchedAt = Number(timestamp);
|
||||
const hours = Number(preferencesState.opptyProductsCacheHours);
|
||||
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
|
||||
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
|
||||
const identity = [principal, kind, String(id)];
|
||||
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`;
|
||||
// SR snapshots from different product updates must not overwrite each other.
|
||||
if (kind === "sr") identity.push(fetchedAt);
|
||||
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`, legacyKey, fetchedAt, ttl };
|
||||
}
|
||||
async function read(kind, id, timestamp) {
|
||||
const scope = context(kind, id, timestamp);
|
||||
if (!scope) return null;
|
||||
try {
|
||||
const invalidated = await storage("get", invalidatedKey);
|
||||
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
|
||||
const result = await storage("get", scope.key);
|
||||
let entry = result?.[scope.key];
|
||||
if (!entry && scope.key !== scope.legacyKey) {
|
||||
const legacy = await storage("get", scope.legacyKey);
|
||||
entry = legacy?.[scope.legacyKey];
|
||||
}
|
||||
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
|
||||
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
|
||||
&& typeof entry.hasNext === "boolean" ? entry : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function write(kind, id, timestamp, items, hasNext = false) {
|
||||
const scope = context(kind, id, timestamp);
|
||||
if (!scope || !Array.isArray(items)) return;
|
||||
try {
|
||||
const invalidated = await storage("get", invalidatedKey);
|
||||
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
|
||||
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
|
||||
if (kind === "sr") {
|
||||
const entries = await storage("get", null);
|
||||
const prefix = `${scope.legacyKey.slice(0, -1)},`;
|
||||
const expired = Object.keys(entries || {}).filter(key => key.startsWith(prefix)
|
||||
&& Number(entries[key]?.fetchedAt) <= Math.max(Date.now() - scope.ttl, Number(invalidated?.[invalidatedKey] || 0)));
|
||||
if (expired.length) await storage("remove", expired);
|
||||
}
|
||||
} catch { /* Storage failure must not discard successfully fetched data. */ }
|
||||
}
|
||||
return { invalidate, read, write };
|
||||
}
|
||||
91
src/features/opportunities/activity-filters.js
Normal file
91
src/features/opportunities/activity-filters.js
Normal file
@@ -0,0 +1,91 @@
|
||||
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
|
||||
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
|
||||
let container = null;
|
||||
let form = null;
|
||||
const groups = [];
|
||||
const buttons = [];
|
||||
|
||||
function createControls(parentForm, statusFilter, stageFilter) {
|
||||
form = parentForm;
|
||||
groups.length = 0;
|
||||
buttons.length = 0;
|
||||
container = environment.document.createElement("div");
|
||||
container.className = "opportunities-extension-badge-filters";
|
||||
for (const [kind, title, options] of [
|
||||
["sr", "Service Request", ["matched", "under4", "missing"]],
|
||||
["actions", "WL Action", ["matched", "missing"]]
|
||||
]) {
|
||||
const section = environment.document.createElement("section");
|
||||
section.className = "opportunities-extension-activity-filter";
|
||||
const label = environment.document.createElement("span");
|
||||
label.textContent = title;
|
||||
const tooltip = kind === "sr"
|
||||
? "Whether a Service Request has been created for the opportunities."
|
||||
: "Whether the Action is registered in Workload Workbench.";
|
||||
label.title = tooltip;
|
||||
const controls = environment.document.createElement("div");
|
||||
controls.className = "opportunities-extension-activity-filter-controls";
|
||||
controls.setAttribute("role", "group");
|
||||
controls.setAttribute("aria-label", `Filter by ${title}`);
|
||||
for (const value of options) {
|
||||
const button = environment.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-activity-filter-button";
|
||||
button.setAttribute("data-value", value);
|
||||
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
|
||||
const description = value === "under4"
|
||||
? "At least one SR with reported hours below 4; replaces other SR filters."
|
||||
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
|
||||
button.title = `${tooltip}\n${description}`;
|
||||
button.setAttribute("aria-label", description);
|
||||
button.addEventListener("click", () => toggle(kind, value));
|
||||
buttons.push({ button, kind, value });
|
||||
controls.append(button);
|
||||
}
|
||||
section.append(label, controls);
|
||||
groups.push(section);
|
||||
container.append(section);
|
||||
}
|
||||
container.append(statusFilter, stageFilter);
|
||||
render();
|
||||
return container;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!container) return;
|
||||
const allowed = allowsCrmActivities();
|
||||
form.setAttribute("data-activity-filters", String(allowed));
|
||||
for (const group of groups) group.hidden = !allowed;
|
||||
for (const { button, kind, value } of buttons) {
|
||||
button.setAttribute("aria-pressed", String(selections[kind].has(value)));
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(kind, value) {
|
||||
const selected = selections[kind];
|
||||
if (!selected || !allowsCrmActivities()) return;
|
||||
if (selected.has(value)) selected.delete(value);
|
||||
else {
|
||||
if (value === "under4") selected.clear();
|
||||
else selected.delete("under4");
|
||||
selected.add(value);
|
||||
}
|
||||
render();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function matchesSelection(selected, summary) {
|
||||
// Unrestricted filters retain rows whose activities are not known yet.
|
||||
if (selected.has("matched") && selected.has("missing")) return true;
|
||||
if (!summary) return false;
|
||||
if (selected.has("under4")) return summary.state === "matched" && summary.hasUnderFourHours;
|
||||
return selected.has(summary.state);
|
||||
}
|
||||
|
||||
function matches(item) {
|
||||
if (!allowsCrmActivities()) return true;
|
||||
return matchesSelection(selections.sr, getSrSummary(item.OptyNumber))
|
||||
&& matchesSelection(selections.actions, getActionSummary(item.OptyNumber));
|
||||
}
|
||||
return { createControls, render, toggle, matches };
|
||||
}
|
||||
48
src/features/opportunities/activity-progress.js
Normal file
48
src/features/opportunities/activity-progress.js
Normal file
@@ -0,0 +1,48 @@
|
||||
function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
|
||||
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
const entries = new Map();
|
||||
function update(key, state, completed = 0, total = null) {
|
||||
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
|
||||
if (!host) return;
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-products-progress";
|
||||
badge.setAttribute("role", "status");
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
host.append(badge);
|
||||
entry = { badge, timer: null, hideTimer: null, state: "idle" };
|
||||
entries.set(key, entry);
|
||||
}
|
||||
if (state === "complete" && entry.state === "complete") return;
|
||||
clearTimeout(entry.timer);
|
||||
clearTimeout(entry.hideTimer);
|
||||
entry.state = state;
|
||||
const label = key === "session" ? "SR session" : key === "sr" ? "Service Requests" : "Actions";
|
||||
entry.badge.hidden = state === "idle";
|
||||
entry.badge.setAttribute("data-state", state);
|
||||
entry.badge.setAttribute("data-fading", "false");
|
||||
entry.badge.setAttribute("aria-busy", String(state === "running"));
|
||||
const count = total === null ? String(completed) : `${completed}/${total}`;
|
||||
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
|
||||
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
|
||||
if (key === "session" && state === "error") {
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.className = "opportunities-extension-session-retry";
|
||||
retry.textContent = "Retry";
|
||||
retry.setAttribute("aria-label", "Retry Service Request session initialization");
|
||||
retry.addEventListener("click", () => { retry.disabled = true; retrySession(); });
|
||||
entry.badge.append(retry);
|
||||
}
|
||||
if (state === "complete") entry.timer = setTimeout(() => {
|
||||
entry.badge.setAttribute("data-fading", "true");
|
||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
|
||||
}, 5000);
|
||||
}
|
||||
return { update };
|
||||
}
|
||||
63
src/features/opportunities/badge-content.js
Normal file
63
src/features/opportunities/badge-content.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
|
||||
* https://github.com/lucide-icons/lucide (ISC License)
|
||||
* Copyright (c) 2026 Lucide Icons and Contributors
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
function createOpportunityBadgeContent({ environment }) {
|
||||
const shapes = {
|
||||
products: [
|
||||
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
|
||||
["path", { d: "M12 22V12" }],
|
||||
["polyline", { points: "3.29 7 12 12 20.71 7" }],
|
||||
["path", { d: "m7.5 4.27 9 5.15" }]
|
||||
],
|
||||
ramp: [
|
||||
["path", { d: "M5 21v-6" }],
|
||||
["path", { d: "M12 21V9" }],
|
||||
["path", { d: "M19 21V3" }]
|
||||
],
|
||||
sr: [
|
||||
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
|
||||
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
|
||||
["path", { d: "M12 11h4" }],
|
||||
["path", { d: "M12 16h4" }],
|
||||
["path", { d: "M8 11h.01" }],
|
||||
["path", { d: "M8 16h.01" }]
|
||||
],
|
||||
actions: [
|
||||
["path", { d: "M13 5h8" }],
|
||||
["path", { d: "M13 12h8" }],
|
||||
["path", { d: "M13 19h8" }],
|
||||
["path", { d: "m3 17 2 2 4-4" }],
|
||||
["path", { d: "m3 7 2 2 4-4" }]
|
||||
]
|
||||
};
|
||||
function setContent(badge, kind, value) {
|
||||
badge.textContent = String(value);
|
||||
const namespace = "http://www.w3.org/2000/svg";
|
||||
const icon = environment.document.createElementNS(namespace, "svg");
|
||||
for (const [name, attribute] of Object.entries({
|
||||
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
|
||||
width: "16", height: "16", fill: "none", stroke: "currentColor",
|
||||
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
|
||||
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
|
||||
})) icon.setAttribute(name, attribute);
|
||||
for (const [tag, attributes] of shapes[kind]) {
|
||||
const shape = environment.document.createElementNS(namespace, tag);
|
||||
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
|
||||
icon.append(shape);
|
||||
}
|
||||
badge.append(icon);
|
||||
}
|
||||
return { setContent };
|
||||
}
|
||||
130
src/features/opportunities/calendar.css
Normal file
130
src/features/opportunities/calendar.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.opportunities-extension-views { min-height: 0; min-width: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; }
|
||||
.opportunities-extension-views > [hidden] { display: none; }
|
||||
.opportunities-extension-view-tabs { display: flex; gap: 20px; border-bottom: 1px solid #dedbd7; }
|
||||
.opportunities-extension-view-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: #625d58; padding: 6px 2px 9px; font: 600 13px/20px Arial, sans-serif; cursor: pointer; }
|
||||
.opportunities-extension-view-tabs button[aria-selected="true"] { border-bottom-color: #00758f; color: #00657b; }
|
||||
.opportunities-extension-view-tabs button:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
|
||||
.opportunities-extension-view-tabs button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.opportunities-extension-view-tabs button svg { width: 16px; height: 16px; flex: 0 0 16px; }
|
||||
.opportunities-extension-calendar {
|
||||
--fc-border-color: #dedbd7; --fc-page-bg-color: #fff; --fc-neutral-bg-color: #f5f4f2;
|
||||
--fc-neutral-text-color: #625d58; --fc-today-bg-color: #eef7fa;
|
||||
--fc-button-bg-color: #fff; --fc-button-text-color: #312d2a; --fc-button-border-color: #b9b4ae;
|
||||
--fc-button-hover-bg-color: #e6f3f5; --fc-button-hover-border-color: #00758f;
|
||||
--fc-button-active-bg-color: #dceff3; --fc-button-active-border-color: #00758f;
|
||||
min-height: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; padding: 12px; background: #fff; color: #312d2a;
|
||||
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
|
||||
}
|
||||
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
|
||||
.opportunities-calendar-day-cards { position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; padding-bottom: 4px; }
|
||||
.opportunities-calendar-day-card { display: block; width: calc(100% - 8px); padding: 0; text-align: left; font: inherit; }
|
||||
.opportunities-calendar-day-card:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
|
||||
/* A single vertical sequence of months, with a separate scroll area per day. */
|
||||
.opportunities-extension-calendar .fc .fc-multimonth { overflow-y: auto; overflow-x: hidden; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth-month { width: 100%; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth-daygrid-table { height: auto !important; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tbody { height: auto; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tr { height: var(--calendar-week-height, 190px); }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-frame { position: relative; height: var(--calendar-week-height, 190px); min-height: var(--calendar-week-height, 190px); }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-events {
|
||||
position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden;
|
||||
margin: 0; padding-bottom: 4px; scrollbar-width: thin;
|
||||
}
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-event-harness { position: relative !important; top: auto !important; left: auto !important; right: auto !important; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-bg { pointer-events: none; }
|
||||
.opportunities-extension-calendar .fc { min-width: 620px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
|
||||
.opportunities-extension-calendar .fc .fc-button { font-size: 13px; line-height: 20px; padding: 5px 10px; border-radius: 4px; box-shadow: none; }
|
||||
.opportunities-extension-calendar .fc .fc-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||
.opportunities-extension-calendar .fc .fc-button svg { width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; }
|
||||
.opportunities-extension-calendar .fc a { color: inherit; text-decoration: none; }
|
||||
.opportunities-extension-calendar .fc th { background: var(--fc-neutral-bg-color); font-size: 12px; font-weight: 600; padding: 4px 0; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day-number { font-size: 11px; line-height: 18px; padding: 1px 6px; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day-events { min-height: 0; margin-bottom: 0; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day-bottom { margin: 0; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { min-height: 0; padding-top: 0; }
|
||||
/* Event content must not contribute to the table's intrinsic row heights. */
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-body tbody { height: 100%; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-body tr { height: calc(100% / var(--opportunities-calendar-weeks, 5)); }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day { position: relative; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { position: absolute; inset: 0; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-week-number { top: 0; left: 0; padding: 3px 6px; background: var(--fc-neutral-bg-color); color: inherit; font: 600 10px/16px Arial, sans-serif; white-space: nowrap; }
|
||||
.opportunities-calendar-day-count { position: absolute; top: 2px; left: 6px; min-width: 18px; height: 16px; padding: 0 4px; border-radius: 8px; background: #dceff3; color: #005467; font: 600 10px/16px Arial, sans-serif; text-align: center; }
|
||||
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 82px; }
|
||||
.opportunities-calendar-day-count[hidden] { display: none; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-count:not([data-empty="true"]) { background: #16454f; color: #b9eaf4; }
|
||||
.opportunities-extension-calendar .fc .fc-day-other { background: var(--fc-neutral-bg-color); }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-event { white-space: normal; margin: 3px 4px; border-radius: 4px; cursor: pointer; border-width: 0 0 0 3px; border-style: solid; }
|
||||
.opportunities-extension-calendar .fc .fc-event-main { color: inherit; }
|
||||
.opportunities-calendar-event-content { min-width: 0; padding: 5px 6px; overflow-wrap: anywhere; }
|
||||
.opportunities-calendar-event-content strong { display: block; font-size: 12px; line-height: 16px; font-weight: 600; }
|
||||
.opportunities-calendar-event-content span { display: block; margin-top: 3px; font-size: 11px; line-height: 15px; }
|
||||
.opportunities-calendar-event-content strong,
|
||||
.opportunities-calendar-event-content span,
|
||||
.opportunities-calendar-day-item strong,
|
||||
.opportunities-calendar-day-item span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-more-link { color: #00657b; font-size: 11px; line-height: 16px; padding: 0 5px; }
|
||||
.opportunities-calendar-day-list { min-height: 0; overflow-y: auto; padding: 8px 20px 20px; }
|
||||
.opportunities-calendar-day-item { display: block; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid #dedbd7; background: transparent; color: #00657b; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
||||
.opportunities-calendar-day-item strong, .opportunities-calendar-day-item span { display: block; font: 600 13px/20px Arial, sans-serif; }
|
||||
.opportunities-calendar-day-item span { margin-top: 4px; font-weight: 400; }
|
||||
.opportunities-calendar-day-item:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-item { border-bottom-color: #514d48; color: #80d6e8; }
|
||||
.opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #dff1f4; border-color: #00829d; color: #005467; }
|
||||
.opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
|
||||
.opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #efeeec; border-color: #706a64; color: #49443f; }
|
||||
.opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #fff0da; border-color: #ba7000; color: #774700; }
|
||||
.opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #f0e8f7; border-color: #8262a3; color: #624379; }
|
||||
.opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #e2f1e5; border-color: #2f8050; color: #215939; }
|
||||
.opportunities-calendar-message { margin: 0 0 12px; font-size: 13px; }
|
||||
.opportunities-calendar-message[hidden], .opportunities-calendar-grid[hidden], .opportunities-calendar-undated[hidden] { display: none; }
|
||||
.opportunities-calendar-undated { flex: 0 0 auto; max-height: 90px; overflow-y: auto; margin-top: 6px; border-top: 1px solid var(--fc-border-color); padding-top: 4px; }
|
||||
.opportunities-calendar-undated summary { cursor: pointer; font-size: 12px; line-height: 18px; }
|
||||
.opportunities-calendar-undated h3 { font-size: 13px; margin: 0 0 8px; }
|
||||
.opportunities-calendar-undated button { display: block; background: none; border: 0; text-align: left; font: inherit; padding: 6px 0; color: inherit; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; }
|
||||
.opportunities-calendar-detail .opportunities-extension-account-plans-header { align-items: start; gap: 16px; }
|
||||
.opportunities-calendar-detail .opportunities-extension-action-form-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
|
||||
.opportunities-calendar-detail h2 { font-size: 18px; overflow-wrap: anywhere; }
|
||||
.opportunities-calendar-detail-content { padding: 20px; overflow-y: auto; }
|
||||
.opportunities-calendar-detail-content a { color: #006b82; }
|
||||
.opportunities-calendar-detail-badges { display: flex; gap: 8px; margin-top: 18px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs { border-color: #514d48; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button { color: #c7c2bc; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button[aria-selected="true"] { color: #80d6e8; border-bottom-color: #80d6e8; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar {
|
||||
--fc-border-color: #514d48; --fc-page-bg-color: #292725; --fc-neutral-bg-color: #33302d;
|
||||
--fc-neutral-text-color: #c7c2bc; --fc-today-bg-color: #223b3f;
|
||||
--fc-button-bg-color: #292725; --fc-button-text-color: #eeeae6; --fc-button-border-color: #756f68;
|
||||
--fc-button-hover-bg-color: #234850; --fc-button-hover-border-color: #80d6e8;
|
||||
--fc-button-active-bg-color: #234850; --fc-button-active-border-color: #80d6e8;
|
||||
background: #292725; color: #eeeae6;
|
||||
}
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #16454f; color: #b9eaf4; border-color: #67cde0; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #403c38; color: #e4dfd9; border-color: #b8b1a8; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #533e1e; color: #ffe0a9; border-color: #dba44f; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #493657; color: #ead5fc; border-color: #b998d8; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #244a32; color: #c7edce; border-color: #84c396; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .fc-daygrid-more-link,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-detail-content a { color: #80d6e8; }
|
||||
@media (max-width: 700px) {
|
||||
.opportunities-extension-body { overflow-y: auto; grid-template-rows: max-content max-content minmax(480px, 1fr); }
|
||||
.opportunities-extension-views { min-height: 480px; }
|
||||
.opportunities-extension-calendar { padding: 10px; overflow-x: auto; }
|
||||
.opportunities-extension-calendar .fc { min-width: 0; }
|
||||
.opportunities-extension-calendar .fc .fc-view-harness { min-width: 560px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 16px; }
|
||||
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { padding-top: 22px; }
|
||||
.opportunities-calendar-day-count { top: 25px; }
|
||||
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
|
||||
}
|
||||
.opportunities-view-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--oe-table-heading-border); flex-wrap: wrap; }
|
||||
.opportunities-view-bar .opportunities-extension-view-tabs { border-bottom: 0; }
|
||||
.opportunities-product-type-filters { display: flex; gap: 8px; margin-left: auto; padding: 4px 0; }
|
||||
.opportunities-product-type-filter { border: 1px solid #8f8a85; border-radius: 12px; padding: 3px 9px; background: transparent; color: inherit; font: 600 12px/16px Arial, sans-serif; cursor: pointer; }
|
||||
.opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #714016; background: #fff0df; border-color: #c58b50; }
|
||||
.opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #63358a; background: #f0e8f7; border-color: #bba5cf; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #ead5fc; background: #43334f; border-color: #9372ac; }
|
||||
.opportunities-product-type-filter:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
349
src/features/opportunities/calendar.js
Normal file
349
src/features/opportunities/calendar.js
Normal file
@@ -0,0 +1,349 @@
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||
productTypeSelection, onProductTypeChange, createPrintButton }) {
|
||||
const { document } = environment;
|
||||
let elements = null;
|
||||
let calendar = null;
|
||||
let library = null;
|
||||
let selectedView = "overview";
|
||||
let anchoredVersion = null;
|
||||
let details = null;
|
||||
let lastEvents = "";
|
||||
let dayCounts = new Map();
|
||||
let dayEvents = new Map();
|
||||
const dayBadges = new Map();
|
||||
let resizeObserver = null;
|
||||
let layoutFrame = null;
|
||||
let positionToday = false;
|
||||
function scheduleLayout() {
|
||||
const win = environment.window;
|
||||
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
|
||||
layoutFrame = win.requestAnimationFrame(() => {
|
||||
layoutFrame = null;
|
||||
if (calendar && selectedView === "calendar") {
|
||||
calendar.updateSize();
|
||||
if (positionToday && !elements.calendarHost.hidden) {
|
||||
const scroller = elements.calendarHost.querySelector(".fc-multimonth");
|
||||
if (!scroller?.clientHeight) return;
|
||||
const today = dateKey(new Date());
|
||||
const range = getRange();
|
||||
const cell = range && today >= range.startCloseDate.slice(0, 10) && today < range.endCloseDate.slice(0, 10)
|
||||
? elements.calendarHost.querySelector(`[data-date="${today}"]:not(.fc-day-other)`)
|
||||
: null;
|
||||
if (cell) {
|
||||
const first = new Date(`${today}T12:00:00`);
|
||||
first.setDate(first.getDate() - first.getDay() - 7);
|
||||
const last = new Date(first);
|
||||
last.setDate(last.getDate() + 20);
|
||||
const start = dateKey(first), end = dateKey(last);
|
||||
// A week crossing a month boundary has two rendered rows. Include both.
|
||||
const rows = [...scroller.querySelectorAll(".fc-daygrid-body tr")].filter(row =>
|
||||
[...row.querySelectorAll("[data-date]:not(.fc-day-other)")].some(day => {
|
||||
const date = day.getAttribute("data-date");
|
||||
return date >= start && date <= end;
|
||||
}));
|
||||
const headings = [...new Set(rows.map(row => row.closest(".fc-multimonth-month")?.querySelector(".fc-multimonth-header")))].filter(Boolean);
|
||||
const headerSpace = headings.reduce((sum, header) => sum + header.getBoundingClientRect().height, 0);
|
||||
const height = Math.max(48, Math.floor((scroller.clientHeight - headerSpace - 20) / Math.max(1, rows.length)));
|
||||
elements.calendarHost.style.setProperty("--calendar-week-height", `${height}px`);
|
||||
const target = rows[0] || cell;
|
||||
const inset = (headings[0]?.getBoundingClientRect().height || 0) + 8;
|
||||
scroller.scrollTop += target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - inset;
|
||||
} else scroller.scrollTop = 0;
|
||||
positionToday = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function dateKey(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
function fiscalWeekLabel(date) {
|
||||
// FullCalendar's week hook supplies a UTC date marker, not a local instant.
|
||||
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
||||
const end = new Date(start.getTime() + 6 * 86400000);
|
||||
const year = end.getUTCMonth() >= 5 ? end.getUTCFullYear() : end.getUTCFullYear() - 1;
|
||||
const fiscalStart = new Date(Date.UTC(year, 5, 1));
|
||||
const firstSunday = fiscalStart.getTime() - fiscalStart.getUTCDay() * 86400000;
|
||||
const representative = start < fiscalStart ? fiscalStart : start;
|
||||
const quarter = Math.floor(((representative.getUTCMonth() + 7) % 12) / 3) + 1;
|
||||
const week = Math.round((start.getTime() - firstSunday) / (7 * 86400000)) + 1;
|
||||
return `FY${String(year + 1).slice(-2)}-Q${quarter}-W${week}`;
|
||||
}
|
||||
function updateDayBadge(badge, key) {
|
||||
const count = dayCounts.get(key) || 0;
|
||||
badge.hidden = count === 0;
|
||||
badge.textContent = String(count);
|
||||
badge.title = `${count} ${count === 1 ? "opportunity" : "opportunities"}`;
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
badge.setAttribute("data-empty", String(count === 0));
|
||||
}
|
||||
function renderDayCards(container, key) {
|
||||
container.textContent = "";
|
||||
for (const event of dayEvents.get(key) || []) {
|
||||
const item = event.extendedProps.item;
|
||||
const button = node("button", `opportunities-calendar-day-card fc-daygrid-event ${event.classNames.join(" ")}`);
|
||||
button.type = "button";
|
||||
const card = node("div", "opportunities-calendar-event-content");
|
||||
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
|
||||
card.append(node("strong", "", event.title), node("span", "", summary));
|
||||
button.title = `${event.title}\n${summary}`;
|
||||
button.append(card);
|
||||
button.addEventListener("click", () => showDetails(item, button));
|
||||
container.append(button);
|
||||
}
|
||||
}
|
||||
function node(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
function closeDate(value) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) return null;
|
||||
const day = value.slice(0, 10);
|
||||
const date = new Date(`${day}T00:00:00Z`);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === day ? day : null;
|
||||
}
|
||||
function eventsFor(items) {
|
||||
return items.flatMap((item, index) => {
|
||||
const start = closeDate(item.EffectiveDate);
|
||||
if (!start) return [];
|
||||
return [{ id: String(item.OptyNumber || item.OptyId || index), title: String(item.Name || item.OptyNumber || "-"),
|
||||
start, allDay: true, extendedProps: { item }, classNames: [`opportunities-calendar-stage-${stage(item)}`] }];
|
||||
});
|
||||
}
|
||||
function stage(item) { return ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"].includes(item.ForecastGroup_c) ? item.ForecastGroup_c : "OTHER"; }
|
||||
function initialDate(items, now = new Date()) {
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
const dates = items.map(item => closeDate(item.EffectiveDate)).filter(Boolean).sort();
|
||||
return dates.find(date => date >= today) || dates.at(-1) || today;
|
||||
}
|
||||
function closeDetails(restoreFocus = true) {
|
||||
if (!details) return false;
|
||||
const previous = details;
|
||||
details = null;
|
||||
previous.overlay.remove();
|
||||
if (restoreFocus) (previous.trigger?.isConnected ? previous.trigger : elements?.tabs.calendar)?.focus();
|
||||
return true;
|
||||
}
|
||||
function showDialog(heading, content, contentControls, trigger) {
|
||||
closeDetails(false);
|
||||
const host = document.getElementById(modalId);
|
||||
if (!host || host.hidden) return;
|
||||
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-calendar-detail");
|
||||
overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "opportunities-calendar-detail-title");
|
||||
const panel = node("div", "opportunities-extension-action-form-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header");
|
||||
const title = node("h2", "", heading);
|
||||
title.id = "opportunities-calendar-detail-title";
|
||||
const dismiss = node("button", "opportunities-extension-icon-button");
|
||||
dismiss.type = "button"; dismiss.title = "Close"; dismiss.setAttribute("aria-label", "Close opportunity details");
|
||||
dismiss.append(createCloseIcon()); dismiss.addEventListener("click", () => closeDetails());
|
||||
header.append(title, dismiss);
|
||||
panel.append(header, content); overlay.append(panel); host.append(overlay);
|
||||
details = { overlay, trigger };
|
||||
overlay.addEventListener("click", event => { if (event.target === overlay) closeDetails(); });
|
||||
overlay.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeDetails(); }
|
||||
if (event.key === "Tab") {
|
||||
const controls = [dismiss, ...contentControls];
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
|
||||
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
dismiss.focus();
|
||||
}
|
||||
function showDetails(item, trigger) {
|
||||
const content = node("div", "opportunities-calendar-detail-content");
|
||||
const link = createDetailLink(item.OptyNumber || "-", item.OptyNumber ? opportunityUrl + encodeURIComponent(item.OptyNumber) : "");
|
||||
const identity = node("div", "opportunities-extension-opty-number-content");
|
||||
identity.append(link);
|
||||
const copy = item.OptyNumber ? createCopyButton(item.OptyNumber) : null;
|
||||
if (copy) identity.append(copy);
|
||||
const summary = node("dl", "opportunities-extension-action-form-context");
|
||||
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
|
||||
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/A"], ["Amount", formatRevenue(item)]]) {
|
||||
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
|
||||
}
|
||||
const badges = node("div", "opportunities-calendar-detail-badges");
|
||||
badges.append(createStatusBadge(item.StatusCode), createStageBadge(item.ForecastGroup_c));
|
||||
content.append(identity, summary, badges);
|
||||
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
|
||||
}
|
||||
function mount(body, tableSurface) {
|
||||
resizeObserver?.disconnect();
|
||||
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
|
||||
layoutFrame = null;
|
||||
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
|
||||
dayBadges.clear(); dayCounts.clear();
|
||||
closeDetails(false);
|
||||
const workspace = node("section", "opportunities-extension-views");
|
||||
const tablist = node("div", "opportunities-extension-view-tabs");
|
||||
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Opportunity views");
|
||||
const tabs = {};
|
||||
const surface = node("section", "opportunities-extension-calendar");
|
||||
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
|
||||
const calendarHost = node("div", "opportunities-calendar-grid");
|
||||
const undated = node("details", "opportunities-calendar-undated");
|
||||
surface.append(message, calendarHost, undated);
|
||||
for (const [key, label, panel] of [["overview", "Overview", tableSurface], ["calendar", "Calendar", surface]]) {
|
||||
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
|
||||
tab.append(createViewIcon(key), node("span", "", label));
|
||||
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
|
||||
tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", panel.id);
|
||||
tab.addEventListener("click", () => selectView(key));
|
||||
tab.addEventListener("keydown", event => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? "overview" : event.key === "End" ? "calendar" : key === "overview" ? "calendar" : "overview";
|
||||
selectView(next); tabs[next].focus();
|
||||
});
|
||||
tabs[key] = tab; tablist.append(tab);
|
||||
}
|
||||
const viewbar = node("div", "opportunities-view-bar");
|
||||
viewbar.append(tablist);
|
||||
if (productTypeSelection) {
|
||||
const filters = node("div", "opportunities-product-type-filters");
|
||||
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
||||
for (const type of ["Booking", "Workload"]) {
|
||||
const button = node("button", "opportunities-product-type-filter", type);
|
||||
button.type = "button";
|
||||
button.setAttribute("data-type", type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
button.addEventListener("click", () => {
|
||||
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
onProductTypeChange();
|
||||
});
|
||||
filters.append(button);
|
||||
}
|
||||
if (createPrintButton) filters.append(createPrintButton());
|
||||
viewbar.append(filters);
|
||||
}
|
||||
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
||||
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
|
||||
if (environment.window?.ResizeObserver) {
|
||||
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
|
||||
resizeObserver.observe(calendarHost);
|
||||
}
|
||||
selectView("overview");
|
||||
}
|
||||
function selectView(view) {
|
||||
if (!elements || !["overview", "calendar"].includes(view)) return;
|
||||
if (view === "calendar" && selectedView !== "calendar") positionToday = true;
|
||||
closeDetails(false); closeSrPopover(); selectedView = view;
|
||||
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
|
||||
for (const [key, tab] of Object.entries(elements.tabs)) {
|
||||
tab.setAttribute("aria-selected", String(view === key)); tab.tabIndex = view === key ? 0 : -1;
|
||||
}
|
||||
render(getItems(), getState());
|
||||
if (view === "calendar") calendar?.updateSize();
|
||||
scheduleLayout();
|
||||
}
|
||||
function render(items, state) {
|
||||
if (!elements || selectedView !== "calendar") return;
|
||||
const { surface, message, calendarHost, undated } = elements;
|
||||
surface.setAttribute("aria-busy", String(state.status === "loading"));
|
||||
if (["loading", "error", "idle"].includes(state.status)) {
|
||||
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
|
||||
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
|
||||
}
|
||||
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
|
||||
calendarHost.hidden = false;
|
||||
const range = getRange();
|
||||
if (!range) { calendarHost.hidden = true; return; }
|
||||
const start = range.startCloseDate.slice(0, 10);
|
||||
const end = range.endCloseDate.slice(0, 10);
|
||||
const visibleRange = { start, end }; // Fiscal ranges end at the next period's start (exclusive).
|
||||
const events = eventsFor(items).filter(event => event.start >= start && event.start < end);
|
||||
dayCounts = new Map();
|
||||
dayEvents = new Map();
|
||||
for (const event of events) {
|
||||
dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
|
||||
if (!dayEvents.has(event.start)) dayEvents.set(event.start, []);
|
||||
dayEvents.get(event.start).push(event);
|
||||
}
|
||||
for (const { badge, key, cards } of dayBadges.values()) { updateDayBadge(badge, key); renderDayCards(cards, key); }
|
||||
const anchor = start;
|
||||
if (!calendar) {
|
||||
try {
|
||||
library ||= loadLibrary();
|
||||
calendar = new library.Calendar(calendarHost, {
|
||||
initialView: "referenceMonths", initialDate: anchor, height: "100%", fixedWeekCount: false,
|
||||
views: { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } },
|
||||
visibleRange, validRange: visibleRange, showNonCurrentDates: false,
|
||||
multiMonthMaxColumns: 1, multiMonthMinWidth: 0,
|
||||
multiMonthTitleFormat: { month: "long", year: "numeric" },
|
||||
datesSet: scheduleLayout,
|
||||
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
|
||||
headerToolbar: false,
|
||||
dayMaxEvents: false, dayMaxEventRows: false, displayEventTime: false,
|
||||
weekNumbers: true,
|
||||
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
|
||||
dayCellDidMount: ({ el, date }) => {
|
||||
const badge = node("span", "opportunities-calendar-day-count");
|
||||
const key = dateKey(date);
|
||||
updateDayBadge(badge, key);
|
||||
const cards = node("div", "opportunities-calendar-day-cards");
|
||||
cards.tabIndex = 0;
|
||||
cards.setAttribute("role", "region");
|
||||
cards.setAttribute("aria-label", `Opportunities for ${formatDate(key)}`);
|
||||
renderDayCards(cards, key);
|
||||
el.querySelector(".fc-daygrid-day-frame").append(badge, cards);
|
||||
dayBadges.set(el, { badge, key, cards });
|
||||
},
|
||||
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.get(el)?.cards.remove(); dayBadges.delete(el); },
|
||||
// MultiMonth imposes its own event limit. Render all cards in our scrollable day region.
|
||||
eventOrder: "title", eventDisplay: "none", events,
|
||||
eventContent: ({ event }) => {
|
||||
const item = event.extendedProps.item;
|
||||
const card = node("div", "opportunities-calendar-event-content");
|
||||
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`);
|
||||
summary.title = summary.textContent;
|
||||
card.append(node("strong", "", event.title), summary);
|
||||
return { domNodes: [card] };
|
||||
},
|
||||
eventDidMount: ({ el, event }) => {
|
||||
el.title = `${event.title}\n${event.extendedProps.item.CustomerAccount?.PartyUniqueName || ""}\n${formatDate(event.startStr)} | ${event.extendedProps.item.ForecastGroup_c || "-"}`;
|
||||
el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title);
|
||||
},
|
||||
eventClick: ({ event, el, jsEvent }) => { jsEvent.preventDefault(); showDetails(event.extendedProps.item, el); }
|
||||
});
|
||||
calendar.render(); lastEvents = JSON.stringify(events); anchoredVersion = getVersion();
|
||||
} catch {
|
||||
calendar?.destroy(); calendar = null; message.textContent = "Unable to display the calendar. Use the overview or try again.";
|
||||
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; return;
|
||||
}
|
||||
}
|
||||
const signature = JSON.stringify(events);
|
||||
calendar.batchRendering(() => {
|
||||
if (anchoredVersion !== getVersion()) {
|
||||
positionToday = true;
|
||||
calendar.setOption("views", { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } });
|
||||
calendar.setOption("visibleRange", visibleRange);
|
||||
calendar.setOption("validRange", visibleRange);
|
||||
calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false);
|
||||
const scroller = calendarHost.querySelector?.(".fc-multimonth");
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
}
|
||||
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
|
||||
});
|
||||
const missing = items.filter(item => !closeDate(item.EffectiveDate));
|
||||
undated.hidden = !missing.length; undated.textContent = "";
|
||||
if (missing.length) {
|
||||
undated.append(node("summary", "", `No valid Close Date (${missing.length})`));
|
||||
for (const item of missing) {
|
||||
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
|
||||
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
|
||||
}
|
||||
}
|
||||
scheduleLayout();
|
||||
}
|
||||
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel };
|
||||
}
|
||||
14
src/features/opportunities/columns.js
Normal file
14
src/features/opportunities/columns.js
Normal file
@@ -0,0 +1,14 @@
|
||||
function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) {
|
||||
return [
|
||||
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
|
||||
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
|
||||
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]) },
|
||||
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
|
||||
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
|
||||
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
|
||||
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
|
||||
{ key: "winProbability", label: "Prob.", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
|
||||
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
|
||||
];
|
||||
}
|
||||
158
src/features/opportunities/controller.js
Normal file
158
src/features/opportunities/controller.js
Normal file
@@ -0,0 +1,158 @@
|
||||
function createOpportunitiesController({
|
||||
STAGE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
authSessionState,
|
||||
consumptionPlansState,
|
||||
consumptionTrendState,
|
||||
getFiscalPeriodRange,
|
||||
opportunitiesDashboardState,
|
||||
opportunitiesFilterState,
|
||||
opportunitiesQueryState,
|
||||
opportunitiesTableState,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
renderProductsProgress,
|
||||
requestOpportunitiesPage,
|
||||
resetCrmServiceRequests,
|
||||
resetWorkloadActions,
|
||||
resetWorkloads,
|
||||
requestOpptyProductsInBackground,
|
||||
requestTokenRelayOnce,
|
||||
ensureCurrentUser,
|
||||
setOpportunitiesTableState
|
||||
}) {
|
||||
async function requestOpportunitiesForPeriod(period) {
|
||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||
opportunitiesQueryState.dateRange = dateRange;
|
||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||
resetWorkloadActions();
|
||||
resetWorkloads();
|
||||
await ensureCurrentUser();
|
||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||
|
||||
if (preferencesState.selectedOpportunityTypeValues.size === 0) {
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "empty",
|
||||
message: "Select at least one Opportunity Type View in Preferences."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dateRange) {
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "empty",
|
||||
message: "No opportunities to display for this period."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authSessionState.accessToken || await requestTokenRelayOnce();
|
||||
|
||||
if (requestVersion !== opportunitiesQueryState.periodRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestOpportunities(token, period, dateRange, requestVersion);
|
||||
} catch (error) {
|
||||
// Authentication and request errors are recorded by their respective request handlers.
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
||||
if (isCurrentPeriodRequest(requestVersion)) {
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsProgress = {
|
||||
state: "idle",
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
cacheHits: 0
|
||||
};
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
renderProductsProgress();
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "loading",
|
||||
message: "Loading opportunities..."
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
||||
|
||||
if (allItems && isCurrentPeriodRequest(requestVersion)) {
|
||||
setOpportunitiesTableState({
|
||||
items: allItems,
|
||||
status: "ready",
|
||||
message: ""
|
||||
});
|
||||
requestOpptyProductsInBackground(allItems, token);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCurrentPeriodRequest(requestVersion)) {
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "error",
|
||||
message: "Unable to load opportunities."
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resetOpportunitiesTable() {
|
||||
resetWorkloadActions();
|
||||
resetWorkloads();
|
||||
resetCrmServiceRequests();
|
||||
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set();
|
||||
opportunitiesFilterState.customerSearch = "";
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set();
|
||||
opportunitiesFilterState.ownerSearch = "";
|
||||
opportunitiesFilterState.tableSearch = "";
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = false;
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = false;
|
||||
opportunitiesFilterState.customerFilterInitialized = false;
|
||||
opportunitiesFilterState.ownerFilterInitialized = false;
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsProgress = {
|
||||
state: "idle",
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
cacheHits: 0
|
||||
};
|
||||
productsContentState.productsLastCachedAt = 0;
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
|
||||
opportunitiesTableState.opportunitiesTableState = {
|
||||
items: [],
|
||||
status: "idle",
|
||||
message: "",
|
||||
sortKey: "",
|
||||
sortDirection: "ascending"
|
||||
};
|
||||
}
|
||||
|
||||
function isCurrentPeriodRequest(requestVersion) {
|
||||
return requestVersion === opportunitiesQueryState.periodRequestVersion;
|
||||
}
|
||||
|
||||
return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest };
|
||||
}
|
||||
98
src/features/opportunities/crm-service-requests.js
Normal file
98
src/features/opportunities/crm-service-requests.js
Normal file
@@ -0,0 +1,98 @@
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
|
||||
let status = "idle";
|
||||
let byOpportunity = new Map();
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
let loadedItems = [];
|
||||
function reset() { closeSrForm(); closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
|
||||
function load(token, version, cacheOptions = null) {
|
||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||
if (status === "ready") {
|
||||
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
|
||||
}
|
||||
if (!pending) {
|
||||
const request = run(token, version, generation, cacheOptions);
|
||||
pending = request;
|
||||
request.finally(() => { if (pending === request) pending = null; });
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
async function run(token, version, currentGeneration, cacheOptions) {
|
||||
const isActive = () => currentGeneration === generation;
|
||||
status = "loading";
|
||||
try {
|
||||
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||
if (!isActive()) return false;
|
||||
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
|
||||
let items = cached?.items;
|
||||
if (!items) {
|
||||
reportProgress("running", 0);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||
if (isActive()) reportProgress("running", count, total);
|
||||
});
|
||||
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||
}
|
||||
if (!items || !isActive()) return false;
|
||||
const grouped = new Map();
|
||||
for (const item of items) {
|
||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||
if (!number) continue;
|
||||
const key = String(number);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(item);
|
||||
}
|
||||
byOpportunity = grouped;
|
||||
loadedItems = items;
|
||||
status = "ready";
|
||||
if (!cached) reportProgress("complete", items.length, items.length);
|
||||
return true;
|
||||
} catch {
|
||||
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function getSummary(optyNumber) {
|
||||
if (!allowsCrmActivities() || !optyNumber) return null;
|
||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||
const hours = items.map(item => item.ReportedHours_c).filter(value =>
|
||||
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
|
||||
).map(Number);
|
||||
return {
|
||||
state: status === "ready" ? items.length ? "matched" : "missing" : status,
|
||||
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
|
||||
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
|
||||
};
|
||||
}
|
||||
function createBadge(optyNumber) {
|
||||
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
||||
badge.className = "opportunities-extension-crm-sr-badge";
|
||||
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
||||
const summary = getSummary(optyNumber);
|
||||
const reportedHours = summary.reportedHours !== null
|
||||
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||
: "N/A";
|
||||
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
|
||||
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
|
||||
const descriptions = items.map(item => [
|
||||
`Title: ${display(item.Title)}`,
|
||||
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
||||
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
||||
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
|
||||
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/A"}`,
|
||||
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||
].join("\n"));
|
||||
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
|
||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
||||
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||
badge.setAttribute("aria-label", description);
|
||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||
badge.tabIndex = 0;
|
||||
return badge;
|
||||
}
|
||||
return { reset, load, createBadge, getSummary };
|
||||
}
|
||||
347
src/features/opportunities/crm-sr-form.js
Normal file
347
src/features/opportunities/crm-sr-form.js
Normal file
@@ -0,0 +1,347 @@
|
||||
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, requestSave, notify, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
function node(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
if (!active) return false;
|
||||
if (active.saving) return true;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.modal.remove();
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function createButton(number) {
|
||||
const button = node("button", "opportunities-extension-add-action", "+");
|
||||
button.type = "button";
|
||||
button.title = "Add Service Request";
|
||||
button.setAttribute("aria-label", `Add Service Request for ${number}`);
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); open(number, button); });
|
||||
return button;
|
||||
}
|
||||
function open(number, trigger) {
|
||||
if (active?.saving) return;
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal");
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-sr-form-title");
|
||||
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-form-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header");
|
||||
const heading = node("div", "opportunities-extension-action-form-heading");
|
||||
const title = node("h2", "", "Add Service Request");
|
||||
title.id = "opportunities-extension-sr-form-title";
|
||||
heading.append(title, node("p", "", String(number)));
|
||||
const opportunity = getOpportunity(number);
|
||||
const details = node("dl", "opportunities-extension-action-form-context");
|
||||
for (const [label, value] of [
|
||||
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
|
||||
["Name", opportunity?.Name],
|
||||
["Close Date", formatDate(opportunity?.EffectiveDate)]
|
||||
]) {
|
||||
details.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "-" : String(value)));
|
||||
}
|
||||
heading.append(details);
|
||||
const dismiss = node("button", "opportunities-extension-icon-button");
|
||||
dismiss.type = "button";
|
||||
dismiss.setAttribute("aria-label", "Close Service Request form");
|
||||
dismiss.append(createCloseIcon());
|
||||
dismiss.addEventListener("click", () => close());
|
||||
header.append(heading, dismiss);
|
||||
const form = node("form", "opportunities-extension-action-form");
|
||||
form.noValidate = true;
|
||||
const fields = new Map();
|
||||
const combos = new Map();
|
||||
const loadingFields = new Set(["primaryPillar", "language", "platform", "services"]);
|
||||
const availableFields = new Set();
|
||||
let serviceItems = [];
|
||||
const serviceRadios = [];
|
||||
let serviceGroup;
|
||||
const field = node("label", "opportunities-extension-action-form-field");
|
||||
const select = node("select");
|
||||
select.id = "opportunities-extension-sr-primary-pillar";
|
||||
select.name = "primaryPillar";
|
||||
select.required = true;
|
||||
field.htmlFor = select.id;
|
||||
field.append(node("span", "", "Primary pillar (required)"), select);
|
||||
const autocomplete = searchableSelect(select, field);
|
||||
fields.set("primaryPillar", select);
|
||||
combos.set(select, autocomplete);
|
||||
const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
||||
status.setAttribute("role", "status");
|
||||
const footer = node("footer", "opportunities-extension-action-form-footer");
|
||||
const retry = node("button", "", "Retry");
|
||||
retry.type = "button";
|
||||
const cancel = node("button", "", "Cancel");
|
||||
const save = node("button", "opportunities-extension-action-form-save", "Save");
|
||||
save.type = "submit";
|
||||
save.disabled = true;
|
||||
cancel.type = "button";
|
||||
cancel.addEventListener("click", () => close());
|
||||
footer.append(retry, save, cancel);
|
||||
form.append(field);
|
||||
const extraControls = [];
|
||||
const extraLoads = [];
|
||||
function textField(name, label, type) {
|
||||
const wrapper = node("label", "opportunities-extension-action-form-field" + (type === "textarea" ? " opportunities-extension-action-form-wide" : ""));
|
||||
const input = node(type === "textarea" ? "textarea" : "input");
|
||||
if (type === "textarea") input.rows = 4;
|
||||
else input.type = type;
|
||||
input.id = `opportunities-extension-sr-${name}`;
|
||||
input.name = name;
|
||||
input.required = true;
|
||||
fields.set(name, input);
|
||||
wrapper.htmlFor = input.id;
|
||||
wrapper.append(node("span", "", `${label} (required)`), input);
|
||||
form.append(wrapper);
|
||||
extraControls.push(input);
|
||||
}
|
||||
textField("customerDeadline", "Customer deadline", "date");
|
||||
function lookupField(name, label, request, radios = false) {
|
||||
const wrapper = node(radios ? "fieldset" : "div", "opportunities-extension-sr-lookup" + (radios ? " opportunities-extension-action-form-wide" : ""));
|
||||
const content = node("div", radios ? "opportunities-extension-sr-services" : "");
|
||||
const message = node("p", "opportunities-extension-action-form-status");
|
||||
const retryOption = node("button", "opportunities-extension-sr-lookup-retry", "Retry");
|
||||
retryOption.type = "button";
|
||||
retryOption.setAttribute("aria-label", `Retry ${label}`);
|
||||
retryOption.hidden = true;
|
||||
extraControls.push(retryOption);
|
||||
let input, combo;
|
||||
if (radios) { wrapper.append(node("legend", "", `${label} (required)`)); serviceGroup = wrapper; }
|
||||
else {
|
||||
const field = node("label", "opportunities-extension-action-form-field");
|
||||
input = node("select");
|
||||
input.id = `opportunities-extension-sr-${name}`;
|
||||
input.name = name;
|
||||
input.required = true;
|
||||
fields.set(name, input);
|
||||
field.htmlFor = input.id;
|
||||
field.append(node("span", "", `${label} (required)`), input);
|
||||
combo = searchableSelect(input, field);
|
||||
combos.set(input, combo);
|
||||
content.append(field);
|
||||
extraControls.push(combo.input);
|
||||
}
|
||||
wrapper.append(content, message, retryOption);
|
||||
form.append(wrapper);
|
||||
let loading = false;
|
||||
async function loadOptions() {
|
||||
if (loading || !current()) return;
|
||||
loading = true;
|
||||
loadingFields.add(name);
|
||||
availableFields.delete(name);
|
||||
updateSave();
|
||||
retryOption.hidden = true;
|
||||
message.hidden = false;
|
||||
message.setAttribute("role", "status");
|
||||
message.textContent = `Loading ${label.toLowerCase()}...`;
|
||||
wrapper.setAttribute("aria-busy", "true");
|
||||
if (input) { input.disabled = true; input.textContent = ""; combo.sync(); }
|
||||
try {
|
||||
const items = await request();
|
||||
if (!current()) return;
|
||||
if (items.length) availableFields.add(name);
|
||||
if (input) {
|
||||
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
|
||||
blank.value = "";
|
||||
input.append(blank);
|
||||
items.forEach(item => { const option = node("option", "", item.name); option.value = item.code; input.append(option); });
|
||||
input.value = "";
|
||||
input.disabled = !items.length;
|
||||
} else {
|
||||
content.textContent = "";
|
||||
serviceItems = items;
|
||||
serviceRadios.length = 0;
|
||||
const seen = new Set();
|
||||
items.forEach((item, index) => {
|
||||
if (seen.has(String(item.Id))) return;
|
||||
seen.add(String(item.Id));
|
||||
const row = node("label", "opportunities-extension-sr-service-option");
|
||||
const radio = node("input");
|
||||
radio.type = "radio";
|
||||
radio.name = "service";
|
||||
radio.required = true;
|
||||
serviceRadios.push(radio);
|
||||
radio.value = String(item.Id);
|
||||
radio.id = `opportunities-extension-sr-service-${index}`;
|
||||
const copy = node("span");
|
||||
const abstract = node("span", "opportunities-extension-sr-service-abstract", item.Abstract_c || "");
|
||||
abstract.id = `${radio.id}-description`;
|
||||
radio.setAttribute("aria-describedby", abstract.id);
|
||||
row.htmlFor = radio.id;
|
||||
copy.append(node("span", "opportunities-extension-sr-service-name", item.RecordName), abstract);
|
||||
row.append(radio, copy);
|
||||
content.append(row);
|
||||
extraControls.push(radio);
|
||||
});
|
||||
}
|
||||
message.textContent = items.length ? "" : "No options available.";
|
||||
message.hidden = !!items.length;
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
message.setAttribute("role", "alert");
|
||||
message.textContent = error.message || `Unable to load ${label.toLowerCase()}.`;
|
||||
retryOption.hidden = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingFields.delete(name);
|
||||
if (current()) updateSave();
|
||||
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
|
||||
}
|
||||
}
|
||||
retryOption.addEventListener("click", loadOptions);
|
||||
extraLoads.push(loadOptions);
|
||||
}
|
||||
lookupField("language", "Language of delivery", requestLanguage);
|
||||
lookupField("platform", "Platform", requestPlatform);
|
||||
textField("description", "Description", "textarea");
|
||||
lookupField("services", "Services", requestServices, true);
|
||||
form.append(status, footer);
|
||||
dialog.append(header, form);
|
||||
modal.append(dialog);
|
||||
host.append(modal);
|
||||
const view = { modal, trigger, loading: false };
|
||||
active = view;
|
||||
const current = () => active === view && allowsCrmActivities();
|
||||
function updateSave() { save.disabled = loadingFields.size > 0 || availableFields.size < 4 || Boolean(view.saving); }
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
if (!current() || view.saving || save.disabled) return;
|
||||
const invalid = [];
|
||||
const value = name => String(fields.get(name)?.value || "").trim();
|
||||
for (const [name, input] of fields) {
|
||||
let valid = !!value(name);
|
||||
if (combos.has(input)) valid = valid && Array.from(input.children).some(option => option.value === value(name));
|
||||
if (name === "customerDeadline") {
|
||||
const date = value(name);
|
||||
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00Z`))
|
||||
&& new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date;
|
||||
}
|
||||
const control = combos.get(input)?.input || input;
|
||||
control.setAttribute("aria-invalid", String(!valid));
|
||||
if (!valid) invalid.push(control);
|
||||
}
|
||||
const selected = serviceRadios.find(radio => radio.checked);
|
||||
const service = serviceItems.find(item => String(item.Id) === selected?.value);
|
||||
serviceGroup.setAttribute("aria-invalid", String(!service));
|
||||
if (!service) invalid.push(serviceRadios[0]);
|
||||
if (invalid.length) {
|
||||
status.hidden = false;
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = "Complete the highlighted required fields with valid values.";
|
||||
invalid[0]?.focus();
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
AccountPartyId: opportunity?.CustomerAccount?.PartyId,
|
||||
OpportunityName_Id_c: opportunity?.OptyId,
|
||||
Title: `${service.RecordName}-${number}-${opportunity?.CustomerAccount?.PartyUniqueName || ""}`,
|
||||
RegionText_c: opportunity?.CustomerAccount?.Region_c,
|
||||
CountryText_c: opportunity?.CustomerAccount?.PrimaryAddress?.CountryCode,
|
||||
ServiceNew_Id_c: service.Id,
|
||||
ProblemDescription: value("description"), Language_c: value("language"),
|
||||
CustomerDeadline_c: value("customerDeadline"), TechPlatformLookUp_c: value("platform"),
|
||||
PrimaryPillarNew_c: value("primaryPillar")
|
||||
};
|
||||
view.saving = true;
|
||||
const controls = [dismiss, retry, cancel, ...fields.values(), ...serviceRadios, ...extraControls];
|
||||
const disabled = controls.map(control => [control, control.disabled]);
|
||||
controls.forEach(control => { control.disabled = true; });
|
||||
combos.forEach(combo => combo.sync());
|
||||
updateSave();
|
||||
save.textContent = "Preparing...";
|
||||
form.setAttribute("aria-busy", "true");
|
||||
status.hidden = true;
|
||||
try {
|
||||
const prepared = await requestSave({ phase: "prepare", payload });
|
||||
if (!current() || host.hidden) return;
|
||||
save.textContent = "Saving...";
|
||||
await requestSave({ phase: "confirm", payload: prepared.payload });
|
||||
view.saving = false;
|
||||
close();
|
||||
notify("Service Request saved successfully.");
|
||||
} catch (error) {
|
||||
status.hidden = false;
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message || "Unable to save Service Request.";
|
||||
notify(status.textContent, true);
|
||||
} finally {
|
||||
view.saving = false;
|
||||
if (current()) {
|
||||
disabled.forEach(([control, wasDisabled]) => { control.disabled = wasDisabled; });
|
||||
combos.forEach(combo => combo.sync());
|
||||
updateSave();
|
||||
save.textContent = "Save";
|
||||
form.setAttribute("aria-busy", "false");
|
||||
save.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
function option(value, text) { const item = node("option", "", text); item.value = value; select.append(item); }
|
||||
async function load() {
|
||||
if (view.loading || !current()) return;
|
||||
view.loading = true;
|
||||
loadingFields.add("primaryPillar");
|
||||
availableFields.delete("primaryPillar");
|
||||
updateSave();
|
||||
retry.hidden = true;
|
||||
select.disabled = true;
|
||||
select.textContent = "";
|
||||
option("", "Loading...");
|
||||
autocomplete.sync();
|
||||
form.setAttribute("data-state", "loading");
|
||||
form.setAttribute("aria-busy", "true");
|
||||
status.hidden = false;
|
||||
status.setAttribute("role", "status");
|
||||
status.textContent = "Loading primary pillars...";
|
||||
try {
|
||||
const items = await requestPillars();
|
||||
if (!current()) return;
|
||||
select.textContent = "";
|
||||
option("", items.length ? "Select primary pillar" : "No options available");
|
||||
items.forEach(item => option(item.code, item.name));
|
||||
select.value = "";
|
||||
select.disabled = !items.length;
|
||||
if (items.length) availableFields.add("primaryPillar");
|
||||
form.setAttribute("data-state", "ready");
|
||||
status.hidden = true;
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
form.setAttribute("data-state", "error");
|
||||
select.textContent = "";
|
||||
option("", "Unavailable");
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message || "Unable to load primary pillars.";
|
||||
retry.hidden = false;
|
||||
} finally {
|
||||
view.loading = false;
|
||||
loadingFields.delete("primaryPillar");
|
||||
if (current()) updateSave();
|
||||
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
|
||||
}
|
||||
}
|
||||
retry.addEventListener("click", load);
|
||||
modal.addEventListener("click", event => { if (event.target === modal) close(); });
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = [dismiss, autocomplete.input, ...extraControls, retry, save, cancel].filter(control => !control.disabled && !control.hidden);
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
|
||||
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||
}
|
||||
});
|
||||
dismiss.focus();
|
||||
load();
|
||||
extraLoads.forEach(loadOptions => loadOptions());
|
||||
}
|
||||
return { createButton, close, open };
|
||||
}
|
||||
210
src/features/opportunities/crm-sr-popover.js
Normal file
210
src/features/opportunities/crm-sr-popover.js
Normal file
@@ -0,0 +1,210 @@
|
||||
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, formatDate, openInternalSrForm, environment }) {
|
||||
const { document, window, setTimeout, clearTimeout } = environment;
|
||||
let active = null;
|
||||
let sequence = 0;
|
||||
function close(restoreFocus = false) {
|
||||
if (!active) return;
|
||||
const entry = active;
|
||||
active = null;
|
||||
clearTimeout(entry.timer);
|
||||
entry.panel.remove();
|
||||
entry.button.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("pointerdown", entry.outside, true);
|
||||
document.removeEventListener("keydown", entry.keydown, true);
|
||||
window.removeEventListener("resize", entry.position);
|
||||
document.removeEventListener("scroll", entry.scroll, true);
|
||||
if (restoreFocus && entry.button.isConnected) {
|
||||
entry.suppressFocus = true;
|
||||
entry.button.focus();
|
||||
entry.suppressFocus = false;
|
||||
}
|
||||
}
|
||||
function bind(button, items, descriptions) {
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
const id = `opportunities-extension-sr-details-${++sequence}`;
|
||||
button.setAttribute("aria-controls", id);
|
||||
const entry = { button, panel: null, timer: null, pinned: false, suppressFocus: false };
|
||||
const contains = target => target && (button.contains(target) || entry.panel?.contains(target));
|
||||
entry.outside = event => { if (!contains(event.target)) close(); };
|
||||
entry.scroll = event => { if (!entry.panel.contains(event.target)) close(); };
|
||||
entry.keydown = event => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close(true);
|
||||
}
|
||||
};
|
||||
entry.position = () => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const width = Math.min(480, window.innerWidth - 24);
|
||||
entry.panel.style.width = `${width}px`;
|
||||
entry.panel.style.maxHeight = `${Math.min(480, window.innerHeight - 24)}px`;
|
||||
const height = entry.panel.getBoundingClientRect().height;
|
||||
entry.panel.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;
|
||||
entry.panel.style.top = `${Math.max(12, Math.min(rect.bottom + 6, window.innerHeight - height - 12))}px`;
|
||||
};
|
||||
function leave() {
|
||||
clearTimeout(entry.timer);
|
||||
if (!entry.pinned && !contains(document.activeElement)) {
|
||||
entry.timer = setTimeout(() => { if (active === entry) close(); }, 200);
|
||||
}
|
||||
}
|
||||
function focusOut(event) { if (!contains(event.relatedTarget) && active === entry) close(); }
|
||||
function open() {
|
||||
if (entry.suppressFocus) return;
|
||||
clearTimeout(entry.timer);
|
||||
if (active === entry) return;
|
||||
close();
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host) return;
|
||||
const panel = document.createElement("div");
|
||||
entry.panel = panel;
|
||||
entry.pinned = false;
|
||||
panel.id = id;
|
||||
panel.className = "opportunities-extension-sr-popover";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-label", "CRM service requests");
|
||||
const dismiss = document.createElement("button");
|
||||
dismiss.type = "button";
|
||||
dismiss.className = "opportunities-extension-sr-dismiss";
|
||||
dismiss.title = "Close service request details";
|
||||
dismiss.setAttribute("aria-label", dismiss.title);
|
||||
dismiss.append(createCloseIcon());
|
||||
dismiss.addEventListener("click", () => close(true));
|
||||
panel.append(dismiss);
|
||||
const detailLoads = [];
|
||||
items.forEach((item, index) => {
|
||||
const section = document.createElement("section");
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "opportunities-extension-sr-number";
|
||||
const number = item.SrNumber;
|
||||
const link = document.createElement(number ? "a" : "span");
|
||||
link.textContent = number ? String(number) : "N/A";
|
||||
if (number) {
|
||||
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "SR Number:";
|
||||
heading.append(label, link);
|
||||
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "opportunities-extension-copy-button opportunities-extension-internal-sr-add";
|
||||
add.title = "Create Internal Service Request";
|
||||
add.setAttribute("aria-label", add.title);
|
||||
add.setAttribute("aria-haspopup", "dialog");
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.setAttribute("focusable", "false");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M12 5v14M5 12h14");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "2");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
icon.append(path);
|
||||
add.append(icon);
|
||||
add.addEventListener("click", event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
close();
|
||||
openInternalSrForm(item, button);
|
||||
});
|
||||
heading.append(add);
|
||||
const details = document.createElement("div");
|
||||
details.className = "opportunities-extension-sr-details";
|
||||
details.textContent = descriptions[index];
|
||||
section.append(heading, details);
|
||||
const children = document.createElement("div");
|
||||
children.className = "opportunities-extension-sr-children";
|
||||
children.setAttribute("aria-busy", "true");
|
||||
children.setAttribute("role", "status");
|
||||
children.textContent = "Loading SR details...";
|
||||
section.append(children);
|
||||
const loadDetails = async () => {
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.textContent = "Loading SR details...";
|
||||
children.setAttribute("aria-busy", "true");
|
||||
children.setAttribute("role", "status");
|
||||
try {
|
||||
const records = await requestDetails(item.SrId, item.SrNumber);
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.textContent = "";
|
||||
if (!records.length) children.textContent = "No further details.";
|
||||
for (const record of records) {
|
||||
const child = document.createElement("section");
|
||||
const row = document.createElement("div");
|
||||
row.className = "opportunities-extension-sr-number";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Internal SR Number:";
|
||||
const link = document.createElement(record.SrNumber ? "a" : "span");
|
||||
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/A";
|
||||
if (record.SrNumber) {
|
||||
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
row.append(label, link);
|
||||
if (record.SrNumber) row.append(createCopyButton(String(record.SrNumber), "SR number", "SR Number"));
|
||||
const body = document.createElement("div");
|
||||
body.className = "opportunities-extension-sr-details";
|
||||
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
||||
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
|
||||
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/A" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
|
||||
child.append(row, body);
|
||||
children.append(child);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.setAttribute("role", "alert");
|
||||
children.textContent = error.message || "Unable to load SR details.";
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.textContent = "Retry";
|
||||
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
|
||||
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
|
||||
children.append(retry);
|
||||
} finally {
|
||||
if (active === entry && entry.panel === panel) {
|
||||
children.setAttribute("aria-busy", "false");
|
||||
entry.position();
|
||||
}
|
||||
}
|
||||
};
|
||||
detailLoads.push(loadDetails);
|
||||
panel.append(section);
|
||||
});
|
||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||
panel.addEventListener("pointerleave", leave);
|
||||
panel.addEventListener("focusout", focusOut);
|
||||
host.append(panel);
|
||||
active = entry;
|
||||
detailLoads.forEach(load => load());
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
entry.position();
|
||||
document.addEventListener("pointerdown", entry.outside, true);
|
||||
document.addEventListener("keydown", entry.keydown, true);
|
||||
document.addEventListener("scroll", entry.scroll, true);
|
||||
window.addEventListener("resize", entry.position);
|
||||
}
|
||||
button.addEventListener("pointerenter", open);
|
||||
button.addEventListener("pointerleave", leave);
|
||||
button.addEventListener("focus", open);
|
||||
button.addEventListener("focusout", focusOut);
|
||||
button.addEventListener("click", event => {
|
||||
event.stopPropagation();
|
||||
if (active === entry && entry.pinned) close();
|
||||
else {
|
||||
open();
|
||||
if (active === entry) {
|
||||
entry.pinned = true;
|
||||
entry.panel.querySelector("a, button").focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return { bind, close };
|
||||
}
|
||||
24
src/features/opportunities/customer-table-search.css
Normal file
24
src/features/opportunities/customer-table-search.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog,
|
||||
.opportunities-extension-account-plans-dialog.opportunities-extension-service-requests-dialog {
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
.opportunities-extension-customer-table-toolbar { padding: 12px 16px; border-bottom: 1px solid #dedbd7; }
|
||||
.opportunities-extension-customer-table-search {
|
||||
box-sizing: border-box;
|
||||
width: min(420px, 100%);
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #8f8a85;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #312d2a;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.opportunities-extension-customer-table-search::placeholder { color: #5f5a55; opacity: 1; }
|
||||
.opportunities-extension-customer-table-search:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-toolbar { border-color: #484541; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search { background: #353331; color: #f6f4f2; border-color: #8f8a85; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search::placeholder { color: #c9c5c1; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search:focus-visible { outline-color: #59d1e5; }
|
||||
24
src/features/opportunities/customer-table-search.js
Normal file
24
src/features/opportunities/customer-table-search.js
Normal file
@@ -0,0 +1,24 @@
|
||||
function createCustomerTableSearch({ environment }) {
|
||||
function createSearch({ label, onSearch, getValues }) {
|
||||
const input = environment.document.createElement("input");
|
||||
input.type = "search";
|
||||
input.className = "opportunities-extension-customer-table-search";
|
||||
input.placeholder = label;
|
||||
input.setAttribute("aria-label", label);
|
||||
input.addEventListener("input", onSearch);
|
||||
const normalize = value => String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||
let index = new WeakMap();
|
||||
return { input,
|
||||
reset() { index = new WeakMap(); },
|
||||
filter(items) {
|
||||
const terms = normalize(input.value).trim().split(/\s+/).filter(Boolean);
|
||||
if (!terms.length) return items;
|
||||
return items.filter(item => {
|
||||
if (!index.has(item)) index.set(item, getValues(item).map(normalize));
|
||||
return terms.every(term => index.get(item).some(value => value.includes(term)));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
return { createSearch };
|
||||
}
|
||||
5
src/features/opportunities/dashboard-state.js
Normal file
5
src/features/opportunities/dashboard-state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) {
|
||||
return {
|
||||
stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])])
|
||||
};
|
||||
}
|
||||
119
src/features/opportunities/dashboard.js
Normal file
119
src/features/opportunities/dashboard.js
Normal file
@@ -0,0 +1,119 @@
|
||||
function createOpportunitiesDashboard({
|
||||
STAGE_DASHBOARD_ID,
|
||||
STAGE_OPTIONS,
|
||||
getNestedValue,
|
||||
opportunitiesDashboardState,
|
||||
environment
|
||||
}) {
|
||||
const { document, requestAnimationFrame, performance } = environment;
|
||||
function renderStageDashboard(items, totalCount = items.length) {
|
||||
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||
|
||||
if (!dashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
dashboard.textContent = "";
|
||||
|
||||
const totalCard = document.createElement("section");
|
||||
const totalLabel = document.createElement("span");
|
||||
const totalValue = document.createElement("strong");
|
||||
|
||||
totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card";
|
||||
totalCard.setAttribute("data-stage", "TOTAL");
|
||||
totalLabel.textContent = "Total Opportunities";
|
||||
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
||||
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
|
||||
animateStageDashboardValue(
|
||||
totalValue,
|
||||
"TOTAL",
|
||||
items.length,
|
||||
(amount) => `${Math.round(amount).toLocaleString("en-US")}/${totalCount.toLocaleString("en-US")}`
|
||||
);
|
||||
totalCard.append(totalLabel, totalValue);
|
||||
dashboard.append(totalCard);
|
||||
|
||||
STAGE_OPTIONS.forEach((stage) => {
|
||||
const stageItems = items.filter((item) => item.ForecastGroup_c === stage);
|
||||
const amount = stageItems.reduce((total, item) => {
|
||||
const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]));
|
||||
return Number.isFinite(value) ? total + value : total;
|
||||
}, 0);
|
||||
const card = document.createElement("section");
|
||||
const label = document.createElement("span");
|
||||
const value = document.createElement("strong");
|
||||
|
||||
card.className = "opportunities-extension-stage-dashboard-card";
|
||||
card.setAttribute("data-stage", stage);
|
||||
label.textContent = stage;
|
||||
value.className = "opportunities-extension-stage-dashboard-value";
|
||||
value.setAttribute("aria-label", `${stage} total amount`);
|
||||
|
||||
animateStageDashboardValue(
|
||||
value,
|
||||
stage,
|
||||
amount,
|
||||
createStageAmountFormatter(stageItems)
|
||||
);
|
||||
|
||||
card.append(label, value);
|
||||
dashboard.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createStageAmountFormatter(items) {
|
||||
const currencies = new Set(items
|
||||
.map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]))
|
||||
.filter(Boolean));
|
||||
|
||||
if (currencies.size === 1) {
|
||||
const [currency] = currencies;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
return (amount) => formatter.format(amount);
|
||||
} catch (error) {
|
||||
// Fall through to a neutral numeric total when the currency code is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
return (amount) => amount.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
function animateStageDashboardValue(element, stage, target, format) {
|
||||
const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0;
|
||||
opportunitiesDashboardState.stageDashboardAmounts.set(stage, target);
|
||||
|
||||
if (current === target) {
|
||||
element.textContent = format(target);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 480;
|
||||
|
||||
function tick(now) {
|
||||
const progress = Math.min((now - startTime) / duration, 1);
|
||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const value = current + ((target - current) * easedProgress);
|
||||
|
||||
element.textContent = format(value);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
element.textContent = format(target);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue };
|
||||
}
|
||||
16
src/features/opportunities/filter-state.js
Normal file
16
src/features/opportunities/filter-state.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
||||
return {
|
||||
selectedProductTypes: new Set(["Booking", "Workload"]),
|
||||
selectedStages: new Set(STAGE_OPTIONS),
|
||||
selectedStatuses: new Set(STATUS_OPTIONS),
|
||||
selectedCustomerKeys: new Set(),
|
||||
customerSearch: "",
|
||||
selectedOwnerKeys: new Set(),
|
||||
ownerSearch: "",
|
||||
tableSearch: "",
|
||||
hasSavedCustomerFilter: false,
|
||||
hasSavedOwnerFilter: false,
|
||||
customerFilterInitialized: false,
|
||||
ownerFilterInitialized: false
|
||||
};
|
||||
}
|
||||
410
src/features/opportunities/filters.js
Normal file
410
src/features/opportunities/filters.js
Normal file
@@ -0,0 +1,410 @@
|
||||
function createOpportunitiesFilters({
|
||||
CUSTOMER_FILTER_BUTTON_ID,
|
||||
CUSTOMER_FILTER_LIST_ID,
|
||||
CUSTOMER_FILTER_PANEL_ID,
|
||||
CUSTOMER_FILTER_SEARCH_ID,
|
||||
CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
OWNER_FILTER_BUTTON_ID,
|
||||
OWNER_FILTER_LIST_ID,
|
||||
OWNER_FILTER_PANEL_ID,
|
||||
OWNER_FILTER_SEARCH_ID,
|
||||
OWNER_FILTER_SELECT_ALL_ID,
|
||||
STAGE_FILTERS_ID,
|
||||
STAGE_OPTIONS,
|
||||
STATUS_FILTERS_ID,
|
||||
STATUS_OPTIONS,
|
||||
getCustomerFilterSummary,
|
||||
getCustomerKey,
|
||||
getCustomerOptions,
|
||||
getOwnerKey,
|
||||
getOwnerOptions,
|
||||
opportunitiesFilterState,
|
||||
opportunitiesTableState,
|
||||
renderOpportunitiesTable,
|
||||
saveFilterPreferences,
|
||||
stringifySearchValue,
|
||||
matchesActivities,
|
||||
matchesProductType = () => true,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function resetCustomerAndOwnerFiltersForPeriod() {
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set();
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set();
|
||||
opportunitiesFilterState.customerSearch = "";
|
||||
opportunitiesFilterState.ownerSearch = "";
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = false;
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = false;
|
||||
opportunitiesFilterState.customerFilterInitialized = false;
|
||||
opportunitiesFilterState.ownerFilterInitialized = false;
|
||||
}
|
||||
|
||||
function renderStageFilterButtons() {
|
||||
const controls = document.getElementById(STAGE_FILTERS_ID);
|
||||
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
controls.textContent = "";
|
||||
|
||||
STAGE_OPTIONS.forEach((stage) => {
|
||||
const button = document.createElement("button");
|
||||
const isPressed = opportunitiesFilterState.selectedStages.has(stage);
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-stage-filter-button";
|
||||
button.setAttribute("data-stage", stage);
|
||||
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
|
||||
button.textContent = stage;
|
||||
button.addEventListener("click", () => toggleStageFilter(stage));
|
||||
controls.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStageFilter(stage) {
|
||||
if (opportunitiesFilterState.selectedStages.has(stage)) {
|
||||
opportunitiesFilterState.selectedStages.delete(stage);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStages.add(stage);
|
||||
}
|
||||
|
||||
saveFilterPreferences();
|
||||
renderStageFilterButtons();
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
function renderStatusFilterButtons() {
|
||||
const controls = document.getElementById(STATUS_FILTERS_ID);
|
||||
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
controls.textContent = "";
|
||||
|
||||
STATUS_OPTIONS.forEach((status) => {
|
||||
const button = document.createElement("button");
|
||||
const isPressed = opportunitiesFilterState.selectedStatuses.has(status);
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-status-filter-button";
|
||||
button.setAttribute("data-status", status);
|
||||
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
|
||||
button.textContent = status;
|
||||
button.addEventListener("click", () => toggleStatusFilter(status));
|
||||
controls.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
||||
opportunitiesFilterState.selectedStatuses.delete(status);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStatuses.add(status);
|
||||
}
|
||||
|
||||
saveFilterPreferences();
|
||||
renderStatusFilterButtons();
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
function toggleCustomerFilterPanel() {
|
||||
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || !button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
|
||||
|
||||
if (!panel.hidden) {
|
||||
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeCustomerFilterPanel(restoreFocus = true) {
|
||||
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || panel.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
panel.hidden = true;
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (restoreFocus) {
|
||||
button.focus();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function toggleOwnerFilterPanel() {
|
||||
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || !button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
|
||||
|
||||
if (!panel.hidden) {
|
||||
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeOwnerFilterPanel(restoreFocus = true) {
|
||||
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || panel.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
panel.hidden = true;
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (restoreFocus) {
|
||||
button.focus();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetCustomerFilter(items) {
|
||||
const customerOptions = getCustomerOptions(items);
|
||||
if (!opportunitiesFilterState.customerFilterInitialized) {
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key));
|
||||
opportunitiesFilterState.customerFilterInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function resetOwnerFilter(items) {
|
||||
const ownerOptions = getOwnerOptions(items);
|
||||
if (!opportunitiesFilterState.ownerFilterInitialized) {
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key));
|
||||
opportunitiesFilterState.ownerFilterInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCustomerFilter() {
|
||||
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
|
||||
const customerOptions = getCustomerOptions();
|
||||
|
||||
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
|
||||
trigger.disabled = customerOptions.length === 0;
|
||||
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
|
||||
searchInput.value = opportunitiesFilterState.customerSearch;
|
||||
selectAllCheckbox.disabled = customerOptions.length === 0;
|
||||
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
|
||||
renderCustomerFilterList();
|
||||
}
|
||||
|
||||
function renderCustomerFilterList() {
|
||||
const list = document.getElementById(CUSTOMER_FILTER_LIST_ID);
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
|
||||
const customerOptions = getCustomerOptions().filter((customer) => {
|
||||
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
list.textContent = "";
|
||||
|
||||
if (customerOptions.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-customer-filter-empty";
|
||||
empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
customerOptions.forEach((customer) => {
|
||||
const option = document.createElement("label");
|
||||
option.className = "opportunities-extension-customer-filter-option";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
||||
}
|
||||
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderCustomerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = customer.label;
|
||||
option.append(checkbox, label);
|
||||
list.append(option);
|
||||
});
|
||||
}
|
||||
|
||||
function renderOwnerFilter() {
|
||||
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
|
||||
const ownerOptions = getOwnerOptions();
|
||||
|
||||
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
|
||||
trigger.disabled = ownerOptions.length === 0;
|
||||
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
|
||||
.replace("customer", "owner");
|
||||
searchInput.value = opportunitiesFilterState.ownerSearch;
|
||||
selectAllCheckbox.disabled = ownerOptions.length === 0;
|
||||
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
|
||||
renderOwnerFilterList();
|
||||
}
|
||||
|
||||
function renderOwnerFilterList() {
|
||||
const list = document.getElementById(OWNER_FILTER_LIST_ID);
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
|
||||
const ownerOptions = getOwnerOptions().filter((owner) => {
|
||||
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
list.textContent = "";
|
||||
|
||||
if (ownerOptions.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-customer-filter-empty";
|
||||
empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ownerOptions.forEach((owner) => {
|
||||
const option = document.createElement("label");
|
||||
option.className = "opportunities-extension-customer-filter-option";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
||||
}
|
||||
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderOwnerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = owner.label;
|
||||
option.append(checkbox, label);
|
||||
list.append(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getSortedOpportunities() {
|
||||
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
||||
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
|
||||
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
|
||||
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
|
||||
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
||||
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
|
||||
&& matchesSelectedStatus(item.StatusCode)
|
||||
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||
&& matchesTableSearch(item, normalizedSearch)
|
||||
&& matchesActivities(item)
|
||||
&& matchesProductType(item);
|
||||
});
|
||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||
|
||||
if (!column) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1;
|
||||
|
||||
return items.sort((first, second) => {
|
||||
const firstValue = column.value(first);
|
||||
const secondValue = column.value(second);
|
||||
const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === "";
|
||||
const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === "";
|
||||
|
||||
if (firstIsEmpty || secondIsEmpty) {
|
||||
if (firstIsEmpty && secondIsEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return firstIsEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const firstNumber = Number(firstValue);
|
||||
const secondNumber = Number(secondValue);
|
||||
|
||||
if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) {
|
||||
return (firstNumber - secondNumber) * direction;
|
||||
}
|
||||
|
||||
return String(firstValue).localeCompare(String(secondValue), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base"
|
||||
}) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesTableSearch(item, normalizedSearch) {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return OPPORTUNITIES_COLUMNS.some((column) => {
|
||||
const rawValue = stringifySearchValue(column.value(item));
|
||||
const displayValue = stringifySearchValue(column.display(item));
|
||||
return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
}
|
||||
|
||||
function matchesSelectedStatus(statusCode) {
|
||||
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
|
||||
if (statusCode === "WON_PENDING") {
|
||||
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||
}
|
||||
|
||||
return opportunitiesFilterState.selectedStatuses.has(statusCode);
|
||||
}
|
||||
|
||||
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
|
||||
}
|
||||
50
src/features/opportunities/fiscal-periods.js
Normal file
50
src/features/opportunities/fiscal-periods.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function createOpportunitiesFiscalPeriods() {
|
||||
// Oracle fiscal years start on June 1; each range ends at the next period's start.
|
||||
const FISCAL_START_MONTH = 5;
|
||||
|
||||
function getFiscalPeriodRange(period, currentDate) {
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth();
|
||||
const fiscalStartYear = currentMonth >= FISCAL_START_MONTH ? currentYear : currentYear - 1;
|
||||
const currentQuarterIndex = Math.floor(((currentMonth - FISCAL_START_MONTH + 12) % 12) / 3);
|
||||
|
||||
if (period === "Current Fiscal Year") {
|
||||
return createDateRange(fiscalStartYear, FISCAL_START_MONTH, fiscalStartYear + 1, FISCAL_START_MONTH);
|
||||
}
|
||||
|
||||
let quarterOffset;
|
||||
let numberOfQuarters = 1;
|
||||
|
||||
if (period === "Current Quarter") {
|
||||
quarterOffset = currentQuarterIndex;
|
||||
} else if (period === "Next Quarter") {
|
||||
quarterOffset = currentQuarterIndex + 1;
|
||||
} else if (period === "Previous Quarter") {
|
||||
quarterOffset = currentQuarterIndex - 1;
|
||||
} else if (period === "4 Rolling Quarters (CQ + 3)") {
|
||||
quarterOffset = currentQuarterIndex;
|
||||
numberOfQuarters = 4;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMonth = FISCAL_START_MONTH + (quarterOffset * 3);
|
||||
return createDateRange(fiscalStartYear, startMonth, fiscalStartYear, startMonth + (numberOfQuarters * 3));
|
||||
}
|
||||
|
||||
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
||||
return {
|
||||
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
||||
endCloseDate: formatOracleDate(new Date(endYear, endMonth, 1))
|
||||
};
|
||||
}
|
||||
|
||||
function formatOracleDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
return { getFiscalPeriodRange, createDateRange, formatOracleDate };
|
||||
}
|
||||
176
src/features/opportunities/formatters.js
Normal file
176
src/features/opportunities/formatters.js
Normal file
@@ -0,0 +1,176 @@
|
||||
function createOpportunitiesFormatters({
|
||||
opportunitiesTableState
|
||||
}) {
|
||||
function getCustomerOptions(items) {
|
||||
const optionsByKey = new Map();
|
||||
|
||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||
const key = getCustomerKey(item);
|
||||
const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
|
||||
|
||||
if (key && label && !optionsByKey.has(key)) {
|
||||
optionsByKey.set(key, { key, label: String(label) });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(optionsByKey.values()).sort((first, second) => {
|
||||
return first.label.localeCompare(second.label, undefined, {
|
||||
sensitivity: "base"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCustomerKey(item) {
|
||||
const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]);
|
||||
const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
|
||||
|
||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||
return `party:${partyId}`;
|
||||
}
|
||||
|
||||
return partyName ? `name:${partyName}` : "";
|
||||
}
|
||||
|
||||
function getOwnerName(item) {
|
||||
const owner = item.Owner;
|
||||
|
||||
if (typeof owner === "string" || typeof owner === "number") {
|
||||
return owner;
|
||||
}
|
||||
|
||||
if (owner && typeof owner === "object") {
|
||||
const firstName = owner.PersonFirstName;
|
||||
const lastName = owner.PersonLastName;
|
||||
|
||||
if (firstName || lastName) {
|
||||
return [firstName, lastName].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
return owner.PartyName
|
||||
|| owner.PartyUniqueName
|
||||
|| owner.Name
|
||||
|| owner.DisplayName
|
||||
|| owner.ResourceName
|
||||
|| owner.OwnerName
|
||||
|| "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getCustomerFilterSummary(total, selected) {
|
||||
if (total === 0) {
|
||||
return "No customers";
|
||||
}
|
||||
|
||||
if (selected === total) {
|
||||
return "All customers";
|
||||
}
|
||||
|
||||
if (selected === 0) {
|
||||
return "No customers";
|
||||
}
|
||||
|
||||
return `${selected} customer${selected === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function getOwnerOptions(items) {
|
||||
const optionsByKey = new Map();
|
||||
|
||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||
const key = getOwnerKey(item);
|
||||
const label = String(getOwnerName(item)).trim() || "-";
|
||||
|
||||
if (key && label && !optionsByKey.has(key)) {
|
||||
optionsByKey.set(key, { key, label: String(label) });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(optionsByKey.values()).sort((first, second) => {
|
||||
return first.label.localeCompare(second.label, undefined, {
|
||||
sensitivity: "base"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getOwnerKey(item) {
|
||||
const owner = item.Owner;
|
||||
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
||||
const name = String(getOwnerName(item)).trim();
|
||||
|
||||
if (!name) return "owner:unassigned";
|
||||
|
||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||
return `party:${partyId}`;
|
||||
}
|
||||
|
||||
return name ? `name:${name}` : "";
|
||||
}
|
||||
|
||||
function stringifySearchValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function displayOpportunityValue(column, item) {
|
||||
const value = column.display(item);
|
||||
return value === null || value === undefined || value === "" ? "-" : String(value);
|
||||
}
|
||||
|
||||
function getNestedValue(value, path) {
|
||||
return path.reduce((result, key) => result && result[key], value);
|
||||
}
|
||||
|
||||
function formatWinProbability(item) {
|
||||
const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]);
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? `${number}%` : value;
|
||||
}
|
||||
|
||||
function formatRevenue(item) {
|
||||
const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]);
|
||||
const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]);
|
||||
const number = Number(amount);
|
||||
|
||||
if (!Number.isFinite(number)) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
return number.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency
|
||||
}).format(number);
|
||||
} catch (error) {
|
||||
return `${currency} ${number.toLocaleString("en-US")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatOracleResponseDate(value, includeTime) {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/);
|
||||
|
||||
if (!match) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const date = `${match[3]}/${match[2]}/${match[1]}`;
|
||||
return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date;
|
||||
}
|
||||
|
||||
return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate };
|
||||
}
|
||||
197
src/features/opportunities/internal-sr-form.js
Normal file
197
src/features/opportunities/internal-sr-form.js
Normal file
@@ -0,0 +1,197 @@
|
||||
function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOpportunity, requestPlatform, requestServices, requestSave, notify, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
function node(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
if (!active) return false;
|
||||
if (active.saving) return true;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.modal.remove();
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function open(parent, trigger) {
|
||||
if (active?.saving) return;
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host || host.hidden) return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal");
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "internal-sr-form-title");
|
||||
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-internal-sr-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header");
|
||||
const heading = node("div", "opportunities-extension-action-form-heading");
|
||||
const title = node("h2", "", "Create Internal Service Request");
|
||||
title.id = "internal-sr-form-title";
|
||||
const context = node("dl", "opportunities-extension-action-form-context");
|
||||
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
|
||||
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
|
||||
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/A" : String(value)));
|
||||
}
|
||||
heading.append(title, context);
|
||||
header.append(heading);
|
||||
const form = node("form", "opportunities-extension-action-form");
|
||||
form.noValidate = true;
|
||||
const fields = new Map();
|
||||
const combos = new Map();
|
||||
const ready = new Set();
|
||||
function field(name, label, type, wide = false) {
|
||||
const wrapper = node("label", "opportunities-extension-action-form-field" + (wide ? " opportunities-extension-action-form-wide" : ""));
|
||||
const input = node(type === "textarea" || type === "select" ? type : "input");
|
||||
if (!["textarea", "select"].includes(type)) input.type = type;
|
||||
if (type === "textarea") input.rows = 4;
|
||||
input.id = `internal-sr-${name}`;
|
||||
input.name = name;
|
||||
fields.set(name, input);
|
||||
input.required = name !== "customerDeadline";
|
||||
input.setAttribute("aria-required", String(input.required));
|
||||
wrapper.htmlFor = input.id;
|
||||
wrapper.append(node("span", "", input.required ? `${label} (required)` : label), input);
|
||||
form.append(wrapper);
|
||||
return { input, wrapper };
|
||||
}
|
||||
const titleInput = field("title", "Title", "text", true).input;
|
||||
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
|
||||
deadline.readOnly = true;
|
||||
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/A";
|
||||
field("internalDeadline", "Internal Deadline", "date");
|
||||
const platform = field("platform", "Platform", "select", true);
|
||||
field("description", "Description", "textarea", true);
|
||||
const services = field("services", "Services", "select", true);
|
||||
const footer = node("footer", "opportunities-extension-action-form-footer");
|
||||
const cancel = node("button", "", "Cancel");
|
||||
const save = node("button", "opportunities-extension-action-form-save", "Save");
|
||||
save.type = "submit";
|
||||
save.disabled = true;
|
||||
const feedback = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
||||
feedback.setAttribute("role", "status");
|
||||
feedback.hidden = true;
|
||||
cancel.type = "button";
|
||||
cancel.addEventListener("click", () => close());
|
||||
footer.append(save, cancel);
|
||||
form.append(feedback, footer);
|
||||
dialog.append(header, form);
|
||||
modal.append(dialog);
|
||||
host.append(modal);
|
||||
const entry = { modal, trigger, saving: false };
|
||||
active = entry;
|
||||
const validDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`))
|
||||
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
if (active !== entry || entry.saving || ready.size !== 2) return;
|
||||
let firstInvalid = null;
|
||||
for (const [name, input] of fields) {
|
||||
if (!input.required) continue;
|
||||
const value = String(input.value || "").trim();
|
||||
const valid = !!value && (name !== "internalDeadline" || validDate(value))
|
||||
&& (!combos.has(name) || Array.from(input.children).some(option => option.value === value));
|
||||
input.setAttribute("aria-invalid", String(!valid));
|
||||
const visible = combos.get(name)?.input || input;
|
||||
visible.setAttribute("aria-invalid", String(!valid));
|
||||
if (!valid && !firstInvalid) firstInvalid = visible;
|
||||
}
|
||||
feedback.hidden = false;
|
||||
if (firstInvalid) {
|
||||
feedback.setAttribute("role", "alert");
|
||||
feedback.textContent = "Complete all required fields with valid values.";
|
||||
firstInvalid.focus();
|
||||
return;
|
||||
}
|
||||
const value = name => String(fields.get(name).value || "").trim();
|
||||
const dateParts = value("customerDeadline").match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||
const payload = {
|
||||
Title: value("title"),
|
||||
ServiceNew_Id_c: value("services"),
|
||||
ProblemDescription: value("description"),
|
||||
CustomerDeadline_c: dateParts ? `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}` : "",
|
||||
TechPlatformLookUp_c: value("platform"),
|
||||
ServiceRequest_Id_SRToInternalSR: String(parent.SrId ?? ""),
|
||||
SvcQuestionsCollection_c: [], InternalDeadline_c: value("internalDeadline")
|
||||
};
|
||||
entry.saving = true;
|
||||
form.setAttribute("aria-busy", "true");
|
||||
save.disabled = cancel.disabled = true;
|
||||
save.textContent = "Saving...";
|
||||
for (const [name, input] of fields) { input.disabled = true; combos.get(name)?.sync(); }
|
||||
feedback.setAttribute("role", "status");
|
||||
feedback.textContent = "Creating Internal Service Request...";
|
||||
try {
|
||||
await requestSave(payload);
|
||||
entry.saving = false;
|
||||
close();
|
||||
notify("Internal Service Request created successfully.");
|
||||
} catch (error) {
|
||||
if (active !== entry) return;
|
||||
feedback.setAttribute("role", "alert");
|
||||
feedback.textContent = error.message || "Unable to create Internal Service Request.";
|
||||
notify(feedback.textContent, true);
|
||||
} finally {
|
||||
entry.saving = false;
|
||||
if (active === entry) {
|
||||
form.setAttribute("aria-busy", "false");
|
||||
save.disabled = cancel.disabled = false;
|
||||
save.textContent = "Save";
|
||||
for (const [name, input] of fields) { input.disabled = false; combos.get(name)?.sync(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
function load(target, request, service) {
|
||||
const { input, wrapper } = target;
|
||||
input.disabled = true;
|
||||
const combo = searchableSelect(input, wrapper);
|
||||
combos.set(input.name, combo);
|
||||
const status = node("p", "opportunities-extension-action-form-status", "Loading...");
|
||||
status.setAttribute("role", "status");
|
||||
wrapper.append(status);
|
||||
wrapper.setAttribute("aria-busy", "true");
|
||||
Promise.resolve().then(request).then(items => {
|
||||
if (active !== entry) return;
|
||||
const placeholder = node("option", "", items.length ? "Select an option" : "No options available");
|
||||
placeholder.value = "";
|
||||
input.append(placeholder);
|
||||
const seen = new Set();
|
||||
for (const item of items) {
|
||||
const value = String(service ? item.Id : item.code);
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
const option = node("option", "", service ? item.RecordName : item.name);
|
||||
option.value = value;
|
||||
if (service && item.Description_c) option.setAttribute("data-description", item.Description_c);
|
||||
input.append(option);
|
||||
}
|
||||
input.value = "";
|
||||
input.disabled = !items.length;
|
||||
if (items.length) ready.add(input.name);
|
||||
save.disabled = ready.size !== 2;
|
||||
status.textContent = items.length ? "" : "No options available.";
|
||||
status.hidden = !!items.length;
|
||||
combo.sync();
|
||||
}).catch(error => {
|
||||
if (active !== entry) return;
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message || "Unable to load options. Close and reopen the form to retry.";
|
||||
}).finally(() => { if (active === entry) wrapper.setAttribute("aria-busy", "false"); });
|
||||
}
|
||||
load(platform, requestPlatform, false);
|
||||
load(services, () => requestServices(parent.SrId), true);
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key === "Tab") {
|
||||
const focusable = Array.from(form.querySelectorAll("input, textarea, button")).filter(el => !el.disabled && !el.hidden);
|
||||
const first = focusable[0], last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
titleInput.focus();
|
||||
}
|
||||
return { open, close };
|
||||
}
|
||||
62
src/features/opportunities/internal-sr-session.js
Normal file
62
src/features/opportunities/internal-sr-session.js
Normal file
@@ -0,0 +1,62 @@
|
||||
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
||||
const pending = new Map();
|
||||
let recovery = null;
|
||||
function isUnauthorized(result) {
|
||||
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
||||
return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
|
||||
}
|
||||
async function warmSession(id, srNumber) {
|
||||
const document = environment?.document;
|
||||
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
|
||||
const frame = document.createElement("iframe");
|
||||
// Restore the original session initialization route without opening a tab.
|
||||
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
||||
frame.title = `${sessionLabel} session initialization`;
|
||||
frame.tabIndex = -1;
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
|
||||
let blocked = false;
|
||||
const failed = () => { blocked = true; };
|
||||
frame.addEventListener("error", failed);
|
||||
try {
|
||||
document.body.append(frame);
|
||||
// Keep the iframe alive while its application completes authentication.
|
||||
// A load event is not proof of a ready session. Limit and space API checks.
|
||||
for (const delay of [2000, 4000, 8000, 12000, 16000]) {
|
||||
await new Promise(resolve => environment.setTimeout(resolve, delay));
|
||||
if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
|
||||
const result = await query(id, srNumber);
|
||||
if (result?.ok) return { id: String(id), result };
|
||||
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
}
|
||||
throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
|
||||
} finally {
|
||||
frame.removeEventListener("error", failed);
|
||||
try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
|
||||
frame.remove();
|
||||
}
|
||||
}
|
||||
function request(id, srNumber) {
|
||||
const key = String(id);
|
||||
if (!pending.has(key)) {
|
||||
const operation = (async () => {
|
||||
let result = await query(id, srNumber);
|
||||
if (!result?.ok && isUnauthorized(result)) {
|
||||
if (!recovery) {
|
||||
const current = warmSession(id, srNumber);
|
||||
recovery = current;
|
||||
current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
|
||||
}
|
||||
const recovered = await recovery;
|
||||
result = recovered.id === key ? recovered.result : await query(id, srNumber);
|
||||
}
|
||||
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
return result.items;
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, operation);
|
||||
}
|
||||
return pending.get(key);
|
||||
}
|
||||
return { request };
|
||||
}
|
||||
118
src/features/opportunities/links.js
Normal file
118
src/features/opportunities/links.js
Normal file
@@ -0,0 +1,118 @@
|
||||
function createOpportunitiesLinks({
|
||||
MODAL_ID,
|
||||
environment
|
||||
}) {
|
||||
const { document, navigator, window } = environment;
|
||||
function createDetailLink(label, href) {
|
||||
if (label === null || label === undefined || label === "") {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.textContent = "-";
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
const text = document.createElement("span");
|
||||
text.textContent = String(label);
|
||||
return text;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.textContent = String(label);
|
||||
return link;
|
||||
}
|
||||
|
||||
function createCopyOpportunityButton(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
|
||||
const button = document.createElement("button");
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-copy-button";
|
||||
button.setAttribute("aria-label", `Copy ${label} ${optyNumber}`);
|
||||
button.title = `Copy ${label}`;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copyOpportunityNumber(String(optyNumber), label, confirmationLabel);
|
||||
});
|
||||
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z");
|
||||
icon.append(path);
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
async function copyOpportunityNumber(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(optyNumber);
|
||||
} else {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = optyNumber;
|
||||
input.setAttribute("readonly", "true");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
|
||||
showCopyConfirmation(`${confirmationLabel} ${optyNumber} copied.`);
|
||||
} catch (error) {
|
||||
showCopyConfirmation(`Unable to copy ${label}.`, true);
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyConfirmation(message, isError = false) {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
let toast = modal.querySelector(".opportunities-extension-copy-toast");
|
||||
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.className = "opportunities-extension-copy-toast";
|
||||
toast.setAttribute("role", "status");
|
||||
modal.append(toast);
|
||||
}
|
||||
|
||||
toast.classList.toggle("opportunities-extension-copy-toast-error", isError);
|
||||
toast.textContent = message;
|
||||
toast.hidden = false;
|
||||
window.clearTimeout(toast.copyTimeout);
|
||||
toast.copyTimeout = window.setTimeout(() => {
|
||||
toast.hidden = true;
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
function createStageBadge(stage) {
|
||||
const badge = document.createElement("span");
|
||||
const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : "";
|
||||
|
||||
badge.className = "opportunities-extension-stage-badge";
|
||||
badge.setAttribute("data-stage", normalizedStage);
|
||||
badge.textContent = normalizedStage || "-";
|
||||
return badge;
|
||||
}
|
||||
|
||||
function createStatusBadge(status) {
|
||||
const badge = document.createElement("span");
|
||||
const normalizedStatus = typeof status === "string" ? status.toUpperCase() : "";
|
||||
|
||||
badge.className = "opportunities-extension-status-badge";
|
||||
badge.setAttribute("data-status", normalizedStatus);
|
||||
badge.textContent = normalizedStatus || "-";
|
||||
return badge;
|
||||
}
|
||||
|
||||
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||
}
|
||||
100
src/features/opportunities/print.js
Normal file
100
src/features/opportunities/print.js
Normal file
@@ -0,0 +1,100 @@
|
||||
function createOpportunitiesPrint({ environment, getSnapshot }) {
|
||||
function createButton() {
|
||||
const doc = environment.document;
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-icon-button";
|
||||
button.title = "Print opportunities";
|
||||
button.setAttribute("aria-label", "Print opportunities");
|
||||
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "1.6");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
|
||||
svg.append(path); button.append(svg);
|
||||
button.addEventListener("click", print);
|
||||
return button;
|
||||
}
|
||||
function print() {
|
||||
const snapshot = getSnapshot();
|
||||
if (!["ready", "empty"].includes(snapshot.status)) {
|
||||
environment.window.alert("Wait for opportunities to finish loading before printing.");
|
||||
return;
|
||||
}
|
||||
const popup = environment.window.open("", "_blank", "width=1200,height=850");
|
||||
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
|
||||
popup.opener = null;
|
||||
const doc = popup.document;
|
||||
doc.title = "Opportunities Extension — Report";
|
||||
doc.documentElement.lang = "en";
|
||||
const node = (tag, text, parent) => {
|
||||
const el = doc.createElement(tag);
|
||||
if (text != null) el.textContent = String(text);
|
||||
if (parent) parent.append(el);
|
||||
return el;
|
||||
};
|
||||
node("style", `
|
||||
@page { size: A4 landscape; margin: 15mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
|
||||
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
|
||||
.meta { color: #555; margin: 0 0 12px; }
|
||||
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
|
||||
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
|
||||
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
|
||||
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
|
||||
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
|
||||
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
|
||||
thead { display: table-header-group; } tr { break-inside: avoid; }
|
||||
th:nth-child(1), th:nth-child(3) { width: 22%; }
|
||||
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
|
||||
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
|
||||
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
|
||||
.money { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
button { padding: 8px 14px; cursor: pointer; }
|
||||
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
|
||||
`, doc.head);
|
||||
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
|
||||
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
|
||||
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
|
||||
node("h1", "Opportunities Extension", doc.body);
|
||||
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
|
||||
const filters = node("div", null, doc.body); filters.className = "filters";
|
||||
for (const [label, value] of snapshot.filters) {
|
||||
const field = node("div", null, filters);
|
||||
node("strong", `${label}:`, field); node("span", value || "None", field);
|
||||
}
|
||||
const totals = node("div", null, doc.body); totals.className = "totals";
|
||||
for (const card of snapshot.totals) {
|
||||
const section = node("section", null, totals);
|
||||
node("span", card.label, section); node("strong", card.value, section);
|
||||
if (card.detail) node("small", card.detail, section);
|
||||
}
|
||||
node("h2", "Opportunities", doc.body);
|
||||
const table = node("table", null, doc.body);
|
||||
const header = node("tr", null, node("thead", null, table));
|
||||
for (const column of snapshot.columns) {
|
||||
const th = node("th", column.label, header); th.scope = "col";
|
||||
if (column.key === "revenue") th.className = "money";
|
||||
}
|
||||
const body = node("tbody", null, table);
|
||||
for (const values of snapshot.rows) {
|
||||
const row = node("tr", null, body);
|
||||
values.forEach((value, index) => {
|
||||
const td = node("td", value, row);
|
||||
if (snapshot.columns[index].key === "revenue") td.className = "money";
|
||||
});
|
||||
}
|
||||
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
|
||||
popup.focus();
|
||||
// Print a frozen snapshot, without changing the extension's scroll or selected view.
|
||||
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
|
||||
}
|
||||
return { createButton, print };
|
||||
}
|
||||
44
src/features/opportunities/product-activities.js
Normal file
44
src/features/opportunities/product-activities.js
Normal file
@@ -0,0 +1,44 @@
|
||||
function createProductActivities({ productsContentState, opportunitiesQueryState,
|
||||
loadCrm, loadActions, blockActions, render }) {
|
||||
let cycle = null;
|
||||
function begin(requestId, token, opportunityNumbers = null) {
|
||||
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
|
||||
}
|
||||
function complete(requestId) {
|
||||
const current = cycle;
|
||||
if (!current || current.completed || current.requestId !== requestId
|
||||
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||
current.completed = true;
|
||||
const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number)));
|
||||
const products = entries.map(([, result]) => result).filter(result => !result.error);
|
||||
if (!products.length) return;
|
||||
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
|
||||
&& opportunitiesQueryState.periodRequestVersion === current.version;
|
||||
// Product cache hits do not guarantee that the corresponding activity cache exists.
|
||||
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => {
|
||||
if (!isActive()) return;
|
||||
if (success === false) {
|
||||
products.forEach(blockActions);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const pending = [];
|
||||
const seen = new Set();
|
||||
for (const result of products) {
|
||||
const items = result.items.filter(item => {
|
||||
const id = String(item?.RevnId ?? "");
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false }));
|
||||
}
|
||||
await Promise.allSettled(pending);
|
||||
if (!isActive()) return;
|
||||
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
|
||||
render();
|
||||
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
|
||||
}
|
||||
return { begin, complete };
|
||||
}
|
||||
6
src/features/opportunities/query-state.js
Normal file
6
src/features/opportunities/query-state.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function createOpportunitiesQueryState() {
|
||||
return {
|
||||
periodRequestVersion: 0,
|
||||
dateRange: null
|
||||
};
|
||||
}
|
||||
48
src/features/opportunities/refresh-all.js
Normal file
48
src/features/opportunities/refresh-all.js
Normal file
@@ -0,0 +1,48 @@
|
||||
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||
reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
let pending = null;
|
||||
function refresh(period) {
|
||||
if (pending) return pending;
|
||||
opportunitiesQueryState.periodRequestVersion += 1;
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
resetRelatedData();
|
||||
pending = (async () => {
|
||||
await activityCache.invalidate();
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
|
||||
await reload(period);
|
||||
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
function createButton(getPeriod) {
|
||||
const button = environment.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
|
||||
button.append(createRefreshIcon());
|
||||
button.title = "Refresh all";
|
||||
button.setAttribute("aria-label", "Refresh all");
|
||||
button.addEventListener("click", async () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
button.title = "Refreshing...";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
try { await refresh(getPeriod()); }
|
||||
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
|
||||
finally {
|
||||
button.disabled = false;
|
||||
button.title = "Refresh all";
|
||||
button.setAttribute("aria-busy", "false");
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { refresh, createButton };
|
||||
}
|
||||
205
src/features/opportunities/searchable-select.js
Normal file
205
src/features/opportunities/searchable-select.js
Normal file
@@ -0,0 +1,205 @@
|
||||
function createSearchableSelect({ environment }) {
|
||||
const { document } = environment;
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180, search, searchDelay = 350 } = {}) {
|
||||
let searchTimer, searchVersion = 0, disposed = false;
|
||||
const schedule = environment.setTimeout || globalThis.setTimeout, cancel = environment.clearTimeout || globalThis.clearTimeout;
|
||||
function cancelSearch() { searchVersion++; if (searchTimer) cancel(searchTimer); searchTimer = null; input.removeAttribute("aria-busy"); }
|
||||
const box = element("div", "opportunities-extension-action-combobox");
|
||||
const input = element("input");
|
||||
input.type = "text";
|
||||
input.autocomplete = "off";
|
||||
input.required = !!select.required;
|
||||
input.setAttribute("aria-required", String(!!select.required));
|
||||
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
|
||||
input.id = `${select.id}-search`;
|
||||
field.htmlFor = input.id;
|
||||
const list = element("div", "opportunities-extension-action-options");
|
||||
list.id = `${select.id}-options`;
|
||||
list.setAttribute("role", "listbox");
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
input.setAttribute("aria-controls", list.id);
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
select.hidden = true;
|
||||
select.tabIndex = -1;
|
||||
box.append(input, list);
|
||||
field.append(box);
|
||||
const tooltip = tooltipContent ? element("div", "opportunities-extension-select-tooltip") : null;
|
||||
if (tooltip) {
|
||||
tooltip.id = `${select.id}-tooltip`;
|
||||
tooltip.setAttribute("role", "tooltip");
|
||||
tooltip.hidden = true;
|
||||
box.append(tooltip);
|
||||
}
|
||||
let tooltipAnchor = null;
|
||||
function hideTooltip() {
|
||||
if (!tooltip) return;
|
||||
tooltip.hidden = true;
|
||||
tooltipAnchor?.removeAttribute?.("aria-describedby");
|
||||
tooltipAnchor = null;
|
||||
}
|
||||
function showTooltip(value, anchor) {
|
||||
hideTooltip();
|
||||
if (!tooltip || !value) return;
|
||||
const content = tooltipContent(value);
|
||||
if (!content) return;
|
||||
tooltip.replaceChildren(content);
|
||||
tooltip.hidden = false;
|
||||
tooltipAnchor = anchor;
|
||||
anchor.setAttribute("aria-describedby", tooltip.id);
|
||||
const rect = anchor.getBoundingClientRect?.();
|
||||
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
||||
if (!rect || !bounds) return;
|
||||
const size = tooltip.getBoundingClientRect();
|
||||
const left = rect.right + 8 + size.width <= bounds.right ? rect.right + 8 : Math.max(bounds.left + 8, rect.left - size.width - 8);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${Math.max(bounds.top + 8, Math.min(rect.top, bounds.bottom - size.height - 8))}px`;
|
||||
}
|
||||
function leaveTooltip(event) {
|
||||
if (!tooltip?.contains?.(event.relatedTarget)) hideTooltip();
|
||||
}
|
||||
tooltip?.addEventListener("mouseleave", hideTooltip);
|
||||
list.addEventListener("scroll", hideTooltip);
|
||||
let matches = [], index = -1;
|
||||
const options = () => Array.from(select.children).filter(item => item.value);
|
||||
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||
function closeList() {
|
||||
if (search) cancelSearch();
|
||||
hideTooltip();
|
||||
list.hidden = true;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.setAttribute("aria-activedescendant", "");
|
||||
}
|
||||
function sync() {
|
||||
closeList();
|
||||
input.disabled = !!select.disabled;
|
||||
input.placeholder = select.children[0]?.textContent || "Select";
|
||||
input.value = options().find(item => item.value === select.value)?.textContent || "";
|
||||
input.title = tooltipContent ? "" : input.value;
|
||||
}
|
||||
function highlight(withTooltip = true) {
|
||||
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
|
||||
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
|
||||
list.children[index]?.scrollIntoView?.({ block: "nearest" });
|
||||
if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
|
||||
}
|
||||
function choose(i) {
|
||||
if (!matches[i]) return;
|
||||
const changed = select.value !== matches[i].value;
|
||||
select.value = matches[i].value;
|
||||
sync();
|
||||
if (changed) onChange?.();
|
||||
}
|
||||
function positionList() {
|
||||
let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
||||
const anchor = input.getBoundingClientRect?.();
|
||||
if (!bounds || !anchor) return;
|
||||
if (floating && listWidth && document.documentElement?.clientWidth) {
|
||||
bounds = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight };
|
||||
}
|
||||
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
|
||||
const above = Math.max(0, anchor.top - bounds.top - 8);
|
||||
const opensAbove = below < listMaxHeight && above > below;
|
||||
list.setAttribute("data-placement", opensAbove ? "above" : "below");
|
||||
list.style.maxHeight = `${Math.min(listMaxHeight, opensAbove ? above : below)}px`;
|
||||
if (floating) {
|
||||
const width = listWidth ? Math.min(Math.max(anchor.width, listWidth), bounds.right - bounds.left - 16) : anchor.width;
|
||||
list.style.position = "fixed";
|
||||
list.style.left = `${listWidth ? Math.max(bounds.left + 8, Math.min(anchor.left, bounds.right - width - 8)) : anchor.left}px`;
|
||||
list.style.width = `${width}px`;
|
||||
list.style.right = "auto";
|
||||
list.style.bottom = "auto";
|
||||
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
|
||||
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
|
||||
}
|
||||
}
|
||||
function render(query = "", statusText = "") {
|
||||
if (input.disabled) return;
|
||||
hideTooltip();
|
||||
matches = statusText ? [] : options().filter(item => search || normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
||||
index = matches.findIndex(item => item.value === select.value);
|
||||
list.textContent = "";
|
||||
matches.forEach((item, i) => {
|
||||
const row = element("div", "opportunities-extension-action-option", item.textContent);
|
||||
const description = item.getAttribute?.("data-description");
|
||||
if (description) row.append(element("small", "opportunities-extension-option-description", description));
|
||||
row.id = `${list.id}-${i}`;
|
||||
row.setAttribute("role", "option");
|
||||
row.setAttribute("aria-selected", "false");
|
||||
row.addEventListener("mousedown", event => event.preventDefault());
|
||||
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
|
||||
if (tooltip) {
|
||||
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
|
||||
row.addEventListener("mouseleave", leaveTooltip);
|
||||
}
|
||||
list.append(row);
|
||||
});
|
||||
if (!matches.length) {
|
||||
const empty = element("div", "opportunities-extension-action-option", statusText || "No results");
|
||||
empty.setAttribute("role", "status");
|
||||
list.append(empty);
|
||||
}
|
||||
positionList();
|
||||
list.hidden = false;
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
highlight(false);
|
||||
}
|
||||
input.addEventListener("click", () => { render(); input.select?.(); });
|
||||
function searchInput(event) {
|
||||
if (!search) { render(input.value); return; }
|
||||
cancelSearch(); select.replaceChildren(select.children[0]); select.value = ""; onChange?.();
|
||||
const query = input.value;
|
||||
render("", query.trim() ? "Searching..." : "Type to search.");
|
||||
if (!query.trim() || event?.isComposing) return;
|
||||
const version = searchVersion;
|
||||
const isCurrent = () => !disposed && version === searchVersion && input.isConnected && document.activeElement === input;
|
||||
input.setAttribute("aria-busy", "true");
|
||||
searchTimer = schedule(async () => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const results = await search(query, isCurrent);
|
||||
if (!isCurrent()) return;
|
||||
const placeholder = select.children[0]; select.replaceChildren(placeholder);
|
||||
for (const result of results) {
|
||||
const option = element("option", "", result.label); option.value = result.value;
|
||||
if (result.description) option.setAttribute("data-description", result.description);
|
||||
select.append(option);
|
||||
}
|
||||
select.value = ""; render();
|
||||
} catch (error) { if (isCurrent()) render("", (error.message || "Unable to search contacts.") + " Type to retry."); }
|
||||
finally { if (isCurrent()) input.removeAttribute("aria-busy"); }
|
||||
}, searchDelay);
|
||||
}
|
||||
input.addEventListener("input", searchInput);
|
||||
if (search) input.addEventListener("compositionend", searchInput);
|
||||
input.addEventListener("blur", sync);
|
||||
if (tooltip) {
|
||||
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
|
||||
input.addEventListener("mouseleave", leaveTooltip);
|
||||
}
|
||||
input.addEventListener("keydown", event => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (list.hidden) render();
|
||||
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
|
||||
highlight();
|
||||
} else if (event.key === "Enter" && !list.hidden) {
|
||||
event.preventDefault();
|
||||
choose(index < 0 && matches.length === 1 ? 0 : index);
|
||||
} else if (event.key === "Escape" && (!list.hidden || (tooltip && !tooltip.hidden))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sync();
|
||||
} else if (event.key === "Tab") sync();
|
||||
});
|
||||
sync();
|
||||
return { input, sync, destroy() { disposed = true; closeList(); } };
|
||||
}
|
||||
return searchableSelect;
|
||||
}
|
||||
30
src/features/opportunities/sr-session-preparation.js
Normal file
30
src/features/opportunities/sr-session-preparation.js
Normal file
@@ -0,0 +1,30 @@
|
||||
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
|
||||
let pending = null;
|
||||
let failed = false;
|
||||
function ensure() {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
failed = false;
|
||||
try {
|
||||
reportProgress("running");
|
||||
// Navigation establishes SR cookies independently of the SPA identity.
|
||||
// Consume user-dependent data only once both preparations have finished.
|
||||
const [, result] = await Promise.all([ensureCurrentUser(), sendRequest()]);
|
||||
appendLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
|
||||
if (!allowsCrmActivities()) { reportProgress("idle"); return; }
|
||||
reportProgress("complete");
|
||||
await onReady();
|
||||
} catch {
|
||||
failed = true;
|
||||
reportProgress("error");
|
||||
}
|
||||
})();
|
||||
return pending;
|
||||
}
|
||||
function retry() {
|
||||
if (failed) pending = null;
|
||||
return ensure();
|
||||
}
|
||||
return { ensure, retry };
|
||||
}
|
||||
633
src/features/opportunities/styles.css
Normal file
633
src/features/opportunities/styles.css
Normal file
@@ -0,0 +1,633 @@
|
||||
/* Compact filters use available CSS width, including Windows display scaling. */
|
||||
.opportunities-extension-products-progress,
|
||||
.opportunities-extension-auth-badge { opacity: 1; transition: opacity 300ms ease; }
|
||||
.opportunities-extension-products-progress[data-fading="true"],
|
||||
.opportunities-extension-auth-badge[data-fading="true"] { opacity: 0; }
|
||||
.opportunities-extension-auth-badge[hidden] { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-products-progress,
|
||||
.opportunities-extension-auth-badge { transition: none; }
|
||||
}
|
||||
.opportunities-extension-crm-sr-badge {
|
||||
border: 1px solid #408254;
|
||||
color: #185b30;
|
||||
background: #e1f1e5;
|
||||
}
|
||||
.opportunities-extension-crm-sr-badge[data-state="missing"] { color: #9f201b; background: #fde8e7; border-color: #c74640; }
|
||||
.opportunities-extension-crm-sr-badge[data-state="error"] { color: #785000; background: #fff0cd; border-color: #987021; }
|
||||
.opportunities-extension-wl-actions-badge[data-state="loading"] { color: #5f5a55; background: #eceae8; border-color: #8f8a85; }
|
||||
.opportunities-extension-crm-sr-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge { color: #b8e4c6; background: #214d34; border-color: #6eac82; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #ffd9d5; background: #542824; border-color: #ef938b; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="error"] { color: #ffe0a3; background: #584219; border-color: #c99b4b; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-wl-actions-badge[data-state="loading"] { color: #dedbd7; background: #484541; border-color: #8f8a85; }
|
||||
|
||||
.opportunities-extension-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form > *,
|
||||
.opportunities-extension-badge-filters > section {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
grid-template-rows: auto 32px;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form > * > span:first-child,
|
||||
.opportunities-extension-badge-filters > section > span:first-child {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form .opportunities-extension-select-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.opportunities-extension-form .opportunities-extension-field select,
|
||||
.opportunities-extension-form .opportunities-extension-customer-filter-trigger,
|
||||
.opportunities-extension-form .opportunities-extension-table-search-field input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form .opportunities-extension-field select,
|
||||
.opportunities-extension-form .opportunities-extension-customer-filter-trigger {
|
||||
padding-right: 26px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form .opportunities-extension-select-wrap::after,
|
||||
.opportunities-extension-form .opportunities-extension-customer-filter-trigger::after {
|
||||
right: 10px;
|
||||
border-left-width: 4px;
|
||||
border-right-width: 4px;
|
||||
border-top-width: 5px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form .opportunities-extension-stage-filter-controls,
|
||||
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
||||
.opportunities-extension-form .opportunities-extension-status-filter-button,
|
||||
.opportunities-extension-form .opportunities-extension-activity-filter-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 18px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
|
||||
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
|
||||
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
min-height: 20px;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
|
||||
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
|
||||
display: block;
|
||||
order: -1;
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.opportunities-extension-form {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.opportunities-extension-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.opportunities-extension-form .opportunities-extension-stage-filter-controls,
|
||||
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter { grid-column: auto; }
|
||||
}
|
||||
|
||||
.opportunities-extension-badge-filters { display: contents; }
|
||||
.opportunities-extension-activity-filter { display: grid; }
|
||||
.opportunities-extension-activity-filter[hidden] { display: none; }
|
||||
.opportunities-extension-activity-filter-controls { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
|
||||
/* Flexible fields share the space left by the four compact badge groups. */
|
||||
.opportunities-extension-form[data-activity-filters="true"] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) repeat(4, max-content);
|
||||
column-gap: 8px;
|
||||
}
|
||||
.opportunities-extension-activity-filter-button {
|
||||
min-width: 32px;
|
||||
gap: 4px;
|
||||
color: #185b30;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
|
||||
.opportunities-extension-activity-filter-button[data-value="under4"] { color: #005f7d; }
|
||||
.opportunities-extension-activity-filter-button[aria-pressed="true"] { color: #fff; background: #25653a; border-color: #25653a; }
|
||||
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="missing"] { background: #a12d27; border-color: #a12d27; }
|
||||
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="under4"] { background: #006b86; border-color: #006b86; }
|
||||
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
|
||||
@media (max-width: 1350px) {
|
||||
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
gap: 12px 20px;
|
||||
}
|
||||
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters > section {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.opportunities-extension-activity-filter-controls { flex-wrap: wrap; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
.opportunities-extension-add-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
margin: 0 0 0 2px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
|
||||
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-action-form-modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, .42);
|
||||
}
|
||||
.opportunities-extension-action-form-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
color: #312d2a;
|
||||
border: 1px solid #b9b4ae;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.opportunities-extension-action-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-action-form-context {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 6px 12px;
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.opportunities-extension-action-form-context dt { font-weight: 600; }
|
||||
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
|
||||
.opportunities-extension-action-form-field[hidden] { display: none; }
|
||||
.opportunities-extension-action-form-status { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-action-form-status[role="alert"] { color: #a12d27; }
|
||||
.opportunities-extension-action-form-wide,
|
||||
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
|
||||
.opportunities-extension-action-form-field input,
|
||||
.opportunities-extension-action-form-field select,
|
||||
.opportunities-extension-action-form-field textarea {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 1px solid #8f8983;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font: inherit;
|
||||
line-height: 20px;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
|
||||
.opportunities-extension-action-form-field select { height: 38px; }
|
||||
.opportunities-extension-action-form-field select[hidden] { display: none; }
|
||||
.opportunities-extension-sr-form-dialog { width: min(900px, 100%); }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
|
||||
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
|
||||
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
|
||||
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
|
||||
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
|
||||
@media (min-width: 1000px) {
|
||||
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
|
||||
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
|
||||
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
|
||||
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
|
||||
}
|
||||
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
|
||||
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
|
||||
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
|
||||
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
|
||||
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
|
||||
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
|
||||
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
|
||||
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
|
||||
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
|
||||
.opportunities-extension-sr-lookup-retry[hidden] { display: none; }
|
||||
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
|
||||
.opportunities-extension-action-combobox { position: relative; min-width: 0; }
|
||||
.opportunities-extension-action-combobox input { height: 38px; }
|
||||
.opportunities-extension-action-options {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid #8f8983;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
|
||||
background: #fff;
|
||||
color: #292725;
|
||||
}
|
||||
.opportunities-extension-action-options[data-placement="above"] { top: auto; bottom: calc(100% + 4px); }
|
||||
.opportunities-extension-action-options[hidden] { display: none; }
|
||||
.opportunities-extension-action-option { padding: 8px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
|
||||
.opportunities-extension-action-option:hover,
|
||||
.opportunities-extension-action-option[aria-selected="true"] { background: #e3f2f5; color: #005b70; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-options { background: #211f1d; color: #eeeae6; border-color: #918a82; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option:hover,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option[aria-selected="true"] { background: #174955; color: #c9f4ff; }
|
||||
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
|
||||
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled {
|
||||
animation: opportunities-action-options-loading 1s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes opportunities-action-options-loading { from { opacity: .45; } to { opacity: .85; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled,
|
||||
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: none; }
|
||||
}
|
||||
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.opportunities-extension-action-form-footer button {
|
||||
border: 1px solid #8f8983;
|
||||
border-radius: 4px;
|
||||
padding: 7px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
|
||||
color-scheme: dark;
|
||||
background: #292725;
|
||||
color: #eeeae6;
|
||||
border-color: #756f68;
|
||||
}
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field select,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-status[role="alert"] { color: #ffd9d5; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
|
||||
@media (max-width: 420px) {
|
||||
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* Keep layout rules on the outer table, never on expanded product tables. */
|
||||
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
|
||||
width: 100%;
|
||||
min-width: 1168px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.opportunities-extension-table-skeleton-row { height: 76px; }
|
||||
.opportunities-extension-table-skeleton-text { width: 85%; max-width: 100%; }
|
||||
.opportunities-extension-table-skeleton-row:nth-of-type(even) .opportunities-extension-table-skeleton-text { width: 70%; }
|
||||
.opportunities-extension-table-skeleton-badge { flex: 0 1 30px; width: 30px; min-width: 0; height: 16px; }
|
||||
.opportunities-extension-table-skeleton-row > td[data-column="customer"] .opportunities-extension-table-skeleton-badge { flex-basis: 18px; width: 18px; }
|
||||
.opportunities-extension-table-skeleton-row > td[data-column="status"] .opportunities-extension-table-skeleton-text,
|
||||
.opportunities-extension-table-skeleton-row > td[data-column="stage"] .opportunities-extension-table-skeleton-text { height: 20px; max-width: 66px; border-radius: 8px; }
|
||||
|
||||
/* Fixed table layout shares leftover width only between the two auto columns. */
|
||||
.opportunities-extension-table > thead > tr > th[data-column="name"] { width: auto; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 132px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 106px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 124px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 100px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="status"] { width: 114px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 110px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 94px; }
|
||||
.opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 136px; }
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column] {
|
||||
padding: 10px 8px;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="name"],
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="customer"] {
|
||||
position: relative;
|
||||
padding-right: 16px;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 38px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="owner"] {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="closeDate"],
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="lastUpdateDate"],
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="status"],
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="stage"],
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="winProbability"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-content {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
min-height: 2.7em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-content > :first-child {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 16px;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions .opportunities-extension-action-button {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
|
||||
min-width: 114px;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions button:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opportunities-extension-products-ramp {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid #bba5cf;
|
||||
background: #f0e8f7;
|
||||
color: #63358a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp {
|
||||
background: #43334f;
|
||||
color: #ead5fc;
|
||||
border-color: #9372ac;
|
||||
}
|
||||
|
||||
.opportunities-extension-products-ramp[data-missing="true"] {
|
||||
background: #fde8e7;
|
||||
color: #9f201b;
|
||||
border-color: #c74640;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-missing="true"] {
|
||||
background: #542824;
|
||||
color: #ffd9d5;
|
||||
border-color: #ef938b;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 12px;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > thead > tr > th[data-column="name"] .opportunities-extension-sort-button,
|
||||
.opportunities-extension-table > thead > tr > th[data-column="customer"] .opportunities-extension-sort-button {
|
||||
padding-left: 12px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.opportunities-extension-sort-label {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button::after {
|
||||
position: static;
|
||||
justify-self: end;
|
||||
}
|
||||
.opportunities-extension-crm-sr-badge[type="button"] { cursor: pointer; font-family: inherit; }
|
||||
.opportunities-extension-sr-popover {
|
||||
position: fixed; z-index: 8; box-sizing: border-box; overflow: auto;
|
||||
padding: 12px; border: 1px solid #b9b5b0; border-radius: 6px;
|
||||
background: #fff; color: #302e2b; box-shadow: 0 6px 24px #0003;
|
||||
font: 12px/1.5 Arial, sans-serif; text-align: left; overflow-wrap: anywhere;
|
||||
}
|
||||
.opportunities-extension-sr-popover section { padding: 8px 0; }
|
||||
.opportunities-extension-sr-popover section + section { border-top: 1px solid #dedbd7; }
|
||||
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
|
||||
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
|
||||
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
|
||||
.opportunities-extension-sr-children { margin: 10px 0 0 8px; padding-left: 10px; border-left: 2px solid #8c969a; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-sr-children[aria-busy="true"] { min-height: 28px; animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
|
||||
.opportunities-extension-sr-children[role="alert"] { color: #a12d27; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-children[role="alert"] { color: #ffd9d5; }
|
||||
@media (prefers-reduced-motion: reduce) { .opportunities-extension-sr-children[aria-busy="true"] { animation: none; } }
|
||||
.opportunities-extension-sr-dismiss {
|
||||
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
||||
display: grid; place-items: center; padding: 4px; border: 0;
|
||||
background: transparent; color: inherit; cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-sr-dismiss svg { width: 16px; height: 16px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
||||
.opportunities-extension-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.opportunities-extension-title-row h1 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-height: 22px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
|
||||
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
|
||||
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
|
||||
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
|
||||
}
|
||||
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
|
||||
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }
|
||||
.opportunities-extension-session-retry {
|
||||
margin-left: 8px; padding: 2px 6px; border: 1px solid currentColor;
|
||||
border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-session-retry:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-session-retry:disabled { opacity: .6; cursor: wait; }
|
||||
11
src/features/opportunities/table-state.js
Normal file
11
src/features/opportunities/table-state.js
Normal file
@@ -0,0 +1,11 @@
|
||||
function createOpportunitiesTableState() {
|
||||
return {
|
||||
opportunitiesTableState: {
|
||||
items: [],
|
||||
status: "idle",
|
||||
message: "",
|
||||
sortKey: "",
|
||||
sortDirection: "ascending"
|
||||
}
|
||||
};
|
||||
}
|
||||
238
src/features/opportunities/table.js
Normal file
238
src/features/opportunities/table.js
Normal file
@@ -0,0 +1,238 @@
|
||||
function createOpportunitiesTable({
|
||||
tableSorting,
|
||||
ACCOUNT_DETAIL_URL,
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
OPPORTUNITIES_TABLE_ID,
|
||||
OPPORTUNITY_DETAIL_URL,
|
||||
createCloudConsumptionButton,
|
||||
createInstallBaseButton,
|
||||
createServiceRequestsButton,
|
||||
createOrganizationChartButton,
|
||||
createCopyOpportunityButton,
|
||||
createDetailLink,
|
||||
createProductsCountButton,
|
||||
createProductsRampBadge,
|
||||
createProjectionIndicator,
|
||||
createCrmServiceRequestBadge,
|
||||
closeCrmSrPopover,
|
||||
createWorkloadActionsBadge,
|
||||
createProductsDetailRow,
|
||||
createStageBadge,
|
||||
createStatusBadge,
|
||||
displayOpportunityValue,
|
||||
getNestedValue,
|
||||
getSortedOpportunities,
|
||||
opportunitiesTableState,
|
||||
productsContentState,
|
||||
renderCustomerFilter,
|
||||
renderOwnerFilter,
|
||||
renderActivityFilters,
|
||||
renderStageDashboard,
|
||||
renderCalendar,
|
||||
resetCustomerFilter,
|
||||
resetOwnerFilter,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function setOpportunitiesTableState(nextState) {
|
||||
if (nextState.status === "ready" && Array.isArray(nextState.items)) {
|
||||
resetCustomerFilter(nextState.items);
|
||||
resetOwnerFilter(nextState.items);
|
||||
}
|
||||
|
||||
opportunitiesTableState.opportunitiesTableState = {
|
||||
...opportunitiesTableState.opportunitiesTableState,
|
||||
...nextState,
|
||||
sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
|
||||
sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
|
||||
};
|
||||
renderOpportunitiesTable();
|
||||
renderCustomerFilter();
|
||||
renderOwnerFilter();
|
||||
}
|
||||
|
||||
function renderOpportunitiesTable() {
|
||||
renderActivityFilters();
|
||||
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
||||
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeCrmSrPopover();
|
||||
table.textContent = "";
|
||||
table.setAttribute("data-opportunities-layout", "inline-actions");
|
||||
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
|
||||
|
||||
const caption = document.createElement("caption");
|
||||
caption.className = "opportunities-extension-visually-hidden";
|
||||
caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
|
||||
|
||||
const tableHead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
|
||||
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||
const header = document.createElement("th");
|
||||
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
|
||||
header.scope = "col";
|
||||
header.setAttribute("data-column", column.key);
|
||||
if (column.key === "revenue") header.setAttribute("data-align", "money");
|
||||
|
||||
if (column.sortable === false) {
|
||||
const label = document.createElement("span");
|
||||
label.className = "opportunities-extension-table-header-label";
|
||||
label.textContent = column.label;
|
||||
header.append(label);
|
||||
} else {
|
||||
header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||
|
||||
const sortButton = document.createElement("button");
|
||||
sortButton.type = "button";
|
||||
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
|
||||
sortButton.className = "opportunities-extension-sort-button";
|
||||
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
||||
const sortLabel = document.createElement("span");
|
||||
sortLabel.className = "opportunities-extension-sort-label";
|
||||
sortLabel.textContent = column.label;
|
||||
sortButton.append(sortLabel);
|
||||
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
|
||||
header.append(sortButton);
|
||||
}
|
||||
|
||||
headerRow.append(header);
|
||||
});
|
||||
|
||||
tableHead.append(headerRow);
|
||||
|
||||
const tableBody = document.createElement("tbody");
|
||||
const items = getSortedOpportunities();
|
||||
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
|
||||
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
|
||||
|
||||
if (opportunitiesTableState.opportunitiesTableState.status === "loading") {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "opportunities-extension-table-skeleton-row";
|
||||
row.setAttribute("aria-hidden", "true");
|
||||
OPPORTUNITIES_COLUMNS.forEach(column => {
|
||||
const cell = document.createElement("td");
|
||||
cell.setAttribute("data-column", column.key);
|
||||
if (column.key === "revenue") cell.setAttribute("data-align", "money");
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-text";
|
||||
cell.append(bar);
|
||||
if (column.key === "name" || column.key === "customer") {
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "opportunities-extension-cell-actions";
|
||||
for (let action = 0; action < 4; action++) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-badge";
|
||||
actions.append(badge);
|
||||
}
|
||||
cell.append(actions);
|
||||
}
|
||||
row.append(cell);
|
||||
});
|
||||
tableBody.append(row);
|
||||
}
|
||||
} else if (opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "opportunities-extension-table-status";
|
||||
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
|
||||
cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found.";
|
||||
row.append(cell);
|
||||
tableBody.append(row);
|
||||
} else {
|
||||
items.forEach((item) => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||
const cell = document.createElement("td");
|
||||
cell.setAttribute("data-column", column.key);
|
||||
if (column.key === "revenue") cell.setAttribute("data-align", "money");
|
||||
|
||||
if (column.key === "stage") {
|
||||
cell.append(createStageBadge(column.display(item)));
|
||||
} else if (column.key === "status") {
|
||||
cell.append(createStatusBadge(column.display(item)));
|
||||
} else if (column.key === "name" || column.key === "optyNumber") {
|
||||
const opportunityLink = createDetailLink(
|
||||
column.display(item),
|
||||
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
|
||||
);
|
||||
|
||||
if (column.key === "name") {
|
||||
const nameCellContent = document.createElement("span");
|
||||
nameCellContent.className = "opportunities-extension-cell-content";
|
||||
const productActions = document.createElement("span");
|
||||
productActions.className = "opportunities-extension-cell-actions";
|
||||
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||
if (productsResult) {
|
||||
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
|
||||
const rampBadge = createProductsRampBadge(productsResult, item);
|
||||
if (rampBadge) productActions.append(rampBadge);
|
||||
}
|
||||
const projectionIndicator = createProjectionIndicator(productsResult, item,
|
||||
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
|
||||
if (projectionIndicator) productActions.append(projectionIndicator);
|
||||
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
|
||||
if (srBadge) productActions.append(srBadge);
|
||||
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
||||
if (actionsBadge) productActions.append(actionsBadge);
|
||||
nameCellContent.append(opportunityLink, productActions);
|
||||
cell.append(nameCellContent);
|
||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||
const opportunityCellContent = document.createElement("span");
|
||||
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
||||
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
||||
cell.append(opportunityCellContent);
|
||||
} else {
|
||||
cell.append(opportunityLink);
|
||||
}
|
||||
} else if (column.key === "customer") {
|
||||
const customerCellContent = document.createElement("span");
|
||||
customerCellContent.className = "opportunities-extension-cell-content";
|
||||
const customerLink = createDetailLink(
|
||||
column.display(item),
|
||||
getNestedValue(item, ["CustomerAccount", "PartyId"])
|
||||
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
|
||||
: ""
|
||||
);
|
||||
const customerActions = document.createElement("span");
|
||||
customerActions.className = "opportunities-extension-cell-actions";
|
||||
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item), createOrganizationChartButton(item));
|
||||
customerCellContent.append(customerLink, customerActions);
|
||||
cell.append(customerCellContent);
|
||||
} else {
|
||||
cell.textContent = displayOpportunityValue(column, item);
|
||||
}
|
||||
|
||||
row.append(cell);
|
||||
});
|
||||
|
||||
tableBody.append(row);
|
||||
|
||||
if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) {
|
||||
tableBody.append(createProductsDetailRow(item));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
table.append(caption, tableHead, tableBody);
|
||||
}
|
||||
|
||||
function sortOpportunitiesBy(key) {
|
||||
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
|
||||
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
||||
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
||||
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
||||
? "descending"
|
||||
: "ascending";
|
||||
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user