Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
248
README.md
248
README.md
@@ -1,6 +1,74 @@
|
|||||||
# Opportunities Extension 1.0.2
|
# Opportunities Extension 1.0.7
|
||||||
|
|
||||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
|
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.
|
||||||
|
|
||||||
|
## Opportunity Views
|
||||||
|
|
||||||
|
**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
|
## Development Structure
|
||||||
|
|
||||||
@@ -19,40 +87,125 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
|
|||||||
|
|
||||||
## CRM Service Request Badges
|
## 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`
|
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
||||||
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
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.
|
do not trigger CRM Service Request or Workload Actions queries or see their badges.
|
||||||
If identity or roles cannot be validated, only these two features stay disabled;
|
Identity must contain a valid user (id, name, roles) and token before dependent
|
||||||
other opportunity features continue. Reload the page to refresh identity/roles.
|
requests start. Failed identity validation keeps those requests waiting and retries
|
||||||
The unused identity token is discarded and redacted from debug responses.
|
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.
|
||||||
|
|
||||||
Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of
|
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`,
|
`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`
|
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
||||||
query endpoint, independently of the opportunity query version. Calls appear in
|
query endpoint, independently of the opportunity query version. Calls appear in
|
||||||
the Service Requests debug category with masked credentials.
|
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
|
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,
|
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.
|
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.
|
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
||||||
Badges follow Ramp Months and do not wait for product downloads. SR results remain
|
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||||
in memory only. Reference changes share the in-flight lookup and reuse its result
|
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||||
(including failures) without another request. Closing resets this state; reopening
|
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||||
starts a fresh lookup, and responses from previous openings are ignored.
|
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
|
## Workload Actions
|
||||||
|
|
||||||
Workload Actions are queried in the background after product results arrive,
|
The missing Act badge includes a separate plus control that opens an Action form
|
||||||
including cached products. Each distinct `RevnId` uses `requestWlActions` with
|
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
|
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
|
aggregates all products: green means an action's `owner` matches the general
|
||||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||||
means ownership could not be confirmed (including a missing principal). Tooltips
|
means ownership could not be confirmed (including a missing principal). Tooltips
|
||||||
retain all returned actions, regardless of owner. Principal stays in memory only.
|
retain all returned actions, regardless of owner. Credentials stay in memory only;
|
||||||
Queries are shared per modal opening (up to four concurrently), reused
|
the principal is used to scope persisted activity entries.
|
||||||
across Reference changes, and discarded on close. Reopening queries again.
|
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.
|
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.
|
The debug panel groups these requests under Actions, with credentials masked.
|
||||||
|
|
||||||
@@ -179,8 +332,9 @@ HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
|
|||||||
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
|
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
|
||||||
supported protection prefixes and JSON documents wrapped in strings are normalized
|
supported protection prefixes and JSON documents wrapped in strings are normalized
|
||||||
and indented, including while sensitive values are visible. Large JSON responses
|
and indented, including while sensitive values are visible. Large JSON responses
|
||||||
are captured as bounded, valid JSON previews with an explicit omission notice,
|
are captured in full, preserving indentation, highlighting and sensitive-field
|
||||||
preserving indentation, highlighting and sensitive-field masking. Already incomplete
|
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
|
or invalid JSON remains plain text. The visibility toggle retains its existing
|
||||||
behavior. Focus, expansion and timeline scroll position survive log updates.
|
behavior. Focus, expansion and timeline scroll position survive log updates.
|
||||||
|
|
||||||
@@ -246,6 +400,66 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
|
|||||||
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
## Loading in Development Mode
|
## Loading in Development Mode
|
||||||
|
|
||||||
### Chrome or Microsoft Edge
|
### Chrome or Microsoft Edge
|
||||||
|
|||||||
1717
dist/chromium/background.js
vendored
1717
dist/chromium/background.js
vendored
File diff suppressed because it is too large
Load Diff
5642
dist/chromium/content.js
vendored
5642
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
19
dist/chromium/manifest.json
vendored
19
dist/chromium/manifest.json
vendored
@@ -2,14 +2,17 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Opportunities Extension",
|
"name": "Opportunities Extension",
|
||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.2",
|
"version": "1.0.8",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,16 @@
|
|||||||
],
|
],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://spa.oracle.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"spa-page.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_end"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"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();
|
||||||
|
})();
|
||||||
1717
dist/firefox/background.js
vendored
1717
dist/firefox/background.js
vendored
File diff suppressed because it is too large
Load Diff
5642
dist/firefox/content.js
vendored
5642
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
19
dist/firefox/manifest.json
vendored
19
dist/firefox/manifest.json
vendored
@@ -2,14 +2,17 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Opportunities Extension",
|
"name": "Opportunities Extension",
|
||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.2",
|
"version": "1.0.8",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,16 @@
|
|||||||
],
|
],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://spa.oracle.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"spa-page.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_end"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"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();
|
||||||
|
})();
|
||||||
@@ -14,6 +14,11 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
|
|||||||
uses `background.scripts`. Neither entry point is declared as a module.
|
uses `background.scripts`. Neither entry point is declared as a module.
|
||||||
- Content scripts run at `document_idle`, in all matching frames on the two
|
- Content scripts run at `document_idle`, in all matching frames on the two
|
||||||
configured Fusion welcome pages. Their code also checks the hostname/path.
|
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,
|
- There are no extension UI pages, injected page-context scripts, custom CSP,
|
||||||
declared execution world, or web-accessible resources.
|
declared execution world, or web-accessible resources.
|
||||||
- Permissions remain `cookies` and `storage`, with host access limited to the
|
- Permissions remain `cookies` and `storage`, with host access limited to the
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pass until executed and recorded separately for each browser.
|
|||||||
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
|
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
|
||||||
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
|
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
|
||||||
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
|
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
|
||||||
directory and version 1.0.2; avoid testing a previously installed stale copy.
|
directory and version 1.0.7; avoid testing a previously installed stale copy.
|
||||||
- Keep debug secrets hidden. Record only status codes, request names, durations
|
- Keep debug secrets hidden. Record only status codes, request names, durations
|
||||||
and sanitized errors. Do not export real tokens, response bodies or account data.
|
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
|
- AI generation and tests requiring a deliberately expired session must use an
|
||||||
@@ -34,6 +34,8 @@ pass until executed and recorded separately for each browser.
|
|||||||
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opportunities-extension",
|
"name": "opportunities-extension",
|
||||||
"version": "1.0.2",
|
"version": "1.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,20 +8,24 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|||||||
const distDir = path.join(rootDir, "dist");
|
const distDir = path.join(rootDir, "dist");
|
||||||
const contentScript = composition.composeScript("content");
|
const contentScript = composition.composeScript("content");
|
||||||
const backgroundScript = composition.composeScript("background");
|
const backgroundScript = composition.composeScript("background");
|
||||||
|
const spaPageScript = composition.composeScript("spa-page");
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
const baseManifest = {
|
const baseManifest = {
|
||||||
manifest_version: 3,
|
manifest_version: 3,
|
||||||
name: "Opportunities Extension",
|
name: "Opportunities Extension",
|
||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.2",
|
version: "1.0.8",
|
||||||
permissions: [
|
permissions: [
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
host_permissions: [
|
host_permissions: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
{
|
{
|
||||||
@@ -32,6 +36,12 @@ const baseManifest = {
|
|||||||
js: ["content.js"],
|
js: ["content.js"],
|
||||||
all_frames: true,
|
all_frames: true,
|
||||||
run_at: "document_idle"
|
run_at: "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ["https://spa.oracle.com/*"],
|
||||||
|
js: ["spa-page.js"],
|
||||||
|
all_frames: false,
|
||||||
|
run_at: "document_end"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -67,7 +77,7 @@ for (const target of targets) {
|
|||||||
JSON.parse(manifestJson);
|
JSON.parse(manifestJson);
|
||||||
|
|
||||||
if (checkOnly) {
|
if (checkOnly) {
|
||||||
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript };
|
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript };
|
||||||
for (const [file, contents] of Object.entries(expected)) {
|
for (const [file, contents] of Object.entries(expected)) {
|
||||||
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
|
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.`);
|
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
|
||||||
@@ -79,6 +89,7 @@ for (const target of targets) {
|
|||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||||
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
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);
|
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const fs = require("node:fs");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
const sources = require("./sources.cjs");
|
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, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
@@ -16,15 +17,23 @@ function composeScript(target, { bootstrap = true } = {}) {
|
|||||||
return `// Source: ${file}\n${source}`;
|
return `// Source: ${file}\n${source}`;
|
||||||
});
|
});
|
||||||
if (target === "content") {
|
if (target === "content") {
|
||||||
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"]
|
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");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
}
|
}
|
||||||
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
|
const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
|
||||||
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
|
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
|
||||||
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
|
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
|
||||||
new vm.Script(output, { filename: `${target}.js` });
|
new vm.Script(output, { filename: `${target}.js` });
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { composeScript, sources };
|
module.exports = { composeScript, sources, vendorSources };
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const server = http.createServer(async (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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") {
|
if (pathname === "/fixture-runtime.js") {
|
||||||
response.setHeader("Content-Type", "text/javascript");
|
response.setHeader("Content-Type", "text/javascript");
|
||||||
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);
|
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
|
"spa-page": ["src/auth/spa-page.js"],
|
||||||
"content": [
|
"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/messages.js",
|
||||||
"src/shared/debug-body.js",
|
"src/shared/debug-body.js",
|
||||||
"src/content/contracts.js",
|
"src/content/contracts.js",
|
||||||
"src/auth/session-state.js",
|
"src/auth/session-state.js",
|
||||||
"src/auth/sales-common-state.js",
|
|
||||||
"src/features/opportunities/query-state.js",
|
"src/features/opportunities/query-state.js",
|
||||||
"src/features/opportunities/table-state.js",
|
"src/features/opportunities/table-state.js",
|
||||||
"src/features/opportunities/filter-state.js",
|
"src/features/opportunities/filter-state.js",
|
||||||
"src/features/preferences/state.js",
|
"src/features/preferences/state.js",
|
||||||
|
"src/features/preferences/request-cache.js",
|
||||||
"src/features/products/content-state.js",
|
"src/features/products/content-state.js",
|
||||||
"src/features/ai-summary/state.js",
|
|
||||||
"src/features/consumption/plans-state.js",
|
"src/features/consumption/plans-state.js",
|
||||||
"src/features/consumption/trend-state.js",
|
"src/features/consumption/trend-state.js",
|
||||||
"src/features/opportunities/dashboard-state.js",
|
"src/features/opportunities/dashboard-state.js",
|
||||||
"src/features/debug/state.js",
|
"src/features/debug/state.js",
|
||||||
"src/features/opportunities/columns.js",
|
"src/features/opportunities/columns.js",
|
||||||
|
"src/features/opportunities/badge-content.js",
|
||||||
"src/content/fusion-page.js",
|
"src/content/fusion-page.js",
|
||||||
"src/content/modal.js",
|
"src/content/modal.js",
|
||||||
"src/content/theme.js",
|
"src/content/theme.js",
|
||||||
@@ -24,7 +33,6 @@ module.exports = {
|
|||||||
"src/content/styles.js",
|
"src/content/styles.js",
|
||||||
"src/auth/content-session.js",
|
"src/auth/content-session.js",
|
||||||
"src/auth/current-user.js",
|
"src/auth/current-user.js",
|
||||||
"src/auth/sales-common.js",
|
|
||||||
"src/auth/token-relay.js",
|
"src/auth/token-relay.js",
|
||||||
"src/auth/xsrf-content.js",
|
"src/auth/xsrf-content.js",
|
||||||
"src/platform/content-messages.js",
|
"src/platform/content-messages.js",
|
||||||
@@ -32,10 +40,23 @@ module.exports = {
|
|||||||
"src/services/fusion/opportunities.js",
|
"src/services/fusion/opportunities.js",
|
||||||
"src/services/fusion/crm-service-requests.js",
|
"src/services/fusion/crm-service-requests.js",
|
||||||
"src/features/opportunities/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-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-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/fiscal-periods.js",
|
||||||
"src/features/opportunities/table.js",
|
"src/features/opportunities/table.js",
|
||||||
|
"src/features/opportunities/calendar.js",
|
||||||
"src/features/opportunities/dashboard.js",
|
"src/features/opportunities/dashboard.js",
|
||||||
"src/features/opportunities/filters.js",
|
"src/features/opportunities/filters.js",
|
||||||
"src/features/opportunities/formatters.js",
|
"src/features/opportunities/formatters.js",
|
||||||
@@ -43,8 +64,11 @@ module.exports = {
|
|||||||
"src/features/preferences/storage.js",
|
"src/features/preferences/storage.js",
|
||||||
"src/features/preferences/view.js",
|
"src/features/preferences/view.js",
|
||||||
"src/features/products/content-jobs.js",
|
"src/features/products/content-jobs.js",
|
||||||
|
"src/features/products/workloads.js",
|
||||||
"src/features/products/details.js",
|
"src/features/products/details.js",
|
||||||
"src/features/ai-summary/content.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/consumption/plans-action.js",
|
||||||
"src/features/install-base/action.js",
|
"src/features/install-base/action.js",
|
||||||
"src/features/install-base/view.js",
|
"src/features/install-base/view.js",
|
||||||
@@ -67,17 +91,28 @@ module.exports = {
|
|||||||
"src/content/application.js"
|
"src/content/application.js"
|
||||||
],
|
],
|
||||||
"background": [
|
"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/messages.js",
|
||||||
"src/shared/debug-body.js",
|
"src/shared/debug-body.js",
|
||||||
"src/background/contracts.js",
|
"src/background/contracts.js",
|
||||||
"src/features/products/cache-state.js",
|
"src/features/products/cache-state.js",
|
||||||
"src/auth/consumer-session.js",
|
"src/auth/consumer-session.js",
|
||||||
"src/auth/spa-credentials.js",
|
"src/auth/spa-credentials.js",
|
||||||
|
"src/auth/sr-request-headers.js",
|
||||||
"src/services/spa/accounts.js",
|
"src/services/spa/accounts.js",
|
||||||
"src/services/spa/install-base.js",
|
"src/services/spa/install-base.js",
|
||||||
"src/services/spa/customer-service-requests.js",
|
"src/services/spa/customer-service-requests.js",
|
||||||
"src/services/spa/workload-actions.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/current-user.js",
|
||||||
|
"src/services/spa/page-client.js",
|
||||||
"src/services/spa/consumption.js",
|
"src/services/spa/consumption.js",
|
||||||
"src/services/spa/response-shapes.js",
|
"src/services/spa/response-shapes.js",
|
||||||
"src/services/http/background-json.js",
|
"src/services/http/background-json.js",
|
||||||
|
|||||||
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; });
|
||||||
@@ -8,9 +8,16 @@ function createAuthConsumerSession({
|
|||||||
fetchJson,
|
fetchJson,
|
||||||
supportsFirefoxCookieStoreTabs,
|
supportsFirefoxCookieStoreTabs,
|
||||||
tabsCreate,
|
tabsCreate,
|
||||||
|
tabsUpdate,
|
||||||
tabsRemove,
|
tabsRemove,
|
||||||
|
responseHeaders,
|
||||||
|
cookieChanges,
|
||||||
|
inspectPage,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
wait
|
wait
|
||||||
}) {
|
}) {
|
||||||
|
const sessionTabs = new Map();
|
||||||
async function requestConsumerTokenService(sender) {
|
async function requestConsumerTokenService(sender) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
|
|
||||||
@@ -103,14 +110,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& 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];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
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;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,11 +153,12 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
await wait(retryDelayMs);
|
if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
|
||||||
|
else await wait(retryDelayMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +168,7 @@ function createAuthConsumerSession({
|
|||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tabsRemove(sessionTab.id);
|
await closeConsumerTokenSessionTab(sessionTab.id);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
||||||
? "closed-after-valid-json"
|
? "closed-after-valid-json"
|
||||||
@@ -204,7 +220,7 @@ function createAuthConsumerSession({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openConsumerTokenSessionTab(sender) {
|
async function openConsumerTokenSessionTab(sender) {
|
||||||
const details = {
|
const details = {
|
||||||
url: CUSTOMER_TOKEN_SESSION_URL,
|
url: CUSTOMER_TOKEN_SESSION_URL,
|
||||||
active: false
|
active: false
|
||||||
@@ -218,8 +234,184 @@ function createAuthConsumerSession({
|
|||||||
details.cookieStoreId = sender.tab.cookieStoreId;
|
details.cookieStoreId = sender.tab.cookieStoreId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabsCreate(details);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function createAuthContentSession({
|
|||||||
return token;
|
return token;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
authSessionState.tokenRelayRequest = null;
|
||||||
authSessionState.accessToken = "";
|
authSessionState.accessToken = "";
|
||||||
authSessionState.principal = "";
|
authSessionState.principal = "";
|
||||||
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
|
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
|
||||||
|
const { setTimeout } = environment;
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
|
let userId = "";
|
||||||
function ensure() {
|
function ensure() {
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
pending = (async () => {
|
||||||
appendBackgroundRequestLog(response?.debugRequests);
|
let retryDelay = 1000;
|
||||||
const roles = response?.user?.roles;
|
// Keep a single pending identity request until the response is usable.
|
||||||
allowed = response?.ok === true && Array.isArray(roles)
|
for (;;) {
|
||||||
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
try {
|
||||||
return allowed;
|
const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
|
||||||
}).catch(() => { allowed = false; return false; });
|
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 pending;
|
||||||
}
|
}
|
||||||
return { ensure, allowsCrmActivities: () => allowed };
|
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise<string>}} */
|
|
||||||
function createAuthSalesCommonState() {
|
|
||||||
return {
|
|
||||||
salesCommonToken: null,
|
|
||||||
salesCommonTokenRequest: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
function createAuthSalesCommon({
|
|
||||||
TOKEN_RELAY_URL,
|
|
||||||
authSalesCommonState,
|
|
||||||
requestTokenRelay
|
|
||||||
}) {
|
|
||||||
function requestTokenRelaySalesCommon() {
|
|
||||||
if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) {
|
|
||||||
return Promise.resolve(authSalesCommonState.salesCommonToken.access_token);
|
|
||||||
}
|
|
||||||
if (authSalesCommonState.salesCommonTokenRequest) {
|
|
||||||
return authSalesCommonState.salesCommonTokenRequest;
|
|
||||||
}
|
|
||||||
authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({
|
|
||||||
name: "requestTokenRelaySalesCommon",
|
|
||||||
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
|
|
||||||
includeExpiry: true
|
|
||||||
}).then((token) => {
|
|
||||||
authSalesCommonState.salesCommonToken = token;
|
|
||||||
return token.access_token;
|
|
||||||
}).finally(() => {
|
|
||||||
authSalesCommonState.salesCommonTokenRequest = null;
|
|
||||||
});
|
|
||||||
return authSalesCommonState.salesCommonTokenRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateSalesCommonToken(rejectedToken) {
|
|
||||||
if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) {
|
|
||||||
authSalesCommonState.salesCommonToken = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { requestTokenRelaySalesCommon, invalidateSalesCommonToken };
|
|
||||||
}
|
|
||||||
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 };
|
||||||
|
}
|
||||||
@@ -34,17 +34,27 @@ function createAuthTokenRelay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
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 {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await Promise.race([fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
headers: {
|
headers: {
|
||||||
"x-xsrf-token": xsrfTokenSource.token
|
"x-xsrf-token": xsrfTokenSource.token
|
||||||
}
|
}
|
||||||
});
|
}), timeout]);
|
||||||
|
|
||||||
const responseBody = await readResponseBodyForDebug(response);
|
const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logRequestSuccess(requestEntry, response, responseBody);
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
@@ -63,8 +73,9 @@ function createAuthTokenRelay({
|
|||||||
let responseData;
|
let responseData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
responseData = await response.json();
|
responseData = await Promise.race([response.json(), timeout]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (controller?.signal.aborted) throw error;
|
||||||
logRequestSuccess(requestEntry, response, responseBody);
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
|
|
||||||
if (!refreshAttempted) {
|
if (!refreshAttempted) {
|
||||||
@@ -92,10 +103,7 @@ function createAuthTokenRelay({
|
|||||||
throw new Error("Token relay response does not contain access_token.");
|
throw new Error("Token relay response does not contain access_token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
...responseData,
|
|
||||||
access_token: maskToken(responseData.access_token)
|
|
||||||
}, null, 2)));
|
|
||||||
|
|
||||||
if (url === TOKEN_RELAY_URL) {
|
if (url === TOKEN_RELAY_URL) {
|
||||||
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
||||||
@@ -112,6 +120,8 @@ function createAuthTokenRelay({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRequestFailure(requestEntry, error.message || "Request failed.");
|
logRequestFailure(requestEntry, error.message || "Request failed.");
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +132,7 @@ function createAuthTokenRelay({
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
"x-xsrf-token": xsrfTokenSource.token || "(missing)"
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
|
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
function createBackgroundApplication(overrides = {}) {
|
function createBackgroundApplication(overrides = {}) {
|
||||||
const environment = {
|
const environment = {
|
||||||
|
indexedDB: globalThis.indexedDB,
|
||||||
fetch: globalThis.fetch,
|
fetch: globalThis.fetch,
|
||||||
browser: globalThis.browser,
|
browser: globalThis.browser,
|
||||||
chrome: globalThis.chrome,
|
chrome: globalThis.chrome,
|
||||||
setTimeout: globalThis.setTimeout,
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
|
||||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||||
const contracts = createBackgroundContracts();
|
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 productsCacheState = createProductsCacheState();
|
||||||
const authConsumerSession = createAuthConsumerSession({
|
const authConsumerSession = createAuthConsumerSession({
|
||||||
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
||||||
@@ -21,7 +37,13 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
||||||
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||||
|
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
|
||||||
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...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)
|
wait: (...args) => platformBackgroundApi.wait(...args)
|
||||||
});
|
});
|
||||||
const authSpaCredentials = createAuthSpaCredentials({
|
const authSpaCredentials = createAuthSpaCredentials({
|
||||||
@@ -56,8 +78,8 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const spaResponseShapes = createSpaResponseShapes();
|
const spaResponseShapes = createSpaResponseShapes();
|
||||||
const httpBackgroundJson = createHttpBackgroundJson({
|
const httpBackgroundJson = createHttpBackgroundJson({
|
||||||
|
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
|
||||||
previewDebugBody: createDebugBodyPreview,
|
previewDebugBody: createDebugBodyPreview,
|
||||||
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
|
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const productsBackgroundJobs = createProductsBackgroundJobs({
|
const productsBackgroundJobs = createProductsBackgroundJobs({
|
||||||
@@ -114,7 +136,21 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...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 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 requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
@@ -122,8 +158,44 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
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() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
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 {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
@@ -138,6 +210,8 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
httpBackgroundJson,
|
httpBackgroundJson,
|
||||||
productsBackgroundJobs,
|
productsBackgroundJobs,
|
||||||
productsCache,
|
productsCache,
|
||||||
|
workloadsCache,
|
||||||
|
spaWorkloads,
|
||||||
fusionProducts,
|
fusionProducts,
|
||||||
authXsrfRecovery,
|
authXsrfRecovery,
|
||||||
authXsrfCookies,
|
authXsrfCookies,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function createBackgroundContracts() {
|
|||||||
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
||||||
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
||||||
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
|
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/oalcrm/web/api/g2m-consumer-application/ui/index.html";
|
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/";
|
||||||
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
|
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
|
||||||
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
|
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
|
||||||
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
|
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
|
loadOrganizationChart,
|
||||||
|
saveOrganizationChart,
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
requestWeekDetails,
|
||||||
|
requestTimeEntries,
|
||||||
|
requestTimeEntriesSave,
|
||||||
|
requestTimeEntriesDelete,
|
||||||
|
requestLookupTaskType,
|
||||||
|
requestServiceRequestExpand,
|
||||||
|
requestResourceUser,
|
||||||
|
requestActiveServiceRequests,
|
||||||
|
invalidateProductsCache,
|
||||||
|
invalidateResourceUser = async () => {},
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestWorkloads,
|
||||||
|
requestWorkloadForecastUpdate,
|
||||||
|
invalidateWorkloadsCache,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestPostServiceRequest,
|
||||||
|
requestPostInternalServiceRequest,
|
||||||
|
requestInternalServiceRequest,
|
||||||
|
requestSrLookupPrimaryPillar,
|
||||||
|
requestSrLookupWithSession,
|
||||||
|
prepareSrSession,
|
||||||
|
requestSrLookupLanguage,
|
||||||
|
requestSrLookupTechPlatform,
|
||||||
|
requestLookupTypePlatform,
|
||||||
|
requestInternalSrServices,
|
||||||
|
requestSrLookupOpportunityBusiness,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -19,7 +48,65 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
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) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -90,13 +177,31 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestCurrentUser) {
|
if (message.type === messageTypes.requestCurrentUser) {
|
||||||
requestCurrentUser().then(sendResponse).catch(error => sendResponse({
|
requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
|
||||||
ok: false, error: error.message || "Unable to load current user.",
|
ok: false, error: error.message || "Unable to load current user.",
|
||||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
}));
|
}));
|
||||||
return true;
|
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) {
|
if (message.type === messageTypes.requestWlActions) {
|
||||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||||
ok: false, error: error.message || "Unable to load workload actions.",
|
ok: false, error: error.message || "Unable to load workload actions.",
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ function createContentApplication(overrides = {}) {
|
|||||||
document: globalThis.document,
|
document: globalThis.document,
|
||||||
window: globalThis.window,
|
window: globalThis.window,
|
||||||
navigator: globalThis.navigator,
|
navigator: globalThis.navigator,
|
||||||
|
crypto: globalThis.crypto,
|
||||||
get localStorage() { return globalThis.localStorage; },
|
get localStorage() { return globalThis.localStorage; },
|
||||||
fetch: globalThis.fetch,
|
fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
|
||||||
browser: globalThis.browser,
|
browser: globalThis.browser,
|
||||||
chrome: globalThis.chrome,
|
chrome: globalThis.chrome,
|
||||||
MutationObserver: globalThis.MutationObserver,
|
MutationObserver: globalThis.MutationObserver,
|
||||||
@@ -12,30 +13,54 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||||
performance: globalThis.performance,
|
performance: globalThis.performance,
|
||||||
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||||
setTimeout: globalThis.setTimeout,
|
setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
|
||||||
clearTimeout: globalThis.clearTimeout,
|
clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
|
||||||
|
AbortController: globalThis.AbortController,
|
||||||
matchMedia: globalThis.matchMedia,
|
matchMedia: globalThis.matchMedia,
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const currentUser = createCurrentUser({
|
const currentUser = createCurrentUser({
|
||||||
|
environment,
|
||||||
messageTypes,
|
messageTypes,
|
||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
|
||||||
});
|
});
|
||||||
const contracts = createContentContracts();
|
const contracts = createContentContracts();
|
||||||
|
const searchableSelect = createSearchableSelect({ environment });
|
||||||
const authSessionState = createAuthSessionState(contracts);
|
const authSessionState = createAuthSessionState(contracts);
|
||||||
const authSalesCommonState = createAuthSalesCommonState();
|
|
||||||
const opportunitiesQueryState = createOpportunitiesQueryState();
|
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 opportunitiesTableState = createOpportunitiesTableState();
|
||||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
const preferencesState = createPreferencesState(contracts);
|
const preferencesState = createPreferencesState(contracts);
|
||||||
|
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||||
const productsContentState = createProductsContentState();
|
const productsContentState = createProductsContentState();
|
||||||
const aiSummaryState = createAiSummaryState();
|
|
||||||
const consumptionPlansState = createConsumptionPlansState();
|
const consumptionPlansState = createConsumptionPlansState();
|
||||||
const consumptionTrendState = createConsumptionTrendState();
|
const consumptionTrendState = createConsumptionTrendState();
|
||||||
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
|
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
|
||||||
const debugState = createDebugState();
|
const debugState = createDebugState(environment);
|
||||||
const columns = createOpportunitiesColumns({
|
const columns = createOpportunitiesColumns({
|
||||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
|
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
|
||||||
@@ -54,6 +79,12 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const contentModal = createContentModal({
|
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(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
AUTH_STATUS: contracts.AUTH_STATUS,
|
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||||
@@ -84,10 +115,12 @@ function createContentApplication(overrides = {}) {
|
|||||||
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
||||||
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
||||||
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
|
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
|
||||||
|
closeOrganizationChart: () => orgChart.close(),
|
||||||
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||||
|
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||||
@@ -110,10 +143,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
||||||
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
||||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
|
||||||
reopenWorkloadActions: () => opportunityWorkloadActions.reopen(),
|
|
||||||
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
|
|
||||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
|
||||||
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
||||||
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||||
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
||||||
@@ -131,6 +160,58 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const svgPaths = createSvgPaths({ 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({
|
const contentStyles = createContentStyles({
|
||||||
STYLE_ID: contracts.STYLE_ID,
|
STYLE_ID: contracts.STYLE_ID,
|
||||||
redwoodStyles,
|
redwoodStyles,
|
||||||
@@ -145,11 +226,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
|
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
|
||||||
});
|
});
|
||||||
const authSalesCommon = createAuthSalesCommon({
|
|
||||||
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
|
||||||
authSalesCommonState,
|
|
||||||
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
|
|
||||||
});
|
|
||||||
const authTokenRelay = createAuthTokenRelay({
|
const authTokenRelay = createAuthTokenRelay({
|
||||||
authSessionState,
|
authSessionState,
|
||||||
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||||
@@ -169,12 +245,14 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const platformContentMessages = createPlatformContentMessages({
|
const platformContentMessages = createPlatformContentMessages({
|
||||||
|
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunitiesController = createOpportunitiesController({
|
const opportunitiesController = createOpportunitiesController({
|
||||||
ensureCurrentUser: () => currentUser.ensure(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||||
|
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||||
authSessionState,
|
authSessionState,
|
||||||
@@ -189,13 +267,13 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
||||||
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
||||||
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
|
|
||||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
||||||
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
||||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
||||||
});
|
});
|
||||||
const fusionOpportunities = createFusionOpportunities({
|
const fusionOpportunities = createFusionOpportunities({
|
||||||
|
resetDebugRequests: () => debugState.reset(),
|
||||||
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
||||||
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
||||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
||||||
@@ -216,19 +294,100 @@ function createContentApplication(overrides = {}) {
|
|||||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||||
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
||||||
});
|
});
|
||||||
|
const internalSrSession = createInternalSrSession({
|
||||||
|
environment,
|
||||||
|
query: async srId => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
const crmSrPopover = createCrmSrPopover({
|
const crmSrPopover = createCrmSrPopover({
|
||||||
|
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
requestDetails: srId => internalSrSession.request(srId),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
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({
|
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(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||||
closePopover: (...args) => crmSrPopover.close(...args),
|
closePopover: (...args) => crmSrPopover.close(...args),
|
||||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||||
});
|
});
|
||||||
|
const opportunityCalendar = createOpportunityCalendar({
|
||||||
|
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({
|
const opportunitiesTable = createOpportunitiesTable({
|
||||||
|
tableSorting,
|
||||||
|
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
||||||
|
renderActivityFilters: () => activityFilters.render(),
|
||||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||||
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
||||||
@@ -238,10 +397,12 @@ function createContentApplication(overrides = {}) {
|
|||||||
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
||||||
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
||||||
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
|
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
|
||||||
|
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
|
||||||
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||||
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
||||||
|
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
|
||||||
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
|
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
|
||||||
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
||||||
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
||||||
@@ -266,6 +427,14 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunitiesFilters = createOpportunitiesFilters({
|
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_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||||
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||||
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||||
@@ -329,8 +498,67 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
environment
|
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({
|
const productsContentJobs = createProductsContentJobs({
|
||||||
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
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),
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
@@ -342,7 +570,43 @@ function createContentApplication(overrides = {}) {
|
|||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
environment
|
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({
|
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(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
authSessionState,
|
authSessionState,
|
||||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
@@ -352,26 +616,43 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState, messageTypes, environment
|
productsContentState, messageTypes, environment
|
||||||
});
|
});
|
||||||
const productsDetails = createProductsDetails({
|
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,
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
|
||||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
|
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const aiSummaryContent = createAiSummaryContent({
|
const rampProjection = createRampProjection();
|
||||||
aiSummaryState,
|
const workloadForecastUpdate = createWorkloadForecastUpdate({
|
||||||
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
|
compare: product => rampProjection.compare(product),
|
||||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
getCurrentUserId: () => currentUser.getUserId(),
|
||||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...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
|
environment
|
||||||
});
|
});
|
||||||
const customerTableSearch = createCustomerTableSearch({ environment });
|
const customerTableSearch = createCustomerTableSearch({ environment });
|
||||||
const installBaseView = createInstallBaseView({
|
const installBaseView = createInstallBaseView({
|
||||||
|
tableSorting,
|
||||||
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
@@ -387,6 +668,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const serviceRequestsView = createServiceRequestsView({
|
const serviceRequestsView = createServiceRequestsView({
|
||||||
|
tableSorting,
|
||||||
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
@@ -432,6 +714,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const ratecardView = createRatecardView({
|
const ratecardView = createRatecardView({
|
||||||
|
tableSorting,
|
||||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
columns: ratecardModel.columns,
|
columns: ratecardModel.columns,
|
||||||
formatValue: (...args) => ratecardModel.formatValue(...args),
|
formatValue: (...args) => ratecardModel.formatValue(...args),
|
||||||
@@ -497,7 +780,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const debugContentLog = createDebugContentLog({
|
const debugContentLog = createDebugContentLog({
|
||||||
previewDebugBody: createDebugBodyPreview,
|
previewDebugBody: createDebugBodyPreview,
|
||||||
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
|
|
||||||
debugState,
|
debugState,
|
||||||
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
||||||
});
|
});
|
||||||
@@ -515,17 +797,19 @@ function createContentApplication(overrides = {}) {
|
|||||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
opportunityCalendar,
|
||||||
|
orgChart,
|
||||||
|
workloadActionForm,
|
||||||
|
activityFilters,
|
||||||
start, contracts,
|
start, contracts,
|
||||||
opportunityWorkloadActions,
|
opportunityWorkloadActions,
|
||||||
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||||
authSessionState,
|
authSessionState,
|
||||||
authSalesCommonState,
|
|
||||||
opportunitiesQueryState,
|
opportunitiesQueryState,
|
||||||
opportunitiesTableState,
|
opportunitiesTableState,
|
||||||
opportunitiesFilterState,
|
opportunitiesFilterState,
|
||||||
preferencesState,
|
preferencesState,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
aiSummaryState,
|
|
||||||
consumptionPlansState,
|
consumptionPlansState,
|
||||||
consumptionTrendState,
|
consumptionTrendState,
|
||||||
opportunitiesDashboardState,
|
opportunitiesDashboardState,
|
||||||
@@ -537,7 +821,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
contentStyles,
|
contentStyles,
|
||||||
authContentSession,
|
authContentSession,
|
||||||
currentUser,
|
currentUser,
|
||||||
authSalesCommon,
|
|
||||||
authTokenRelay,
|
authTokenRelay,
|
||||||
authXsrfContent,
|
authXsrfContent,
|
||||||
platformContentMessages,
|
platformContentMessages,
|
||||||
@@ -552,8 +835,16 @@ function createContentApplication(overrides = {}) {
|
|||||||
preferencesStorage,
|
preferencesStorage,
|
||||||
preferencesView,
|
preferencesView,
|
||||||
productsContentJobs,
|
productsContentJobs,
|
||||||
|
productWorkloads,
|
||||||
|
productActivities,
|
||||||
|
activityProgress,
|
||||||
|
srSessionPreparation,
|
||||||
|
refreshAll,
|
||||||
|
activityCache,
|
||||||
productsDetails,
|
productsDetails,
|
||||||
aiSummaryContent,
|
rampProjection,
|
||||||
|
workloadForecastUpdate,
|
||||||
|
rampProjectionView,
|
||||||
consumptionPlansAction,
|
consumptionPlansAction,
|
||||||
installBaseAction,
|
installBaseAction,
|
||||||
installBaseView,
|
installBaseView,
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ function createContentContracts() {
|
|||||||
"Next Quarter",
|
"Next Quarter",
|
||||||
"Previous Quarter",
|
"Previous Quarter",
|
||||||
"Current Fiscal Year",
|
"Current Fiscal Year",
|
||||||
"4 Rolling Quarters (CQ + 3)",
|
"4 Rolling Quarters (CQ + 3)"
|
||||||
"RENEWALS (Current + Past Due)"
|
|
||||||
];
|
];
|
||||||
const ALLOWED_PATHS = [
|
const ALLOWED_PATHS = [
|
||||||
"/hcmUI/faces/FuseWelcome",
|
"/hcmUI/faces/FuseWelcome",
|
||||||
|
|||||||
@@ -70,5 +70,82 @@ function createContentIcons({
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createThemeIcon, createSettingsIcon, createCloseIcon };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,16 @@ function createContentModal({
|
|||||||
closeAccountPlansModal,
|
closeAccountPlansModal,
|
||||||
closeInstallBaseModal,
|
closeInstallBaseModal,
|
||||||
closeServiceRequestsModal,
|
closeServiceRequestsModal,
|
||||||
|
closeOrganizationChart,
|
||||||
|
closeActionForm,
|
||||||
closeCustomerFilterPanel,
|
closeCustomerFilterPanel,
|
||||||
closeOwnerFilterPanel,
|
closeOwnerFilterPanel,
|
||||||
closePreferencesModal,
|
closePreferencesModal,
|
||||||
createSettingsIcon,
|
createSettingsIcon,
|
||||||
|
createRefreshAllButton,
|
||||||
|
createManageTimeButton,
|
||||||
|
createActivityFilterControls,
|
||||||
|
mountOpportunityViews,
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles,
|
ensureExtensionStyles,
|
||||||
getCustomerOptions,
|
getCustomerOptions,
|
||||||
@@ -51,12 +57,10 @@ function createContentModal({
|
|||||||
renderStageFilterButtons,
|
renderStageFilterButtons,
|
||||||
renderStatusFilterButtons,
|
renderStatusFilterButtons,
|
||||||
requestOpportunitiesForPeriod,
|
requestOpportunitiesForPeriod,
|
||||||
loadCrmServiceRequests,
|
closeCrmSrPopover,
|
||||||
resetCrmServiceRequests,
|
|
||||||
resetWorkloadActions,
|
|
||||||
reopenWorkloadActions,
|
|
||||||
requestTokenRelayOnce,
|
requestTokenRelayOnce,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
|
prepareSrSession,
|
||||||
resetCustomerAndOwnerFiltersForPeriod,
|
resetCustomerAndOwnerFiltersForPeriod,
|
||||||
resetOpportunitiesTable,
|
resetOpportunitiesTable,
|
||||||
restoreFilterPreferences,
|
restoreFilterPreferences,
|
||||||
@@ -77,28 +81,28 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
function openOpportunitiesModal() {
|
function openOpportunitiesModal() {
|
||||||
ensureExtensionStyles();
|
ensureExtensionStyles();
|
||||||
|
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
|
||||||
|
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
|
||||||
debugState.debugSecretsVisible = false;
|
debugState.debugSecretsVisible = false;
|
||||||
|
|
||||||
const existingModal = document.getElementById(MODAL_ID);
|
const existingModal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
if (existingModal) {
|
if (existingModal) {
|
||||||
existingModal.hidden = false;
|
existingModal.hidden = false;
|
||||||
|
prepareSrSession?.();
|
||||||
existingModal.querySelector("select").focus();
|
existingModal.querySelector("select").focus();
|
||||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
ensureEscapeKeyHandler();
|
ensureEscapeKeyHandler();
|
||||||
ensureCurrentUser().then(() => {
|
ensureCurrentUser().then(() => {
|
||||||
if (existingModal.hidden) return;
|
if (existingModal.hidden) return;
|
||||||
reopenWorkloadActions();
|
return requestTokenRelayOnce();
|
||||||
return requestTokenRelayOnce().then(token => {
|
|
||||||
if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.then(() => renderOpportunitiesTable()).catch(() => {});
|
.then(() => renderOpportunitiesTable()).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
debugState.requestLog = [];
|
overlay.setAttribute("lang", "en");
|
||||||
authSessionState.tokenRelayRequest = null;
|
authSessionState.tokenRelayRequest = null;
|
||||||
authSessionState.consumerTokenServiceRequest = null;
|
authSessionState.consumerTokenServiceRequest = null;
|
||||||
authSessionState.accessToken = "";
|
authSessionState.accessToken = "";
|
||||||
@@ -114,7 +118,6 @@ function createContentModal({
|
|||||||
overlay.setAttribute("role", "dialog");
|
overlay.setAttribute("role", "dialog");
|
||||||
overlay.setAttribute("aria-modal", "true");
|
overlay.setAttribute("aria-modal", "true");
|
||||||
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||||
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
|
|
||||||
|
|
||||||
const shell = document.createElement("section");
|
const shell = document.createElement("section");
|
||||||
shell.className = "opportunities-extension-shell";
|
shell.className = "opportunities-extension-shell";
|
||||||
@@ -126,10 +129,6 @@ function createContentModal({
|
|||||||
title.id = "opportunities-extension-title";
|
title.id = "opportunities-extension-title";
|
||||||
title.textContent = "Opportunities Extension";
|
title.textContent = "Opportunities Extension";
|
||||||
|
|
||||||
const subtitle = document.createElement("p");
|
|
||||||
subtitle.id = "opportunities-extension-subtitle";
|
|
||||||
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
|
|
||||||
|
|
||||||
const authBadge = document.createElement("span");
|
const authBadge = document.createElement("span");
|
||||||
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||||
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||||
@@ -149,15 +148,16 @@ function createContentModal({
|
|||||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||||
productsCacheTimestamp.setAttribute("role", "status");
|
productsCacheTimestamp.setAttribute("role", "status");
|
||||||
productsCacheTimestamp.hidden = true;
|
productsCacheTimestamp.hidden = true;
|
||||||
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
|
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||||
|
|
||||||
const titleBlock = document.createElement("div");
|
const titleBlock = document.createElement("div");
|
||||||
|
titleBlock.className = "opportunities-extension-title-row";
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
|
|
||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
closeButton.type = "button";
|
closeButton.type = "button";
|
||||||
closeButton.className = "opportunities-extension-icon-button";
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
closeButton.setAttribute("aria-label", "Fechar");
|
closeButton.setAttribute("aria-label", "Close");
|
||||||
closeButton.textContent = "X";
|
closeButton.textContent = "X";
|
||||||
closeButton.addEventListener("click", closeOpportunitiesModal);
|
closeButton.addEventListener("click", closeOpportunitiesModal);
|
||||||
|
|
||||||
@@ -173,14 +173,14 @@ function createContentModal({
|
|||||||
const preferencesButton = document.createElement("button");
|
const preferencesButton = document.createElement("button");
|
||||||
preferencesButton.type = "button";
|
preferencesButton.type = "button";
|
||||||
preferencesButton.className = "opportunities-extension-icon-button";
|
preferencesButton.className = "opportunities-extension-icon-button";
|
||||||
preferencesButton.setAttribute("aria-label", "Abrir preferências");
|
preferencesButton.setAttribute("aria-label", "Open preferences");
|
||||||
preferencesButton.title = "Preferences";
|
preferencesButton.title = "Preferences";
|
||||||
preferencesButton.append(createSettingsIcon());
|
preferencesButton.append(createSettingsIcon());
|
||||||
preferencesButton.addEventListener("click", openPreferencesModal);
|
preferencesButton.addEventListener("click", openPreferencesModal);
|
||||||
|
|
||||||
const headerActions = document.createElement("div");
|
const headerActions = document.createElement("div");
|
||||||
headerActions.className = "opportunities-extension-header-actions";
|
headerActions.className = "opportunities-extension-header-actions";
|
||||||
headerActions.append(themeButton, preferencesButton, closeButton);
|
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
|
||||||
|
|
||||||
header.append(titleBlock, headerActions);
|
header.append(titleBlock, headerActions);
|
||||||
|
|
||||||
@@ -389,7 +389,8 @@ function createContentModal({
|
|||||||
|
|
||||||
statusFilter.append(statusFilterLabel, statusFilters);
|
statusFilter.append(statusFilterLabel, statusFilters);
|
||||||
form.addEventListener("submit", (event) => event.preventDefault());
|
form.addEventListener("submit", (event) => event.preventDefault());
|
||||||
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
|
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
|
||||||
|
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
|
||||||
body.append(form);
|
body.append(form);
|
||||||
|
|
||||||
const stageDashboard = document.createElement("section");
|
const stageDashboard = document.createElement("section");
|
||||||
@@ -404,10 +405,11 @@ function createContentModal({
|
|||||||
const opportunitiesTable = document.createElement("table");
|
const opportunitiesTable = document.createElement("table");
|
||||||
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
|
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
|
||||||
opportunitiesTable.className = "opportunities-extension-table";
|
opportunitiesTable.className = "opportunities-extension-table";
|
||||||
|
opportunitiesTable.setAttribute("data-table-layout", "standard");
|
||||||
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
||||||
tableSurface.append(opportunitiesTable);
|
tableSurface.append(opportunitiesTable);
|
||||||
|
|
||||||
body.append(tableSurface);
|
mountOpportunityViews(body, tableSurface);
|
||||||
|
|
||||||
const debugPanel = document.createElement("aside");
|
const debugPanel = document.createElement("aside");
|
||||||
debugPanel.id = DEBUG_PANEL_ID;
|
debugPanel.id = DEBUG_PANEL_ID;
|
||||||
@@ -426,6 +428,7 @@ function createContentModal({
|
|||||||
renderOwnerFilter();
|
renderOwnerFilter();
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestOpportunitiesForPeriod(select.value);
|
requestOpportunitiesForPeriod(select.value);
|
||||||
|
prepareSrSession?.();
|
||||||
select.focus();
|
select.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,9 +471,9 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeOpportunitiesModal() {
|
function closeOpportunitiesModal() {
|
||||||
|
if (closeOrganizationChart()) return;
|
||||||
resetAuthCompletion();
|
resetAuthCompletion();
|
||||||
resetWorkloadActions(false);
|
closeCrmSrPopover();
|
||||||
resetCrmServiceRequests();
|
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
if (modal) {
|
if (modal) {
|
||||||
@@ -478,6 +481,7 @@ function createContentModal({
|
|||||||
closeAccountPlansModal();
|
closeAccountPlansModal();
|
||||||
closeInstallBaseModal();
|
closeInstallBaseModal();
|
||||||
closeServiceRequestsModal();
|
closeServiceRequestsModal();
|
||||||
|
closeActionForm(false);
|
||||||
modal.hidden = true;
|
modal.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +510,11 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
if (closeOrganizationChart()) { event.preventDefault(); return; }
|
||||||
|
if (closeActionForm()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (closeServiceRequestsModal()) {
|
if (closeServiceRequestsModal()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -918,51 +918,6 @@
|
|||||||
color: #312d2a;
|
color: #312d2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-ai-summary {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #dedbd7;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #312d2a;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: left;
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opportunities-extension-ai-summary strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opportunities-extension-ai-summary p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font: inherit;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opportunities-extension-ai-summary-error {
|
|
||||||
color: #b42318;
|
|
||||||
border-color: #d9928b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
|
|
||||||
background: #292827;
|
|
||||||
color: #f6f4f2;
|
|
||||||
border-color: #5f5a55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
|
|
||||||
color: #ffb4ab;
|
|
||||||
border-color: #a45c55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opportunities-extension-products-detail-heading strong {
|
.opportunities-extension-products-detail-heading strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/content/styles/tables.css
Normal file
127
src/content/styles/tables.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* 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: 38px; vertical-align: top; }
|
||||||
|
.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; }
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
function createAiSummaryContent({
|
|
||||||
aiSummaryState,
|
|
||||||
invalidateSalesCommonToken,
|
|
||||||
logRequestFailure,
|
|
||||||
logRequestStart,
|
|
||||||
logRequestSuccess,
|
|
||||||
readResponseBodyForDebug,
|
|
||||||
requestTokenRelaySalesCommon,
|
|
||||||
environment
|
|
||||||
}) {
|
|
||||||
const { document, fetch } = environment;
|
|
||||||
function requestGenerateAiSummary(optyNumber) {
|
|
||||||
const key = String(optyNumber);
|
|
||||||
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
|
|
||||||
if (existing && existing.status === "loading") {
|
|
||||||
return existing.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = { status: "loading", summaryText: "", error: "", promise: null };
|
|
||||||
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
|
|
||||||
state.promise = (async () => {
|
|
||||||
let entry;
|
|
||||||
try {
|
|
||||||
let token = await requestTokenRelaySalesCommon();
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Authentication is required to generate the summary.");
|
|
||||||
}
|
|
||||||
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
|
|
||||||
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
|
|
||||||
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
|
|
||||||
let response;
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
||||||
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
||||||
entry = logRequestStart({
|
|
||||||
name: "requestGenerateAiSummary", method: "POST", url, headers,
|
|
||||||
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
|
|
||||||
});
|
|
||||||
response = await fetch(url, {
|
|
||||||
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
|
||||||
if (response.status !== 401 && response.status !== 403) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// A late failure for an old token must not discard a newly refreshed token.
|
|
||||||
invalidateSalesCommonToken(token);
|
|
||||||
if (attempt === 0) {
|
|
||||||
token = await requestTokenRelaySalesCommon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
|
|
||||||
}
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
|
|
||||||
}
|
|
||||||
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
|
|
||||||
throw new Error("requestGenerateAiSummary returned an empty summary.");
|
|
||||||
}
|
|
||||||
state.summaryText = data.summaryText;
|
|
||||||
state.status = "ready";
|
|
||||||
} catch (error) {
|
|
||||||
state.status = "error";
|
|
||||||
state.error = error.message || "Unable to generate the AI summary.";
|
|
||||||
if (entry) {
|
|
||||||
logRequestFailure(entry, state.error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Update only visible summaries, without reopening rows or rebuilding the table.
|
|
||||||
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
|
|
||||||
if (alert.dataset.optyNumber === key) {
|
|
||||||
alert.replaceWith(createAiSummaryAlert(key));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return state.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAiSummaryAlert(optyNumber) {
|
|
||||||
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
|
|
||||||
const alert = document.createElement("div");
|
|
||||||
alert.className = "opportunities-extension-ai-summary";
|
|
||||||
alert.dataset.optyNumber = optyNumber;
|
|
||||||
alert.setAttribute("role", "status");
|
|
||||||
alert.setAttribute("aria-live", "polite");
|
|
||||||
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
|
|
||||||
const title = document.createElement("strong");
|
|
||||||
title.textContent = "Summary generated by AI";
|
|
||||||
const description = document.createElement("p");
|
|
||||||
description.textContent = !state || state.status === "loading"
|
|
||||||
? "Generating summary..."
|
|
||||||
: state.status === "error" ? state.error : state.summaryText;
|
|
||||||
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
|
|
||||||
alert.append(title, description);
|
|
||||||
return alert;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { requestGenerateAiSummary, createAiSummaryAlert };
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
function createAiSummaryState() {
|
|
||||||
return {
|
|
||||||
aiSummariesByOptyNumber: new Map()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
current.promise = (async () => {
|
current.promise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
||||||
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
|
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);
|
const items = await pending.get(subPlanNum);
|
||||||
if (!section.isConnected || states.get(section) !== current) return;
|
if (!section.isConnected || states.get(section) !== current) return;
|
||||||
current.status = "ready";
|
current.status = "ready";
|
||||||
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
})();
|
})();
|
||||||
return current.promise;
|
return current.promise;
|
||||||
}
|
}
|
||||||
return { createPlanDetailSection, loadPlanDetailForPanel };
|
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
const currentRequests = consumptionPlansState.accountPlansRequestState;
|
||||||
|
currentRequests.set(optyNumber, { status: "loading" });
|
||||||
openAccountPlansModal(opportunity, [], "loading");
|
openAccountPlansModal(opportunity, [], "loading");
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestConsumerTokenServiceOnce()
|
requestConsumerTokenServiceOnce()
|
||||||
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
|
|||||||
authorizations
|
authorizations
|
||||||
}))
|
}))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
appendBackgroundRequestLog(response && response.debugRequests);
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
if (!response || !response.ok) {
|
||||||
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
|
|||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
function createDebugContentLog({
|
function createDebugContentLog({
|
||||||
previewDebugBody,
|
previewDebugBody,
|
||||||
DEBUG_BODY_LIMIT,
|
|
||||||
debugState,
|
debugState,
|
||||||
renderDebugPanel
|
renderDebugPanel
|
||||||
}) {
|
}) {
|
||||||
@@ -10,14 +9,14 @@ function createDebugContentLog({
|
|||||||
name: details.name,
|
name: details.name,
|
||||||
method: details.method,
|
method: details.method,
|
||||||
url: details.url,
|
url: details.url,
|
||||||
headers: details.headers,
|
headers: details.headers || {},
|
||||||
status: "pending",
|
status: "pending",
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
durationMs: null,
|
durationMs: null,
|
||||||
httpStatus: null,
|
httpStatus: null,
|
||||||
statusText: "",
|
statusText: "",
|
||||||
requestHeaders: details.headers,
|
requestHeaders: details.headers || {},
|
||||||
requestBody: details.body || "",
|
requestBody: details.body || "",
|
||||||
metadata: details.metadata || {},
|
metadata: details.metadata || {},
|
||||||
responseHeaders: {},
|
responseHeaders: {},
|
||||||
@@ -27,11 +26,13 @@ function createDebugContentLog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
debugState.requestLog.push(entry);
|
debugState.requestLog.push(entry);
|
||||||
|
debugState.persist(entry);
|
||||||
renderDebugPanel();
|
renderDebugPanel();
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logRequestSuccess(entry, response, responseBody) {
|
function logRequestSuccess(entry, response, responseBody) {
|
||||||
|
if (!debugState.requestLog.includes(entry)) return;
|
||||||
entry.status = response.ok ? "success" : "failed";
|
entry.status = response.ok ? "success" : "failed";
|
||||||
entry.completedAt = new Date();
|
entry.completedAt = new Date();
|
||||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||||
@@ -40,14 +41,17 @@ function createDebugContentLog({
|
|||||||
entry.responseHeaders = headersToObject(response.headers);
|
entry.responseHeaders = headersToObject(response.headers);
|
||||||
entry.responseBody = responseBody.value;
|
entry.responseBody = responseBody.value;
|
||||||
entry.responseBodyTruncated = responseBody.truncated;
|
entry.responseBodyTruncated = responseBody.truncated;
|
||||||
|
debugState.persist(entry);
|
||||||
renderDebugPanel();
|
renderDebugPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function logRequestFailure(entry, errorMessage) {
|
function logRequestFailure(entry, errorMessage) {
|
||||||
|
if (!debugState.requestLog.includes(entry)) return;
|
||||||
entry.status = "failed";
|
entry.status = "failed";
|
||||||
entry.completedAt = new Date();
|
entry.completedAt = new Date();
|
||||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||||
entry.error = errorMessage;
|
entry.error = errorMessage;
|
||||||
|
debugState.persist(entry);
|
||||||
renderDebugPanel();
|
renderDebugPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +86,7 @@ function createDebugContentLog({
|
|||||||
};
|
};
|
||||||
if (existing) Object.assign(existing, normalized);
|
if (existing) Object.assign(existing, normalized);
|
||||||
else debugState.requestLog.push(normalized);
|
else debugState.requestLog.push(normalized);
|
||||||
|
debugState.persist(existing || normalized);
|
||||||
});
|
});
|
||||||
renderDebugPanel();
|
renderDebugPanel();
|
||||||
}
|
}
|
||||||
@@ -99,7 +104,7 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDebugBody(text) {
|
function createDebugBody(text) {
|
||||||
return previewDebugBody(text, DEBUG_BODY_LIMIT);
|
return previewDebugBody(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function headersToObject(headers) {
|
function headersToObject(headers) {
|
||||||
@@ -113,7 +118,7 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatHeaderBlock(headers) {
|
function formatHeaderBlock(headers) {
|
||||||
const entries = Object.entries(headers);
|
const entries = Object.entries(headers || {});
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return "(none)";
|
return "(none)";
|
||||||
@@ -126,7 +131,7 @@ function createDebugContentLog({
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDebugBody(body) {
|
function formatDebugBody(body, requestName = "") {
|
||||||
const text = body || "(none)";
|
const text = body || "(none)";
|
||||||
try {
|
try {
|
||||||
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
|
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
|
||||||
@@ -137,8 +142,14 @@ function createDebugContentLog({
|
|||||||
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
|
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
|
||||||
try { value = JSON.parse(value); } catch { break; }
|
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);
|
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +173,7 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSensitiveDebugKey(key) {
|
function isSensitiveDebugKey(key) {
|
||||||
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
|
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$|x-appbuilder-client-id/i.test(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHiddenDebugValue(value) {
|
function formatHiddenDebugValue(value) {
|
||||||
@@ -174,14 +185,14 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMetadataBlock(metadata) {
|
function formatMetadataBlock(metadata) {
|
||||||
const entries = Object.entries(metadata);
|
const entries = Object.entries(metadata || {});
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return "(none)";
|
return "(none)";
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
.map(([key, value]) => `${key}: ${value || "(none)"}`)
|
.map(([key, value]) => `${key}: ${value ?? "(none)"}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,24 @@ function createDebugContentView({
|
|||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
const categories = [
|
const categories = [
|
||||||
["all", "All"], ["authentication", "Authentication"],
|
["all", "All"], ["authentication", "Authentication"],
|
||||||
["opportunities", "Opportunities"], ["products", "Products"],
|
["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
|
||||||
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
|
["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||||
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
|
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
|
||||||
];
|
];
|
||||||
let selectedCategory = "all";
|
let selectedCategory = "all";
|
||||||
const expandedIds = new Set();
|
const expandedIds = new Set();
|
||||||
let returnFocus = null;
|
let returnFocus = null;
|
||||||
|
let generation = -1;
|
||||||
|
|
||||||
function getRequestCategory(entry) {
|
function getRequestCategory(entry) {
|
||||||
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||||
if (entry.name === "requestOpportunities") return "opportunities";
|
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 (entry.name === "requestOpptyProducts") return "products";
|
||||||
if (entry.name === "requestWlActions") return "actions";
|
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
|
||||||
if (entry.name === "requestInstallBase") return "installBase";
|
if (entry.name === "requestInstallBase") return "installBase";
|
||||||
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
|
if (["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";
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,11 @@ function createDebugContentView({
|
|||||||
closeDebugPanel();
|
closeDebugPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debugState.reload();
|
||||||
returnFocus = document.activeElement;
|
returnFocus = document.activeElement;
|
||||||
|
// Escape nested stacking contexts and inert containers created by child modals.
|
||||||
|
modal.append(panel);
|
||||||
|
panel.inert = false;
|
||||||
panel.hidden = false;
|
panel.hidden = false;
|
||||||
renderDebugPanel();
|
renderDebugPanel();
|
||||||
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
|
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
|
||||||
@@ -62,6 +68,8 @@ function createDebugContentView({
|
|||||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.hidden = true;
|
panel.hidden = true;
|
||||||
|
panel.textContent = "";
|
||||||
|
panel.onkeydown = null;
|
||||||
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
||||||
returnFocus = null;
|
returnFocus = null;
|
||||||
}
|
}
|
||||||
@@ -73,6 +81,7 @@ function createDebugContentView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generation !== debugState.generation) { expandedIds.clear(); selectedCategory = "all"; generation = debugState.generation; }
|
||||||
for (const item of panel.querySelectorAll("details[data-request-id]")) {
|
for (const item of panel.querySelectorAll("details[data-request-id]")) {
|
||||||
const id = item.getAttribute("data-request-id");
|
const id = item.getAttribute("data-request-id");
|
||||||
if (item.open) expandedIds.add(id);
|
if (item.open) expandedIds.add(id);
|
||||||
@@ -97,7 +106,8 @@ function createDebugContentView({
|
|||||||
title.textContent = "Debug Requests";
|
title.textContent = "Debug Requests";
|
||||||
|
|
||||||
const summary = document.createElement("p");
|
const summary = document.createElement("p");
|
||||||
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
|
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;
|
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
|
||||||
if (running) summary.textContent += ` ${running} running.`;
|
if (running) summary.textContent += ` ${running} running.`;
|
||||||
heading.append(title, summary);
|
heading.append(title, summary);
|
||||||
@@ -217,15 +227,25 @@ function createDebugContentView({
|
|||||||
const startedTime = document.createElement("time");
|
const startedTime = document.createElement("time");
|
||||||
startedTime.className = "opportunities-extension-debug-time";
|
startedTime.className = "opportunities-extension-debug-time";
|
||||||
startedTime.textContent = formatDebugTime(entry.startedAt);
|
startedTime.textContent = formatDebugTime(entry.startedAt);
|
||||||
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
|
startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
|
||||||
const method = document.createElement("span");
|
const method = document.createElement("span");
|
||||||
method.className = "opportunities-extension-debug-method";
|
method.className = "opportunities-extension-debug-method";
|
||||||
method.textContent = entry.method || "GET";
|
method.textContent = entry.method || "GET";
|
||||||
method.setAttribute("data-method", method.textContent.toUpperCase());
|
method.setAttribute("data-method", method.textContent.toUpperCase());
|
||||||
summaryMeta.append(startedTime, method, httpStatus, status);
|
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);
|
heading.append(name, summaryMeta);
|
||||||
|
|
||||||
|
item.append(heading);
|
||||||
|
const renderDetails = () => {
|
||||||
|
if (!item.open || item.children.length > 1) return;
|
||||||
const lines = [
|
const lines = [
|
||||||
`${entry.method} ${entry.url}`,
|
`${entry.method} ${entry.url}`,
|
||||||
`Started: ${formatDebugTime(entry.startedAt)}`,
|
`Started: ${formatDebugTime(entry.startedAt)}`,
|
||||||
@@ -242,9 +262,19 @@ function createDebugContentView({
|
|||||||
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
||||||
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
||||||
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
||||||
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
|
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
|
||||||
|
|
||||||
item.append(heading, details);
|
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);
|
list.append(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +371,7 @@ function createDebugContentView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDebugTime(date) {
|
function formatDebugTime(date) {
|
||||||
return date.toLocaleTimeString(undefined, {
|
return date.toLocaleTimeString("en-US", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
|
|||||||
@@ -1,6 +1,87 @@
|
|||||||
function createDebugState() {
|
function createDebugState(environment = {}) {
|
||||||
return {
|
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: [],
|
requestLog: [],
|
||||||
debugSecretsVisible: false
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
--debug-literal: #73429b;
|
--debug-literal: #73429b;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 50;
|
z-index: 2147483647;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -214,3 +214,10 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
||||||
outline-color: #8cc8d3;
|
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; }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
if (!pending.has(partyNumber)) {
|
||||||
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
|
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||||
pending.set(partyNumber, request);
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createInstallBaseButton };
|
return { createInstallBaseButton, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px;
|
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 col { width: 7%; }
|
||||||
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
|
|
||||||
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
|
.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-productName { width: 13%; }
|
||||||
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
|
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
|
||||||
@@ -78,3 +77,5 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@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-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; }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
|
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
const columns = [
|
const columns = [
|
||||||
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
|
["productGroupName", "Product Group"], ["productName", "Product"],
|
||||||
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
|
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
|
||||||
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
|
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
|
||||||
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
|
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
|
||||||
@@ -33,8 +33,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
}
|
}
|
||||||
return String(value).trim();
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
function createTable(items, loading = false) {
|
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");
|
const table = element("table", "opportunities-extension-install-base-table");
|
||||||
|
table.setAttribute("data-table-layout", "standard");
|
||||||
table.setAttribute("aria-label", "Installed base items");
|
table.setAttribute("aria-label", "Installed base items");
|
||||||
const head = element("thead");
|
const head = element("thead");
|
||||||
const header = element("tr");
|
const header = element("tr");
|
||||||
@@ -43,13 +45,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
const col = element("col");
|
const col = element("col");
|
||||||
col.className = `install-base-col-${key}`;
|
col.className = `install-base-col-${key}`;
|
||||||
group.append(col);
|
group.append(col);
|
||||||
const th = element("th", "", label);
|
const th = element("th");
|
||||||
th.scope = "col";
|
th.scope = "col";
|
||||||
|
sorting.header(th, key, label, onSort, loading);
|
||||||
header.append(th);
|
header.append(th);
|
||||||
});
|
});
|
||||||
head.append(header);
|
head.append(header);
|
||||||
const body = element("tbody");
|
const body = element("tbody");
|
||||||
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
|
(loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
|
||||||
const row = element("tr");
|
const row = element("tr");
|
||||||
columns.forEach(([key, , type]) => {
|
columns.forEach(([key, , type]) => {
|
||||||
const cell = element("td");
|
const cell = element("td");
|
||||||
@@ -103,6 +106,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
|
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
|
||||||
status.setAttribute("role", "status");
|
status.setAttribute("role", "status");
|
||||||
let loadedItems = [], loaded = false, failure = "";
|
let loadedItems = [], loaded = false, failure = "";
|
||||||
|
const sorting = tableSorting.create("installed-base", sortColumns);
|
||||||
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
|
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
|
||||||
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
|
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
|
||||||
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
||||||
@@ -115,7 +119,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
modal.addEventListener("keydown", event => {
|
modal.addEventListener("keydown", event => {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const controls = [close, search.input, content];
|
const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
|
||||||
const index = controls.indexOf(document.activeElement);
|
const index = controls.indexOf(document.activeElement);
|
||||||
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
|
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
: "No installed base items found for this customer.");
|
: "No installed base items found for this customer.");
|
||||||
status.setAttribute("role", failure ? "alert" : "status");
|
status.setAttribute("role", failure ? "alert" : "status");
|
||||||
content.append(status);
|
content.append(status);
|
||||||
if (!failure && items.length) content.append(createTable(items));
|
if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
|
||||||
|
renderResults();
|
||||||
|
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
|
||||||
|
}));
|
||||||
content.scrollTop = 0;
|
content.scrollTop = 0;
|
||||||
}
|
}
|
||||||
return { render(items, error = "") {
|
return { render(items, error = "") {
|
||||||
|
|||||||
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; }
|
||||||
348
src/features/opportunities/calendar.js
Normal file
348
src/features/opportunities/calendar.js
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||||
|
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||||
|
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||||
|
productTypeSelection, onProductTypeChange }) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
|
|||||||
return [
|
return [
|
||||||
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
|
{ 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: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
|
||||||
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
|
{ 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: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
|
||||||
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
|
{ 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: "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: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
|
||||||
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
{ 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: "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) }
|
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ function createOpportunitiesController({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress,
|
renderProductsProgress,
|
||||||
requestOpportunitiesPage,
|
requestOpportunitiesPage,
|
||||||
loadCrmServiceRequests,
|
|
||||||
resetCrmServiceRequests,
|
resetCrmServiceRequests,
|
||||||
resetWorkloadActions,
|
resetWorkloadActions,
|
||||||
|
resetWorkloads,
|
||||||
requestOpptyProductsInBackground,
|
requestOpptyProductsInBackground,
|
||||||
requestTokenRelayOnce,
|
requestTokenRelayOnce,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
@@ -23,7 +23,10 @@ function createOpportunitiesController({
|
|||||||
}) {
|
}) {
|
||||||
async function requestOpportunitiesForPeriod(period) {
|
async function requestOpportunitiesForPeriod(period) {
|
||||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||||
|
opportunitiesQueryState.dateRange = dateRange;
|
||||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||||
|
resetWorkloadActions();
|
||||||
|
resetWorkloads();
|
||||||
await ensureCurrentUser();
|
await ensureCurrentUser();
|
||||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||||
|
|
||||||
@@ -70,7 +73,6 @@ function createOpportunitiesController({
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
cacheHits: 0
|
cacheHits: 0
|
||||||
};
|
};
|
||||||
productsContentState.productsLastCachedAt = 0;
|
|
||||||
consumptionPlansState.accountPlansRequestState = new Map();
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
consumptionTrendState.consumptionTrendCache = new Map();
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
@@ -84,7 +86,6 @@ function createOpportunitiesController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadCrmServiceRequests(token, requestVersion);
|
|
||||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||||
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ function createOpportunitiesController({
|
|||||||
|
|
||||||
function resetOpportunitiesTable() {
|
function resetOpportunitiesTable() {
|
||||||
resetWorkloadActions();
|
resetWorkloadActions();
|
||||||
|
resetWorkloads();
|
||||||
resetCrmServiceRequests();
|
resetCrmServiceRequests();
|
||||||
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||||
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
|
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
|
||||||
let status = "idle";
|
let status = "idle";
|
||||||
let byOpportunity = new Map();
|
let byOpportunity = new Map();
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
|
let loadedItems = [];
|
||||||
function load(token, version) {
|
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 (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||||
if (!pending) pending = run(token, version, generation);
|
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;
|
return pending;
|
||||||
}
|
}
|
||||||
async function run(token, version, currentGeneration) {
|
async function run(token, version, currentGeneration, cacheOptions) {
|
||||||
const isActive = () => currentGeneration === generation;
|
const isActive = () => currentGeneration === generation;
|
||||||
status = "loading";
|
status = "loading";
|
||||||
try {
|
try {
|
||||||
const items = await requestCrmServiceRequest(token, version, isActive);
|
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||||
if (!items || !isActive()) return;
|
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();
|
const grouped = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||||
@@ -24,24 +42,45 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
grouped.get(key).push(item);
|
grouped.get(key).push(item);
|
||||||
}
|
}
|
||||||
byOpportunity = grouped;
|
byOpportunity = grouped;
|
||||||
|
loadedItems = items;
|
||||||
status = "ready";
|
status = "ready";
|
||||||
|
if (!cached) reportProgress("complete", items.length, items.length);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (isActive()) status = "error";
|
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) {
|
function createBadge(optyNumber) {
|
||||||
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
||||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||||
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge";
|
badge.className = "opportunities-extension-crm-sr-badge";
|
||||||
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
||||||
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
|
const summary = getSummary(optyNumber);
|
||||||
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
|
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 => [
|
const descriptions = items.map(item => [
|
||||||
`Title: ${display(item.Title)}`,
|
`Title: ${display(item.Title)}`,
|
||||||
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
||||||
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
||||||
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
|
`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)}`,
|
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
@@ -49,10 +88,11 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
: !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");
|
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||||
badge.setAttribute("aria-label", description);
|
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") bindPopover(badge, items, descriptions);
|
||||||
else badge.title = description;
|
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
return { reset, load, createBadge };
|
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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) {
|
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, formatDate, openInternalSrForm, environment }) {
|
||||||
const { document, window, setTimeout, clearTimeout } = environment;
|
const { document, window, setTimeout, clearTimeout } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -74,13 +74,14 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
dismiss.append(createCloseIcon());
|
dismiss.append(createCloseIcon());
|
||||||
dismiss.addEventListener("click", () => close(true));
|
dismiss.addEventListener("click", () => close(true));
|
||||||
panel.append(dismiss);
|
panel.append(dismiss);
|
||||||
|
const detailLoads = [];
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
const heading = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
heading.className = "opportunities-extension-sr-number";
|
heading.className = "opportunities-extension-sr-number";
|
||||||
const number = item.SrNumber;
|
const number = item.SrNumber;
|
||||||
const link = document.createElement(number ? "a" : "span");
|
const link = document.createElement(number ? "a" : "span");
|
||||||
link.textContent = number ? String(number) : "N/D";
|
link.textContent = number ? String(number) : "N/A";
|
||||||
if (number) {
|
if (number) {
|
||||||
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
|
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
|
||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
@@ -90,10 +91,79 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
label.textContent = "SR Number:";
|
label.textContent = "SR Number:";
|
||||||
heading.append(label, link);
|
heading.append(label, link);
|
||||||
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
|
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");
|
const details = document.createElement("div");
|
||||||
details.className = "opportunities-extension-sr-details";
|
details.className = "opportunities-extension-sr-details";
|
||||||
details.textContent = descriptions[index];
|
details.textContent = descriptions[index];
|
||||||
section.append(heading, details);
|
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);
|
||||||
|
detailLoads.push(async () => {
|
||||||
|
try {
|
||||||
|
const records = await requestDetails(item.SrId);
|
||||||
|
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.";
|
||||||
|
} finally {
|
||||||
|
if (active === entry && entry.panel === panel) {
|
||||||
|
children.setAttribute("aria-busy", "false");
|
||||||
|
entry.position();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
panel.append(section);
|
panel.append(section);
|
||||||
});
|
});
|
||||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||||
@@ -101,6 +171,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
panel.addEventListener("focusout", focusOut);
|
panel.addEventListener("focusout", focusOut);
|
||||||
host.append(panel);
|
host.append(panel);
|
||||||
active = entry;
|
active = entry;
|
||||||
|
detailLoads.forEach(load => load());
|
||||||
button.setAttribute("aria-expanded", "true");
|
button.setAttribute("aria-expanded", "true");
|
||||||
entry.position();
|
entry.position();
|
||||||
document.addEventListener("pointerdown", entry.outside, true);
|
document.addEventListener("pointerdown", entry.outside, true);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document, requestAnimationFrame, performance } = environment;
|
const { document, requestAnimationFrame, performance } = environment;
|
||||||
function renderStageDashboard(items) {
|
function renderStageDashboard(items, totalCount = items.length) {
|
||||||
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
|
|||||||
totalCard.setAttribute("data-stage", "TOTAL");
|
totalCard.setAttribute("data-stage", "TOTAL");
|
||||||
totalLabel.textContent = "Total Opportunities";
|
totalLabel.textContent = "Total Opportunities";
|
||||||
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
||||||
totalValue.setAttribute("aria-label", "Total opportunities");
|
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
|
||||||
animateStageDashboardValue(
|
animateStageDashboardValue(
|
||||||
totalValue,
|
totalValue,
|
||||||
"TOTAL",
|
"TOTAL",
|
||||||
items.length,
|
items.length,
|
||||||
(amount) => Math.round(amount).toLocaleString()
|
(amount) => `${Math.round(amount).toLocaleString("en-US")}/${totalCount.toLocaleString("en-US")}`
|
||||||
);
|
);
|
||||||
totalCard.append(totalLabel, totalValue);
|
totalCard.append(totalLabel, totalValue);
|
||||||
dashboard.append(totalCard);
|
dashboard.append(totalCard);
|
||||||
@@ -81,7 +81,7 @@ function createOpportunitiesDashboard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (amount) => amount.toLocaleString(undefined, {
|
return (amount) => amount.toLocaleString("en-US", {
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
||||||
return {
|
return {
|
||||||
|
selectedProductTypes: new Set(["Booking", "Workload"]),
|
||||||
selectedStages: new Set(STAGE_OPTIONS),
|
selectedStages: new Set(STAGE_OPTIONS),
|
||||||
selectedStatuses: new Set(STATUS_OPTIONS),
|
selectedStatuses: new Set(STATUS_OPTIONS),
|
||||||
selectedCustomerKeys: new Set(),
|
selectedCustomerKeys: new Set(),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function createOpportunitiesFilters({
|
|||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
saveFilterPreferences,
|
saveFilterPreferences,
|
||||||
stringifySearchValue,
|
stringifySearchValue,
|
||||||
|
matchesActivities,
|
||||||
|
matchesProductType = () => true,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
@@ -335,12 +337,17 @@ function createOpportunitiesFilters({
|
|||||||
|
|
||||||
function getSortedOpportunities() {
|
function getSortedOpportunities() {
|
||||||
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
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) => {
|
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
||||||
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
|
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
|
||||||
&& matchesSelectedStatus(item.StatusCode)
|
&& matchesSelectedStatus(item.StatusCode)
|
||||||
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
|
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||||
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
|
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||||
&& matchesTableSearch(item, normalizedSearch);
|
&& matchesTableSearch(item, normalizedSearch)
|
||||||
|
&& matchesActivities(item)
|
||||||
|
&& matchesProductType(item);
|
||||||
});
|
});
|
||||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||||
|
|
||||||
@@ -391,6 +398,7 @@ function createOpportunitiesFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesSelectedStatus(statusCode) {
|
function matchesSelectedStatus(statusCode) {
|
||||||
|
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
|
||||||
if (statusCode === "WON_PENDING") {
|
if (statusCode === "WON_PENDING") {
|
||||||
return opportunitiesFilterState.selectedStatuses.has("WON");
|
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
function createOpportunitiesFiscalPeriods() {
|
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) {
|
function getFiscalPeriodRange(period, currentDate) {
|
||||||
const currentYear = currentDate.getFullYear();
|
const currentYear = currentDate.getFullYear();
|
||||||
const currentMonth = currentDate.getMonth();
|
const currentMonth = currentDate.getMonth();
|
||||||
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
|
const fiscalStartYear = currentMonth >= FISCAL_START_MONTH ? currentYear : currentYear - 1;
|
||||||
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
|
const currentQuarterIndex = Math.floor(((currentMonth - FISCAL_START_MONTH + 12) % 12) / 3);
|
||||||
|
|
||||||
if (period === "Current Fiscal Year") {
|
if (period === "Current Fiscal Year") {
|
||||||
const isBeforeFiscalYearStart = currentMonth < 5;
|
return createDateRange(fiscalStartYear, FISCAL_START_MONTH, fiscalStartYear + 1, FISCAL_START_MONTH);
|
||||||
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
|
|
||||||
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
|
|
||||||
return createDateRange(startYear, 5, endYear, 4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let quarterOffset;
|
let quarterOffset;
|
||||||
@@ -28,19 +28,14 @@ function createOpportunitiesFiscalPeriods() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
|
const startMonth = FISCAL_START_MONTH + (quarterOffset * 3);
|
||||||
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
|
return createDateRange(fiscalStartYear, startMonth, fiscalStartYear, startMonth + (numberOfQuarters * 3));
|
||||||
|
|
||||||
return {
|
|
||||||
startCloseDate: formatOracleDate(start),
|
|
||||||
endCloseDate: formatOracleDate(end)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
||||||
return {
|
return {
|
||||||
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
||||||
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
|
endCloseDate: formatOracleDate(new Date(endYear, endMonth, 1))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
|
|||||||
|
|
||||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||||
const key = getOwnerKey(item);
|
const key = getOwnerKey(item);
|
||||||
const label = getOwnerName(item);
|
const label = String(getOwnerName(item)).trim() || "-";
|
||||||
|
|
||||||
if (key && label && !optionsByKey.has(key)) {
|
if (key && label && !optionsByKey.has(key)) {
|
||||||
optionsByKey.set(key, { key, label: String(label) });
|
optionsByKey.set(key, { key, label: String(label) });
|
||||||
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
|
|||||||
function getOwnerKey(item) {
|
function getOwnerKey(item) {
|
||||||
const owner = item.Owner;
|
const owner = item.Owner;
|
||||||
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
||||||
const name = getOwnerName(item);
|
const name = String(getOwnerName(item)).trim();
|
||||||
|
|
||||||
|
if (!name) return "owner:unassigned";
|
||||||
|
|
||||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||||
return `party:${partyId}`;
|
return `party:${partyId}`;
|
||||||
@@ -142,7 +144,7 @@ function createOpportunitiesFormatters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
return number.toLocaleString();
|
return number.toLocaleString("en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +153,7 @@ function createOpportunitiesFormatters({
|
|||||||
currency
|
currency
|
||||||
}).format(number);
|
}).format(number);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return `${currency} ${number.toLocaleString()}`;
|
return `${currency} ${number.toLocaleString("en-US")}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
60
src/features/opportunities/internal-sr-session.js
Normal file
60
src/features/opportunities/internal-sr-session.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
||||||
|
const { document, setTimeout } = environment;
|
||||||
|
let recovery = null;
|
||||||
|
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
|
||||||
|
const now = () => environment.performance?.now() ?? Date.now();
|
||||||
|
function isUnauthorized(result) {
|
||||||
|
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
||||||
|
if (status != null) return Number(status) === 401;
|
||||||
|
// Older background workers may return only the request error text.
|
||||||
|
return /failed with status 401\.$/.test(result?.error || "");
|
||||||
|
}
|
||||||
|
async function warmSession(id) {
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
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;
|
||||||
|
let loaded = false;
|
||||||
|
frame.addEventListener("error", () => { blocked = true; });
|
||||||
|
frame.addEventListener("load", () => { loaded = true; });
|
||||||
|
try {
|
||||||
|
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
|
||||||
|
document.body.append(frame);
|
||||||
|
const deadline = now() + 60000;
|
||||||
|
for (let attempt = 0; now() < deadline; attempt += 1) {
|
||||||
|
// Check immediately, then briefly poll faster before backing off.
|
||||||
|
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
|
||||||
|
if (now() >= deadline) break;
|
||||||
|
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
|
||||||
|
const result = await query(id);
|
||||||
|
if (result?.ok) return { id, result };
|
||||||
|
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||||
|
}
|
||||||
|
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
|
||||||
|
} finally {
|
||||||
|
// Stop remaining page resources without stopping the caller's page.
|
||||||
|
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
|
||||||
|
frame.src = "about:blank";
|
||||||
|
frame.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function request(id) {
|
||||||
|
let result = await query(id);
|
||||||
|
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
|
||||||
|
if (!recovery) {
|
||||||
|
recovery = warmSession(id);
|
||||||
|
const pending = recovery;
|
||||||
|
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
|
||||||
|
}
|
||||||
|
const recovered = await recovery;
|
||||||
|
result = recovered.id === id ? recovered.result : await query(id);
|
||||||
|
}
|
||||||
|
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
function createOpportunitiesQueryState() {
|
function createOpportunitiesQueryState() {
|
||||||
return {
|
return {
|
||||||
periodRequestVersion: 0
|
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 };
|
||||||
|
}
|
||||||
@@ -9,14 +9,7 @@
|
|||||||
.opportunities-extension-auth-badge { transition: none; }
|
.opportunities-extension-auth-badge { transition: none; }
|
||||||
}
|
}
|
||||||
.opportunities-extension-crm-sr-badge {
|
.opportunities-extension-crm-sr-badge {
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid #408254;
|
border: 1px solid #408254;
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #185b30;
|
color: #185b30;
|
||||||
background: #e1f1e5;
|
background: #e1f1e5;
|
||||||
}
|
}
|
||||||
@@ -31,14 +24,15 @@
|
|||||||
|
|
||||||
.opportunities-extension-form {
|
.opportunities-extension-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;
|
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form > * {
|
.opportunities-extension-form > *,
|
||||||
|
.opportunities-extension-badge-filters > section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -47,7 +41,8 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form > * > span:first-child {
|
.opportunities-extension-form > * > span:first-child,
|
||||||
|
.opportunities-extension-badge-filters > section > span:first-child {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
@@ -89,30 +84,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
||||||
.opportunities-extension-form .opportunities-extension-status-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;
|
min-height: 28px;
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native select intrinsic sizing includes its longest option, not just the selection. */
|
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
|
||||||
@media (min-width: 1151px) {
|
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
|
||||||
.opportunities-extension-form > .opportunities-extension-field,
|
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
|
||||||
.opportunities-extension-form .opportunities-extension-select-wrap,
|
display: inline-flex;
|
||||||
.opportunities-extension-form .opportunities-extension-field select {
|
align-items: center;
|
||||||
width: max-content;
|
justify-content: flex-start;
|
||||||
max-width: none;
|
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) {
|
@media (max-width: 1150px) {
|
||||||
.opportunities-extension-form {
|
.opportunities-extension-form {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter {
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,25 +167,259 @@
|
|||||||
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter {
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; }
|
.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. */
|
/* Keep layout rules on the outer table, never on expanded product tables. */
|
||||||
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
|
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1120px;
|
min-width: 1168px;
|
||||||
table-layout: fixed;
|
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. */
|
/* 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="name"] { width: auto; }
|
||||||
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
|
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
|
||||||
@@ -218,7 +494,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
|
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
|
||||||
min-width: 84px;
|
min-width: 114px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
|
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
|
||||||
@@ -232,18 +508,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-products-ramp {
|
.opportunities-extension-products-ramp {
|
||||||
display: inline-block;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid #bba5cf;
|
border: 1px solid #bba5cf;
|
||||||
border-radius: 8px;
|
|
||||||
background: #f0e8f7;
|
background: #f0e8f7;
|
||||||
color: #63358a;
|
color: #63358a;
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 16px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -302,6 +571,11 @@
|
|||||||
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
|
.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-popover a { color: #00677d; text-decoration: underline; }
|
||||||
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
|
.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 {
|
.opportunities-extension-sr-dismiss {
|
||||||
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
||||||
display: grid; place-items: center; padding: 4px; border: 0;
|
display: grid; place-items: center; padding: 4px; border: 0;
|
||||||
@@ -311,3 +585,49 @@
|
|||||||
.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 { 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 section + section { border-color: #5f5953; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
.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; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
function createOpportunitiesTable({
|
function createOpportunitiesTable({
|
||||||
|
tableSorting,
|
||||||
ACCOUNT_DETAIL_URL,
|
ACCOUNT_DETAIL_URL,
|
||||||
OPPORTUNITIES_COLUMNS,
|
OPPORTUNITIES_COLUMNS,
|
||||||
OPPORTUNITIES_TABLE_ID,
|
OPPORTUNITIES_TABLE_ID,
|
||||||
@@ -6,10 +7,12 @@ function createOpportunitiesTable({
|
|||||||
createCloudConsumptionButton,
|
createCloudConsumptionButton,
|
||||||
createInstallBaseButton,
|
createInstallBaseButton,
|
||||||
createServiceRequestsButton,
|
createServiceRequestsButton,
|
||||||
|
createOrganizationChartButton,
|
||||||
createCopyOpportunityButton,
|
createCopyOpportunityButton,
|
||||||
createDetailLink,
|
createDetailLink,
|
||||||
createProductsCountButton,
|
createProductsCountButton,
|
||||||
createProductsRampBadge,
|
createProductsRampBadge,
|
||||||
|
createProjectionIndicator,
|
||||||
createCrmServiceRequestBadge,
|
createCrmServiceRequestBadge,
|
||||||
closeCrmSrPopover,
|
closeCrmSrPopover,
|
||||||
createWorkloadActionsBadge,
|
createWorkloadActionsBadge,
|
||||||
@@ -23,7 +26,9 @@ function createOpportunitiesTable({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderCustomerFilter,
|
renderCustomerFilter,
|
||||||
renderOwnerFilter,
|
renderOwnerFilter,
|
||||||
|
renderActivityFilters,
|
||||||
renderStageDashboard,
|
renderStageDashboard,
|
||||||
|
renderCalendar,
|
||||||
resetCustomerFilter,
|
resetCustomerFilter,
|
||||||
resetOwnerFilter,
|
resetOwnerFilter,
|
||||||
environment
|
environment
|
||||||
@@ -38,8 +43,8 @@ function createOpportunitiesTable({
|
|||||||
opportunitiesTableState.opportunitiesTableState = {
|
opportunitiesTableState.opportunitiesTableState = {
|
||||||
...opportunitiesTableState.opportunitiesTableState,
|
...opportunitiesTableState.opportunitiesTableState,
|
||||||
...nextState,
|
...nextState,
|
||||||
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
|
sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
|
||||||
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
|
sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
|
||||||
};
|
};
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
renderCustomerFilter();
|
renderCustomerFilter();
|
||||||
@@ -47,6 +52,7 @@ function createOpportunitiesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderOpportunitiesTable() {
|
function renderOpportunitiesTable() {
|
||||||
|
renderActivityFilters();
|
||||||
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
||||||
|
|
||||||
if (!table) {
|
if (!table) {
|
||||||
@@ -60,7 +66,7 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const caption = document.createElement("caption");
|
const caption = document.createElement("caption");
|
||||||
caption.className = "opportunities-extension-visually-hidden";
|
caption.className = "opportunities-extension-visually-hidden";
|
||||||
caption.textContent = "Opportunities";
|
caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
|
||||||
|
|
||||||
const tableHead = document.createElement("thead");
|
const tableHead = document.createElement("thead");
|
||||||
const headerRow = document.createElement("tr");
|
const headerRow = document.createElement("tr");
|
||||||
@@ -70,6 +76,7 @@ function createOpportunitiesTable({
|
|||||||
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
|
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
|
||||||
header.scope = "col";
|
header.scope = "col";
|
||||||
header.setAttribute("data-column", column.key);
|
header.setAttribute("data-column", column.key);
|
||||||
|
if (column.key === "revenue") header.setAttribute("data-align", "money");
|
||||||
|
|
||||||
if (column.sortable === false) {
|
if (column.sortable === false) {
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
@@ -81,6 +88,7 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const sortButton = document.createElement("button");
|
const sortButton = document.createElement("button");
|
||||||
sortButton.type = "button";
|
sortButton.type = "button";
|
||||||
|
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
|
||||||
sortButton.className = "opportunities-extension-sort-button";
|
sortButton.className = "opportunities-extension-sort-button";
|
||||||
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||||
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
||||||
@@ -99,9 +107,36 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const tableBody = document.createElement("tbody");
|
const tableBody = document.createElement("tbody");
|
||||||
const items = getSortedOpportunities();
|
const items = getSortedOpportunities();
|
||||||
renderStageDashboard(items);
|
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
|
||||||
|
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
|
||||||
|
|
||||||
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
|
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 row = document.createElement("tr");
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
cell.className = "opportunities-extension-table-status";
|
cell.className = "opportunities-extension-table-status";
|
||||||
@@ -116,6 +151,7 @@ function createOpportunitiesTable({
|
|||||||
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
cell.setAttribute("data-column", column.key);
|
cell.setAttribute("data-column", column.key);
|
||||||
|
if (column.key === "revenue") cell.setAttribute("data-align", "money");
|
||||||
|
|
||||||
if (column.key === "stage") {
|
if (column.key === "stage") {
|
||||||
cell.append(createStageBadge(column.display(item)));
|
cell.append(createStageBadge(column.display(item)));
|
||||||
@@ -135,9 +171,12 @@ function createOpportunitiesTable({
|
|||||||
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||||
if (productsResult) {
|
if (productsResult) {
|
||||||
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
|
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
|
||||||
const rampBadge = createProductsRampBadge(productsResult);
|
const rampBadge = createProductsRampBadge(productsResult, item);
|
||||||
if (rampBadge) productActions.append(rampBadge);
|
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);
|
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
|
||||||
if (srBadge) productActions.append(srBadge);
|
if (srBadge) productActions.append(srBadge);
|
||||||
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
||||||
@@ -163,7 +202,7 @@ function createOpportunitiesTable({
|
|||||||
);
|
);
|
||||||
const customerActions = document.createElement("span");
|
const customerActions = document.createElement("span");
|
||||||
customerActions.className = "opportunities-extension-cell-actions";
|
customerActions.className = "opportunities-extension-cell-actions";
|
||||||
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item));
|
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item), createOrganizationChartButton(item));
|
||||||
customerCellContent.append(customerLink, customerActions);
|
customerCellContent.append(customerLink, customerActions);
|
||||||
cell.append(customerCellContent);
|
cell.append(customerCellContent);
|
||||||
} else {
|
} else {
|
||||||
@@ -185,11 +224,13 @@ function createOpportunitiesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sortOpportunitiesBy(key) {
|
function sortOpportunitiesBy(key) {
|
||||||
|
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
|
||||||
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
||||||
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
||||||
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
||||||
? "descending"
|
? "descending"
|
||||||
: "ascending";
|
: "ascending";
|
||||||
|
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
304
src/features/opportunities/workload-action-form.js
Normal file
304
src/features/opportunities/workload-action-form.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
|
||||||
|
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
let active = null;
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
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(optyNumber) {
|
||||||
|
const button = element("button", "opportunities-extension-add-action", "+");
|
||||||
|
button.type = "button";
|
||||||
|
button.title = "Add Action";
|
||||||
|
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
|
||||||
|
button.setAttribute("aria-haspopup", "dialog");
|
||||||
|
button.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
open(optyNumber, button);
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
function open(optyNumber, trigger) {
|
||||||
|
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
|
||||||
|
const host = document.getElementById(MODAL_ID);
|
||||||
|
if (!host || host.hidden) return;
|
||||||
|
close(false);
|
||||||
|
const modal = element("section", "opportunities-extension-action-form-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
|
||||||
|
const dialog = element("div", "opportunities-extension-action-form-dialog");
|
||||||
|
const header = element("header", "opportunities-extension-account-plans-header");
|
||||||
|
const heading = element("div", "opportunities-extension-action-form-heading");
|
||||||
|
const title = element("h2", "", "Add Action");
|
||||||
|
title.id = "opportunities-extension-action-form-title";
|
||||||
|
heading.append(title, element("p", "", String(optyNumber)));
|
||||||
|
const opportunity = getOpportunity(optyNumber);
|
||||||
|
const details = element("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(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
|
||||||
|
}
|
||||||
|
heading.append(details);
|
||||||
|
const dismiss = element("button", "opportunities-extension-icon-button");
|
||||||
|
dismiss.type = "button";
|
||||||
|
dismiss.title = "Close";
|
||||||
|
dismiss.setAttribute("aria-label", "Close Action form");
|
||||||
|
dismiss.append(createCloseIcon());
|
||||||
|
dismiss.addEventListener("click", () => close());
|
||||||
|
header.append(heading, dismiss);
|
||||||
|
const form = element("form", "opportunities-extension-action-form");
|
||||||
|
form.noValidate = true;
|
||||||
|
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
|
||||||
|
.map(product => [String(product.RevnId), product])).entries()];
|
||||||
|
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
|
||||||
|
const workload = element("select");
|
||||||
|
workload.id = "opportunities-extension-action-form-workload";
|
||||||
|
workloadField.htmlFor = workload.id;
|
||||||
|
function option(select, value, label) {
|
||||||
|
const item = element("option", "", label);
|
||||||
|
item.value = value;
|
||||||
|
select.append(item);
|
||||||
|
}
|
||||||
|
option(workload, "", "Select workload");
|
||||||
|
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
|
||||||
|
workload.value = workloads.length === 1 ? workloads[0][0] : "";
|
||||||
|
workloadField.hidden = workloads.length <= 1;
|
||||||
|
workloadField.append(element("span", "", "Workload"), workload);
|
||||||
|
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
form.append(workloadField, status);
|
||||||
|
const searchable = new Map();
|
||||||
|
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
|
||||||
|
const fields = [];
|
||||||
|
const selects = [];
|
||||||
|
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
|
||||||
|
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
|
||||||
|
for (const [name, label, type] of [
|
||||||
|
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
|
||||||
|
["team", "Team", "select"], ["startDate", "Start date", "date"],
|
||||||
|
["endDate", "End Date", "date"], ["status", "Status", "select"],
|
||||||
|
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
|
||||||
|
]) {
|
||||||
|
const field = element("label", "opportunities-extension-action-form-field");
|
||||||
|
const input = element(type === "select" || type === "textarea" ? type : "input");
|
||||||
|
if (type === "select") selects.push({ input, label, name });
|
||||||
|
else if (type === "textarea") input.rows = 5;
|
||||||
|
else input.type = type;
|
||||||
|
input.disabled = true;
|
||||||
|
input.required = name !== "notes";
|
||||||
|
input.setAttribute("aria-required", String(input.required));
|
||||||
|
input.name = name;
|
||||||
|
input.id = `opportunities-extension-action-form-${name}`;
|
||||||
|
field.htmlFor = input.id;
|
||||||
|
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
|
||||||
|
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
|
||||||
|
if (type === "select") searchable.set(input, searchableSelect(input, field));
|
||||||
|
form.append(field);
|
||||||
|
fields.push(input);
|
||||||
|
}
|
||||||
|
const footer = element("footer", "opportunities-extension-action-form-footer");
|
||||||
|
const save = element("button", "opportunities-extension-action-form-save", "Save");
|
||||||
|
save.type = "submit";
|
||||||
|
save.disabled = true;
|
||||||
|
save.title = "Save Action";
|
||||||
|
const retry = element("button", "", "Retry");
|
||||||
|
retry.type = "button";
|
||||||
|
retry.hidden = true;
|
||||||
|
const cancel = element("button", "", "Cancel");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.addEventListener("click", () => close());
|
||||||
|
footer.append(retry, save, cancel);
|
||||||
|
form.append(footer);
|
||||||
|
dialog.append(header, form);
|
||||||
|
modal.append(dialog);
|
||||||
|
host.append(modal);
|
||||||
|
const view = { modal, trigger, generation: 0, lookup: null, loadingId: null };
|
||||||
|
active = view;
|
||||||
|
const isOpen = () => active === view && allowsCrmActivities();
|
||||||
|
let ready = false;
|
||||||
|
function validate() {
|
||||||
|
const invalid = [];
|
||||||
|
const value = name => String(fields.find(field => field.name === name)?.value || "").trim();
|
||||||
|
for (const field of fields) {
|
||||||
|
let valid = !field.required || !!value(field.name);
|
||||||
|
if (field.required && searchable.has(field)) valid = Array.from(field.children).some(option => option.value && option.value === field.value);
|
||||||
|
if (field.type === "date") {
|
||||||
|
const date = value(field.name);
|
||||||
|
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00.000Z`));
|
||||||
|
if (valid) valid = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date;
|
||||||
|
if (field.name === "endDate" && value("startDate") > date) valid = false;
|
||||||
|
}
|
||||||
|
const control = searchable.get(field)?.input || field;
|
||||||
|
control.setAttribute("aria-invalid", String(!valid));
|
||||||
|
if (!valid) invalid.push(control);
|
||||||
|
}
|
||||||
|
return { invalid, value };
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!ready || !isOpen() || view.saving) return;
|
||||||
|
const { invalid, value } = validate();
|
||||||
|
if (invalid.length) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = "Complete the highlighted required fields. End date must be on or after start date.";
|
||||||
|
invalid[0].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = getCurrentUserId();
|
||||||
|
if (!userId) { notify("Unable to save: current user is unavailable.", true); return; }
|
||||||
|
const payload = { parentType: "WORKLOAD", parentId: workload.value, action: {
|
||||||
|
ownerEmail: value("owner"), role: value("role"), name: value("action"), team: value("team"), tags: "",
|
||||||
|
startDate: `${value("startDate")}T00:00:00.000Z`, endDate: `${value("endDate")}T00:00:00.000Z`,
|
||||||
|
status: value("status"), complexity: value("complexity"), notes: value("notes") ? [value("notes")] : [],
|
||||||
|
objective: "", createdBy: userId, updatedBy: userId
|
||||||
|
} };
|
||||||
|
view.saving = true;
|
||||||
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = true;
|
||||||
|
fields.forEach(field => { field.disabled = true; });
|
||||||
|
searchable.forEach(control => control.sync());
|
||||||
|
save.textContent = "Saving...";
|
||||||
|
form.setAttribute("aria-busy", "true");
|
||||||
|
status.hidden = true;
|
||||||
|
try {
|
||||||
|
const result = await saveAction(payload, isOpen);
|
||||||
|
if (!result) return;
|
||||||
|
view.saving = false;
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
await onActionSaved(optyNumber, payload.parentId, result.result, payload.action);
|
||||||
|
} catch {
|
||||||
|
notify("Action saved, but the local badge could not be updated.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify("Action saved successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message || "Unable to save Action.";
|
||||||
|
notify(status.textContent, true);
|
||||||
|
} finally {
|
||||||
|
view.saving = false;
|
||||||
|
if (active === view) {
|
||||||
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = false;
|
||||||
|
fields.forEach(field => { field.disabled = false; });
|
||||||
|
searchable.forEach(control => control.sync());
|
||||||
|
save.textContent = "Save";
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const settled = promise => promise.then(result => ({ result }), error => ({ error }));
|
||||||
|
async function loadOptions(refresh = false) {
|
||||||
|
const id = workload.value;
|
||||||
|
if (!isOpen() || (view.loadingId === id && !refresh)) return;
|
||||||
|
const generation = ++view.generation;
|
||||||
|
const isCurrent = () => isOpen() && view.generation === generation;
|
||||||
|
ready = false;
|
||||||
|
save.disabled = true;
|
||||||
|
retry.hidden = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
fields.forEach(input => { input.disabled = true; });
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Loading..."); });
|
||||||
|
const syncSearch = () => selects.forEach(({ input }) => searchable.get(input).sync());
|
||||||
|
syncSearch();
|
||||||
|
if (!workloads.length) {
|
||||||
|
form.setAttribute("data-state", "error");
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = "No product with a valid RevnId is available for this opportunity.";
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
||||||
|
syncSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!view.lookup || refresh) view.lookup = settled(requestLookup(isOpen));
|
||||||
|
if (!workloads.some(([value]) => value === id)) {
|
||||||
|
view.loadingId = null;
|
||||||
|
form.setAttribute("data-state", "waiting");
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
status.textContent = "Select a workload to load Action options.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.loadingId = id;
|
||||||
|
form.setAttribute("data-state", "loading");
|
||||||
|
form.setAttribute("aria-busy", "true");
|
||||||
|
status.textContent = "Loading Action options...";
|
||||||
|
try {
|
||||||
|
const [lookup, team] = await Promise.all([view.lookup, settled(requestTeamMembers(id, isCurrent))]);
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
if (lookup.error || team.error) throw lookup.error || team.error;
|
||||||
|
const values = selects.map(({ name }) => name === "owner"
|
||||||
|
? team.result.members.map(({ email }) => ({ code: email, name: email }))
|
||||||
|
: lookup.result.lookups[lookupKeys[name]]);
|
||||||
|
const defaults = { owner: getCurrentUserId(), role: "Cloud Architect",
|
||||||
|
action: "Success Plan & Consumption review", status: "Complete" };
|
||||||
|
const normalize = value => String(value ?? "").trim().toLowerCase();
|
||||||
|
for (const [index, { input, label, name }] of selects.entries()) {
|
||||||
|
input.textContent = "";
|
||||||
|
option(input, "", values[index].length ? `Select ${label.toLowerCase()}` : "No options available");
|
||||||
|
for (const item of values[index]) option(input, item.code, item.name);
|
||||||
|
const match = defaults[name] && values[index].find(item =>
|
||||||
|
normalize(name === "owner" ? item.code : item.name) === normalize(defaults[name]));
|
||||||
|
input.value = match ? match.code : "";
|
||||||
|
}
|
||||||
|
fields.forEach(input => { input.disabled = false; });
|
||||||
|
selects.forEach(({ input }, index) => { input.disabled = !values[index].length; });
|
||||||
|
form.setAttribute("data-state", "ready");
|
||||||
|
ready = true;
|
||||||
|
save.disabled = false;
|
||||||
|
status.hidden = true;
|
||||||
|
syncSearch();
|
||||||
|
if (document.activeElement === dismiss) {
|
||||||
|
const first = fields.find(field => !field.disabled);
|
||||||
|
(searchable.get(first)?.input || first)?.focus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
form.setAttribute("data-state", "error");
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message || "Unable to load Action options.";
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
||||||
|
syncSearch();
|
||||||
|
retry.hidden = false;
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) { view.loadingId = null; form.setAttribute("aria-busy", "false"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retry.addEventListener("click", () => { if (!view.loadingId) loadOptions(true); });
|
||||||
|
workload.addEventListener("change", () => loadOptions());
|
||||||
|
modal.addEventListener("click", event => { if (event.target === modal) close(); });
|
||||||
|
modal.addEventListener("keydown", event => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = [dismiss, ...(!workloadField.hidden ? [workload] : []), ...fields, retry, save, cancel]
|
||||||
|
.map(control => searchable.get(control)?.input || control)
|
||||||
|
.filter(control => !control.disabled && !control.hidden);
|
||||||
|
const index = controls.indexOf(document.activeElement);
|
||||||
|
if (!controls.length) { event.preventDefault(); return; }
|
||||||
|
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
|
||||||
|
event.preventDefault();
|
||||||
|
controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!workloadField.hidden) searchable.get(workload).input.focus();
|
||||||
|
else dismiss.focus();
|
||||||
|
loadOptions();
|
||||||
|
}
|
||||||
|
return { createButton, open, close };
|
||||||
|
}
|
||||||
17
src/features/opportunities/workload-action-options.js
Normal file
17
src/features/opportunities/workload-action-options.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
|
async function request(type, workloadId, isCurrent, payload) {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
const authorization = authorizations?.workloadActions;
|
||||||
|
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
|
||||||
|
appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
|
||||||
|
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
|
||||||
|
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,31 +1,59 @@
|
|||||||
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
|
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
|
||||||
let session = newSession();
|
let session = newSession();
|
||||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
|
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
|
||||||
|
function updateProgress(current) {
|
||||||
|
if (current !== session || !current.enabled || !current.progress.size) return;
|
||||||
|
const records = [...current.progress];
|
||||||
|
const completed = records.filter(record => record.status !== "loading").length;
|
||||||
|
const failed = records.filter(record => record.status === "error").length;
|
||||||
|
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
|
||||||
|
}
|
||||||
function reset(enabled = true) {
|
function reset(enabled = true) {
|
||||||
|
closeActionForm(false);
|
||||||
session.enabled = false;
|
session.enabled = false;
|
||||||
|
for (const job of session.queue) job.record.resolveCompletion();
|
||||||
session.queue.length = 0;
|
session.queue.length = 0;
|
||||||
session = newSession();
|
session = newSession();
|
||||||
session.enabled = enabled;
|
session.enabled = enabled;
|
||||||
|
reportProgress("idle");
|
||||||
}
|
}
|
||||||
function idsFor(products) {
|
function idsFor(products) {
|
||||||
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
function loadProducts(products) {
|
function loadProducts(products, refresh = false, cacheOptions = null) {
|
||||||
if (!session.enabled || !allowsCrmActivities()) return;
|
if (!session.enabled || !allowsCrmActivities()) return;
|
||||||
|
const pending = [];
|
||||||
|
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
|
||||||
for (const revnId of idsFor(products)) {
|
for (const revnId of idsFor(products)) {
|
||||||
if (session.records.has(revnId)) continue;
|
if (!refresh && session.records.has(revnId)) {
|
||||||
|
pending.push(session.records.get(revnId).completion);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
session.progress.delete(session.records.get(revnId));
|
||||||
const record = { status: "loading", actions: [], hasNext: false };
|
const record = { status: "loading", actions: [], hasNext: false };
|
||||||
|
record.completion = new Promise(resolve => { record.resolveCompletion = resolve; });
|
||||||
|
pending.push(record.completion);
|
||||||
session.records.set(revnId, record);
|
session.records.set(revnId, record);
|
||||||
session.queue.push({ revnId, record });
|
if (!cacheOptions) session.progress.add(record);
|
||||||
|
session.queue.push({ revnId, record, cacheOptions });
|
||||||
}
|
}
|
||||||
|
updateProgress(session);
|
||||||
pump(session);
|
pump(session);
|
||||||
|
return Promise.all(pending);
|
||||||
}
|
}
|
||||||
function reopen() {
|
function reopen() {
|
||||||
reset();
|
reset();
|
||||||
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
||||||
queueProductsTableRender();
|
queueProductsTableRender();
|
||||||
}
|
}
|
||||||
|
function blockProducts(products) {
|
||||||
|
if (!allowsCrmActivities()) return;
|
||||||
|
for (const id of idsFor(products)) {
|
||||||
|
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
|
||||||
|
}
|
||||||
|
updateProgress(session);
|
||||||
|
}
|
||||||
function pump(current) {
|
function pump(current) {
|
||||||
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
|
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
|
||||||
const job = current.queue.shift();
|
const job = current.queue.shift();
|
||||||
@@ -33,26 +61,48 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function run(current, { revnId, record }) {
|
async function run(current, { revnId, record, cacheOptions }) {
|
||||||
try {
|
try {
|
||||||
const authorizations = await requestConsumerTokenServiceOnce();
|
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
|
||||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
if (cached) {
|
||||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
record.actions = cached.items;
|
||||||
appendBackgroundRequestLog(result?.debugRequests);
|
record.hasNext = cached.hasNext;
|
||||||
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
record.status = "ready";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
|
||||||
|
current.progress.add(record);
|
||||||
|
updateProgress(current);
|
||||||
|
let result;
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
|
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
|
appendBackgroundRequestLog(result?.debugRequests);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 5) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
record.actions = result.actions;
|
record.actions = result.actions;
|
||||||
record.hasNext = result.hasNext;
|
record.hasNext = result.hasNext;
|
||||||
record.status = "ready";
|
record.status = "ready";
|
||||||
|
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!current.enabled || current !== session) return;
|
if (!current.enabled || current !== session) return;
|
||||||
record.status = "error";
|
record.status = "error";
|
||||||
record.error = error.message || "Unable to load workload actions.";
|
record.error = error.message || "Unable to load workload actions.";
|
||||||
} finally {
|
} finally {
|
||||||
if (current.enabled && current === session) queueProductsTableRender();
|
record.resolveCompletion();
|
||||||
|
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createBadge(optyNumber) {
|
function getSummary(optyNumber) {
|
||||||
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
|
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
|
||||||
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
||||||
if (!products) return null;
|
if (!products) return null;
|
||||||
@@ -63,25 +113,63 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|
||||||
|| records.some(record => record?.status === "error" || record?.hasNext);
|
|| records.some(record => record?.status === "error" || record?.hasNext);
|
||||||
const principal = authSessionState.principal;
|
const principal = authSessionState.principal;
|
||||||
const owned = Boolean(principal) && actions.some(action => typeof action.owner === "string" && action.owner.trim() === principal);
|
const owned = Boolean(principal?.trim()) && actions.some(action => typeof action.owner === "string"
|
||||||
|
&& action.owner.trim().toLowerCase() === principal.trim().toLowerCase());
|
||||||
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
|
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
|
||||||
|
return { state, actions, records, principal, pending, uncertain };
|
||||||
|
}
|
||||||
|
async function applySavedAction(optyNumber, revnId, response, submittedAction = {}) {
|
||||||
|
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
||||||
|
const id = String(revnId);
|
||||||
|
if (!session.enabled || !allowsCrmActivities() || !idsFor(products).includes(id)) return false;
|
||||||
|
const returned = Array.isArray(response) ? response : Array.isArray(response?.actions) ? response.actions : [response?.action || response];
|
||||||
|
const saved = returned.filter(action => action && typeof action === "object" && action.id != null
|
||||||
|
&& (action.revenueId == null || String(action.revenueId) === id));
|
||||||
|
if (!saved.length) return false;
|
||||||
|
const previous = session.records.get(id);
|
||||||
|
const actions = [...(previous?.actions || [])];
|
||||||
|
for (const action of saved) {
|
||||||
|
// The POST can return a display name in owner, unlike the workload GET.
|
||||||
|
const ownerEmail = typeof action.ownerEmail === "string" && action.ownerEmail.trim()
|
||||||
|
? action.ownerEmail.trim() : submittedAction.ownerEmail || action.owner || "";
|
||||||
|
const normalized = { ...action, ownerEmail, owner: ownerEmail };
|
||||||
|
const index = actions.findIndex(item => String(item.id) === String(action.id));
|
||||||
|
if (index === -1) actions.push(normalized);
|
||||||
|
else actions[index] = { ...actions[index], ...normalized };
|
||||||
|
}
|
||||||
|
const record = { status: "ready", actions, hasNext: previous?.hasNext || false,
|
||||||
|
completion: Promise.resolve(), resolveCompletion() {} };
|
||||||
|
session.records.set(id, record);
|
||||||
|
previous?.resolveCompletion?.();
|
||||||
|
session.progress.delete(previous);
|
||||||
|
queueProductsTableRender();
|
||||||
|
if (products.cachedAt) await activityCache.write("actions", id, products.cachedAt, actions, record.hasNext);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function createBadge(optyNumber) {
|
||||||
|
const summary = getSummary(optyNumber);
|
||||||
|
if (!summary) return null;
|
||||||
|
const { state, actions, records, principal, pending, uncertain } = summary;
|
||||||
const badge = environment.document.createElement("span");
|
const badge = environment.document.createElement("span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
||||||
badge.setAttribute("data-state", state);
|
badge.setAttribute("data-state", state);
|
||||||
badge.textContent = state === "matched" ? "\u2705 Act" : state === "missing" ? "\u26d4 Act" : state === "loading" ? "Act ..." : "Act ?";
|
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
|
||||||
badge.title = actions.length ? actions.map(action => [
|
badge.title = actions.length ? actions.map(action => [
|
||||||
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
|
`Action: ${action.name ?? "N/A"}`, `ID: ${action.id ?? "N/A"}`, `Status: ${action.status ?? "N/A"}`,
|
||||||
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
|
`Owner: ${action.owner || "N/A"}`, `Email: ${action.ownerEmail || "N/A"}`, `Workload: ${action.workloadName || "N/A"}`,
|
||||||
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
|
`Description: ${action.workloadDescription || "N/A"}`, `Start: ${action.startDate || "N/A"}`, `End: ${action.endDate || "N/A"}`
|
||||||
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
|
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
|
||||||
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
|
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
|
||||||
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
|
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
|
||||||
|
const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))];
|
||||||
|
if (errors.length) badge.title += `\n${errors.join("\n")}`;
|
||||||
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
|
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
|
||||||
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
|
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
|
||||||
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
|
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
|
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
return { reset, reopen, loadProducts, createBadge };
|
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/features/org-chart/calling-codes.js
Normal file
3
src/features/org-chart/calling-codes.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Calling codes: Google libphonenumber, Apache-2.0. Retrieved 2026-10-26.
|
||||||
|
// https://github.com/google/libphonenumber/blob/master/resources/PhoneNumberMetadata.xml
|
||||||
|
const orgChartCallingCodes = [["AC","247"],["AD","376"],["AE","971"],["AF","93"],["AG","1"],["AI","1"],["AL","355"],["AM","374"],["AO","244"],["AR","54"],["AS","1"],["AT","43"],["AU","61"],["AW","297"],["AX","358"],["AZ","994"],["BA","387"],["BB","1"],["BD","880"],["BE","32"],["BF","226"],["BG","359"],["BH","973"],["BI","257"],["BJ","229"],["BL","590"],["BM","1"],["BN","673"],["BO","591"],["BQ","599"],["BR","55"],["BS","1"],["BT","975"],["BW","267"],["BY","375"],["BZ","501"],["CA","1"],["CC","61"],["CD","243"],["CF","236"],["CG","242"],["CH","41"],["CI","225"],["CK","682"],["CL","56"],["CM","237"],["CN","86"],["CO","57"],["CR","506"],["CU","53"],["CV","238"],["CW","599"],["CX","61"],["CY","357"],["CZ","420"],["DE","49"],["DJ","253"],["DK","45"],["DM","1"],["DO","1"],["DZ","213"],["EC","593"],["EE","372"],["EG","20"],["EH","212"],["ER","291"],["ES","34"],["ET","251"],["FI","358"],["FJ","679"],["FK","500"],["FM","691"],["FO","298"],["FR","33"],["GA","241"],["GB","44"],["GD","1"],["GE","995"],["GF","594"],["GG","44"],["GH","233"],["GI","350"],["GL","299"],["GM","220"],["GN","224"],["GP","590"],["GQ","240"],["GR","30"],["GT","502"],["GU","1"],["GW","245"],["GY","592"],["HK","852"],["HN","504"],["HR","385"],["HT","509"],["HU","36"],["ID","62"],["IE","353"],["IL","972"],["IM","44"],["IN","91"],["IO","246"],["IQ","964"],["IR","98"],["IS","354"],["IT","39"],["JE","44"],["JM","1"],["JO","962"],["JP","81"],["KE","254"],["KG","996"],["KH","855"],["KI","686"],["KM","269"],["KN","1"],["KP","850"],["KR","82"],["KW","965"],["KY","1"],["KZ","7"],["LA","856"],["LB","961"],["LC","1"],["LI","423"],["LK","94"],["LR","231"],["LS","266"],["LT","370"],["LU","352"],["LV","371"],["LY","218"],["MA","212"],["MC","377"],["MD","373"],["ME","382"],["MF","590"],["MG","261"],["MH","692"],["MK","389"],["ML","223"],["MM","95"],["MN","976"],["MO","853"],["MP","1"],["MQ","596"],["MR","222"],["MS","1"],["MT","356"],["MU","230"],["MV","960"],["MW","265"],["MX","52"],["MY","60"],["MZ","258"],["NA","264"],["NC","687"],["NE","227"],["NF","672"],["NG","234"],["NI","505"],["NL","31"],["NO","47"],["NP","977"],["NR","674"],["NU","683"],["NZ","64"],["OM","968"],["PA","507"],["PE","51"],["PF","689"],["PG","675"],["PH","63"],["PK","92"],["PL","48"],["PM","508"],["PR","1"],["PS","970"],["PT","351"],["PW","680"],["PY","595"],["QA","974"],["RE","262"],["RO","40"],["RS","381"],["RU","7"],["RW","250"],["SA","966"],["SB","677"],["SC","248"],["SD","249"],["SE","46"],["SG","65"],["SH","290"],["SI","386"],["SJ","47"],["SK","421"],["SL","232"],["SM","378"],["SN","221"],["SO","252"],["SR","597"],["SS","211"],["ST","239"],["SV","503"],["SX","1"],["SY","963"],["SZ","268"],["TA","290"],["TC","1"],["TD","235"],["TG","228"],["TH","66"],["TJ","992"],["TK","690"],["TL","670"],["TM","993"],["TN","216"],["TO","676"],["TR","90"],["TT","1"],["TV","688"],["TW","886"],["TZ","255"],["UA","380"],["UG","256"],["US","1"],["UY","598"],["UZ","998"],["VA","39"],["VC","1"],["VE","58"],["VG","1"],["VI","1"],["VN","84"],["VU","678"],["WF","681"],["WS","685"],["XK","383"],["YE","967"],["YT","262"],["ZA","27"],["ZM","260"],["ZW","263"]];
|
||||||
28
src/features/org-chart/icons.js
Normal file
28
src/features/org-chart/icons.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
function createOrgChartIcons({ environment }) {
|
||||||
|
// Lucide geometry; license included in src/vendor/lucide/LICENSE.
|
||||||
|
const paths = {
|
||||||
|
opportunities: ["M3 7h18v14H3Z", "M8 7V3h8v4", "M3 12h18", "M10 12v3h4v-3"],
|
||||||
|
contact: ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0", "M20 8v6", "M17 11h6"],
|
||||||
|
chart: ["M12 8v4", "M12 12H5v4", "M12 12h7v4"],
|
||||||
|
plus: ["M12 5v14", "M5 12h14"],
|
||||||
|
close: ["m6 6 12 12", "M18 6 6 18"], check: ["m9 12 2 2 4-4"],
|
||||||
|
down: ["m6 9 6 6 6-6"], up: ["m18 15-6-6-6 6"],
|
||||||
|
expand: ["m7 10 5-5 5 5", "m7 14 5 5 5-5"], collapse: ["m7 4 5 5 5-5", "m7 20 5-5 5 5"],
|
||||||
|
minus: ["M5 12h14"], fit: ["M8 3H5a2 2 0 0 0-2 2v3", "M16 3h3a2 2 0 0 1 2 2v3", "M21 16v3a2 2 0 0 1-2 2h-3", "M8 21H5a2 2 0 0 1-2-2v-3"],
|
||||||
|
save: ["M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12l4 4v12a2 2 0 0 1-2 2", "M7 21v-8h10v8", "M7 3v6h10V3"],
|
||||||
|
trash: ["M3 6h18", "M19 6v14H5V6", "M10 11v6", "M14 11v6", "M9 6V3h6v3"],
|
||||||
|
note: ["M12 3H5v18h14V10Z", "M12 3v7h7", "M8 15h8", "M8 18h5"]
|
||||||
|
};
|
||||||
|
function icon(name) {
|
||||||
|
const svg = environment.document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
for (const [key, value] of Object.entries({ viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.8", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false" })) svg.setAttribute(key, value);
|
||||||
|
for (const d of paths[name] || paths.chart) { const path = environment.document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", d); svg.append(path); }
|
||||||
|
if (name === "chart") for (const [x, y] of [[8, 2], [1, 16], [15, 16]]) {
|
||||||
|
const rect = environment.document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||||
|
for (const [key, value] of Object.entries({ x, y, width: 8, height: 6, rx: 1 })) rect.setAttribute(key, value);
|
||||||
|
svg.append(rect);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
return { icon };
|
||||||
|
}
|
||||||
63
src/features/org-chart/model.js
Normal file
63
src/features/org-chart/model.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
function createOrgChartModel() {
|
||||||
|
function normalize(nodes) {
|
||||||
|
if (!Array.isArray(nodes) || nodes.length > 500) throw new Error("An organization chart supports up to 500 people.");
|
||||||
|
const ids = new Set();
|
||||||
|
const result = nodes.map(node => {
|
||||||
|
if (!node || typeof node.id !== "string" || !/^[\w-]{1,80}$/.test(node.id) || ids.has(node.id)) throw new Error("Invalid or duplicate person ID.");
|
||||||
|
ids.add(node.id);
|
||||||
|
const item = { id: node.id, parentId: node.parentId == null ? null : node.parentId, collapsed: node.collapsed === true };
|
||||||
|
const jobTitle = node.jobTitle ?? node.role ?? "";
|
||||||
|
if (typeof jobTitle !== "string" || jobTitle.length > 160) throw new Error("Invalid Job Title.");
|
||||||
|
item.jobTitle = jobTitle.trim();
|
||||||
|
if (node.PartyNumber != null && node.PartyNumber !== "") {
|
||||||
|
if (!/^[A-Za-z0-9]{1,100}$/.test(String(node.PartyNumber))) throw new Error("Invalid contact PartyNumber.");
|
||||||
|
item.PartyNumber = String(node.PartyNumber);
|
||||||
|
}
|
||||||
|
if (node.contactPartyId != null) {
|
||||||
|
if (typeof node.contactPartyId !== "string" || !/^[\w-]{1,100}$/.test(node.contactPartyId)) throw new Error("Invalid contact ID.");
|
||||||
|
item.contactPartyId = node.contactPartyId;
|
||||||
|
}
|
||||||
|
for (const [key, max] of [["name", 160], ["notes", 4000]]) {
|
||||||
|
if (typeof node[key] !== "string" || node[key].length > max || (key === "name" && !node[key].trim())) throw new Error(`Enter a valid ${key} (up to ${max} characters).`);
|
||||||
|
item[key] = key === "notes" ? node[key] : node[key].trim();
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
const byId = new Map(result.map(node => [node.id, node]));
|
||||||
|
for (const node of result) {
|
||||||
|
const seen = new Set([node.id]);
|
||||||
|
let parent = node.parentId;
|
||||||
|
while (parent !== null) {
|
||||||
|
if (!byId.has(parent) || seen.has(parent)) throw new Error("The reporting structure contains a missing manager or a cycle.");
|
||||||
|
seen.add(parent);
|
||||||
|
if (seen.size > 60) throw new Error("The reporting structure supports up to 60 levels.");
|
||||||
|
parent = byId.get(parent).parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function descendants(nodes, id) {
|
||||||
|
const found = new Set([id]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const node of nodes) if (found.has(node.parentId) && !found.has(node.id)) { found.add(node.id); changed = true; }
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
function upsert(nodes, person) {
|
||||||
|
return normalize(nodes.some(node => node.id === person.id) ? nodes.map(node => node.id === person.id ? person : node) : [...nodes, person]);
|
||||||
|
}
|
||||||
|
function remove(nodes, id) {
|
||||||
|
const person = nodes.find(node => node.id === id);
|
||||||
|
if (!person) return nodes;
|
||||||
|
return normalize(nodes.filter(node => node.id !== id).map(node => node.parentId === id ? { ...node, parentId: person.parentId } : node));
|
||||||
|
}
|
||||||
|
function hierarchy(nodes) {
|
||||||
|
const root = { id: "", children: [] }, byId = new Map([[null, root]]);
|
||||||
|
nodes.forEach(node => byId.set(node.id, { ...node, children: [] }));
|
||||||
|
nodes.forEach(node => { if (!byId.get(node.parentId).collapsed) byId.get(node.parentId).children.push(byId.get(node.id)); });
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
return { normalize, descendants, upsert, remove, hierarchy };
|
||||||
|
}
|
||||||
68
src/features/org-chart/storage.js
Normal file
68
src/features/org-chart/storage.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
function createOrgChartStorage({ environment, model }) {
|
||||||
|
let opening = null;
|
||||||
|
function database() {
|
||||||
|
if (opening) return opening;
|
||||||
|
opening = new Promise((resolve, reject) => {
|
||||||
|
if (!environment.indexedDB) { reject(new Error("IndexedDB is unavailable. The organization chart cannot be saved locally.")); return; }
|
||||||
|
const request = environment.indexedDB.open("opportunities-extension-organizations", 3);
|
||||||
|
let blocked = false;
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
if (!request.result.objectStoreNames.contains("charts")) request.result.createObjectStore("charts", { keyPath: "key" });
|
||||||
|
else {
|
||||||
|
const cursor = request.transaction.objectStore("charts").openCursor();
|
||||||
|
cursor.onsuccess = () => {
|
||||||
|
const current = cursor.result;
|
||||||
|
if (!current) return;
|
||||||
|
const record = current.value;
|
||||||
|
if (Array.isArray(record.nodes)) {
|
||||||
|
record.nodes = record.nodes.map(node => {
|
||||||
|
const clean = { ...node, jobTitle: node.jobTitle ?? node.role ?? "" }; delete clean.role; delete clean.emailAddress; return clean;
|
||||||
|
});
|
||||||
|
current.update(record);
|
||||||
|
}
|
||||||
|
current.continue();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(new Error("Unable to open local organization charts."));
|
||||||
|
request.onblocked = () => { blocked = true; reject(new Error("Local storage is blocked by another window. Close it and retry.")); };
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (blocked) { db.close(); return; }
|
||||||
|
db.onversionchange = () => { db.close(); opening = null; };
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
}).catch(error => { opening = null; throw error; });
|
||||||
|
return opening;
|
||||||
|
}
|
||||||
|
function identity({ principal, customerId }) {
|
||||||
|
if (typeof principal !== "string" || !principal.trim() || principal.length > 254 || typeof customerId !== "string" || !/^[\w-]{1,100}$/.test(customerId)) throw new Error("A current user and customer ID are required.");
|
||||||
|
return JSON.stringify([principal.trim().toLowerCase(), customerId]);
|
||||||
|
}
|
||||||
|
async function transact(message, write) {
|
||||||
|
const key = identity(message);
|
||||||
|
const nodes = write ? model.normalize(message.nodes) : null;
|
||||||
|
if (write && (!Number.isSafeInteger(message.revision) || message.revision < 0)) throw new Error("Invalid organization chart revision.");
|
||||||
|
const db = await database();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction("charts", write ? "readwrite" : "readonly");
|
||||||
|
const store = transaction.objectStore("charts");
|
||||||
|
const request = store.get(key);
|
||||||
|
let result, failure;
|
||||||
|
request.onsuccess = () => {
|
||||||
|
try {
|
||||||
|
const existing = request.result;
|
||||||
|
if (existing && existing.schemaVersion !== 1) throw new Error("Unsupported organization chart version.");
|
||||||
|
if (write) {
|
||||||
|
if ((existing?.revision || 0) !== message.revision) throw new Error("This chart changed in another window. Reopen it before saving your changes.");
|
||||||
|
result = { key, schemaVersion: 1, revision: message.revision + 1, nodes, updatedAt: new Date().toISOString() };
|
||||||
|
store.put(result);
|
||||||
|
} else result = existing ? { ...existing, nodes: model.normalize(existing.nodes) } : { schemaVersion: 1, revision: 0, nodes: [], updatedAt: null };
|
||||||
|
} catch (error) { failure = error; transaction.abort(); }
|
||||||
|
};
|
||||||
|
transaction.oncomplete = () => resolve({ ok: true, chart: result });
|
||||||
|
transaction.onabort = transaction.onerror = () => reject(failure || new Error("Unable to save or read the organization chart. Check available browser storage and retry."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { load: message => transact(message, false), save: message => transact(message, true) };
|
||||||
|
}
|
||||||
104
src/features/org-chart/styles.css
Normal file
104
src/features/org-chart/styles.css
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
.oe-org-modal { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.oe-org-dialog { --org-bg: #fff; --org-canvas: #faf9f8; --org-border: #dedbd7; --org-muted: #625d58; --org-accent: #00758f; --org-selected: #e3f1f4; width: min(1500px, 100%); height: min(880px, calc(100dvh - 40px)); max-height: calc(100dvh - 40px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; color: #312d2a; background: var(--org-bg); }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-org-dialog { --org-bg: #292725; --org-canvas: #211f1d; --org-border: #625d58; --org-muted: #c9c3bd; --org-accent: #80d6e8; --org-selected: #203f46; color: #f5f4f2; }
|
||||||
|
.oe-org-modal [hidden] { display: none !important; }
|
||||||
|
.oe-org-dialog * { box-sizing: border-box; letter-spacing: 0; }
|
||||||
|
.oe-org-dialog > header, .oe-org-toolbar, .oe-org-footer { flex: none; }
|
||||||
|
.oe-org-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.oe-org-heading p { margin: 5px 0 0; font-size: 12px; color: var(--org-muted); }
|
||||||
|
.oe-org-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--org-border); }
|
||||||
|
.oe-org-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||||
|
.oe-org-count { color: var(--org-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.oe-org-zoom { width: 44px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||||
|
.oe-org-modal .oe-org-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 34px; min-height: 34px; padding: 6px 9px; border: 1px solid var(--org-border, #8f8983); border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
||||||
|
.oe-org-modal svg { width: 17px; height: 17px; flex: none; }
|
||||||
|
.oe-org-dialog button:hover:not(:disabled) { background: var(--org-selected); }
|
||||||
|
.oe-org-dialog :focus-visible { outline: 2px solid var(--org-accent); outline-offset: 2px; }
|
||||||
|
.oe-org-modal .oe-org-primary { background: #006879; border-color: #006879; color: #fff; }
|
||||||
|
.oe-org-dialog .oe-org-primary:hover:not(:disabled) { background: #005563; }
|
||||||
|
.oe-org-dialog :disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.oe-org-workspace { min-height: 0; min-width: 0; flex: 1; display: flex; overflow: hidden; }
|
||||||
|
.oe-org-viewport { position: relative; min-width: 0; min-height: 0; flex: 1; overflow: auto; background: var(--org-canvas); overscroll-behavior: contain; cursor: grab; }
|
||||||
|
.oe-org-sizing { position: relative; margin-inline: auto; }
|
||||||
|
.oe-org-canvas { position: relative; transform-origin: top left; }
|
||||||
|
.oe-org-canvas .oe-org-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||||||
|
.oe-org-lines path { stroke: var(--org-border); stroke-width: 2; fill: none; }
|
||||||
|
.oe-org-node { position: absolute; width: 236px; height: 146px; border: 1px solid var(--org-border); border-top: 3px solid var(--org-accent); border-radius: 4px; background: var(--org-bg); display: flex; flex-direction: column; cursor: default; }
|
||||||
|
.oe-org-node[data-selected="true"] { outline: 2px solid var(--org-accent); outline-offset: 2px; }
|
||||||
|
.oe-org-person { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 6px; text-align: left; padding: 9px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; border-radius: 0; width: 100%; }
|
||||||
|
.oe-org-person strong, .oe-org-person > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; line-height: 18px; }
|
||||||
|
.oe-org-person > span { color: var(--org-muted); font-size: 12px; }
|
||||||
|
.oe-org-node-actions { flex: none; display: flex; align-items: center; gap: 4px; padding: 4px 8px; height: 38px; border-top: 1px solid var(--org-border); }
|
||||||
|
.oe-org-node-actions .oe-org-button { min-height: 28px; height: 28px; min-width: 28px; padding: 3px; border: 0; }
|
||||||
|
.oe-org-node-actions [aria-expanded] { margin-left: auto; font-size: 11px; }
|
||||||
|
.oe-org-note-marker { display: inline-flex; color: var(--org-muted); margin-left: auto; }
|
||||||
|
.oe-org-editor { flex: 0 0 310px; width: 310px; padding: 16px; border-left: 1px solid var(--org-border); overflow-y: auto; background: var(--org-bg); }
|
||||||
|
.oe-org-editor-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.oe-org-editor h3 { margin: 0; font-size: 15px; }
|
||||||
|
.oe-org-form { display: grid; gap: 16px; }
|
||||||
|
.oe-org-field { display: grid; gap: 6px; font-size: 12px; font-weight: 600; min-width: 0; }
|
||||||
|
.oe-org-dialog input, .oe-org-dialog textarea { min-width: 0; width: 100%; font: inherit; font-weight: 400; line-height: 20px; color: inherit; background: var(--org-bg); border: 1px solid var(--org-muted); border-radius: 3px; padding: 7px 8px; }
|
||||||
|
.oe-org-dialog textarea { resize: vertical; min-height: 100px; max-height: 300px; }
|
||||||
|
.oe-org-form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
|
.oe-contact-name { position: relative; }
|
||||||
|
.oe-org-dialog .oe-contact-name > input { padding-right: 42px; }
|
||||||
|
.oe-org-modal .oe-contact-name > .oe-org-button { position: absolute; right: 2px; top: 2px; min-height: 30px; height: 30px; border: 0; }
|
||||||
|
.oe-org-dialog.oe-contact-dialog { width: min(680px, 100%); height: auto; padding: 20px; gap: 20px; overflow-y: auto; }
|
||||||
|
.oe-contact-dialog > header { margin: 0; }
|
||||||
|
.oe-org-dialog.oe-contact-opportunities { width: min(1200px, 100%); height: auto; padding: 20px; gap: 16px; }
|
||||||
|
.oe-contact-opportunities > header { margin: 0; }
|
||||||
|
.oe-contact-opportunities-table { overflow: auto; min-height: 0; }
|
||||||
|
.oe-contact-opportunities table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.oe-contact-opportunities th, .oe-contact-opportunities td { padding: 12px; text-align: left; border-bottom: 1px solid var(--org-border); }
|
||||||
|
.oe-contact-opportunities th { background: var(--org-canvas); white-space: nowrap; }
|
||||||
|
.oe-contact-opportunities .opportunities-extension-sort-button { white-space: nowrap; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-start; text-align: left; }
|
||||||
|
.oe-contact-opportunities .opportunities-extension-sort-button::after { right: 0; }
|
||||||
|
.oe-contact-opportunities th:nth-child(5), .oe-contact-opportunities td:nth-child(5) { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.oe-contact-opportunities th:nth-child(5) .opportunities-extension-sort-button { padding: 4px 0 4px 18px; justify-content: flex-end; text-align: right; }
|
||||||
|
.oe-contact-opportunities th:nth-child(5) .opportunities-extension-sort-button::after { left: 0; right: auto; }
|
||||||
|
.oe-contact-opportunities td:nth-child(5) .oe-contact-skeleton { margin-left: auto; }
|
||||||
|
.oe-contact-opportunities .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
|
||||||
|
.oe-contact-opportunities table { min-width: 900px; }
|
||||||
|
.oe-contact-opportunities a { color: var(--org-accent); }
|
||||||
|
.oe-contact-skeleton { display: block; height: 14px; min-width: 24px; border-radius: 4px; background: var(--org-border); animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
|
||||||
|
.oe-contact-skeleton-total { width: 110px; }
|
||||||
|
.oe-contact-opportunities table[aria-busy="true"] { table-layout: fixed; }
|
||||||
|
.oe-contact-opportunities table[aria-busy="true"] th:nth-child(3) { width: 28%; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .oe-contact-skeleton { animation: none; } }
|
||||||
|
.oe-contact-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-contact-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||||
|
.oe-contact-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.oe-contact-phone { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; border: 1px solid var(--org-border); border-radius: 4px; padding: 12px; min-width: 0; margin: 0; }
|
||||||
|
.oe-contact-form legend { font-weight: 600; padding: 0 4px; }
|
||||||
|
.oe-contact-primary { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 20px; border: 0; padding: 0; margin: 0; }
|
||||||
|
.oe-contact-primary label { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||||
|
.oe-contact-primary input { width: auto; accent-color: var(--org-accent); }
|
||||||
|
@media (max-width: 520px) { .oe-contact-form, .oe-contact-phone { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.oe-org-dialog .oe-org-danger, .oe-org-error { color: #b3261e; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-org-dialog .oe-org-danger, .opportunities-extension-modal[data-theme="dark"] .oe-org-error { color: #ffaaa4; }
|
||||||
|
.oe-org-footer { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--org-border); }
|
||||||
|
.oe-org-status { margin: 0 auto 0 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.oe-org-footer .oe-org-button { flex: none; }
|
||||||
|
.oe-org-empty { display: grid; place-content: center; justify-items: center; height: 100%; min-height: 180px; gap: 12px; }
|
||||||
|
.oe-org-empty > svg { width: 40px; height: 40px; color: var(--org-accent); }
|
||||||
|
.oe-org-empty h3 { margin: 0; font-size: 16px; }
|
||||||
|
.oe-org-tooltip { position: fixed; z-index: 20; max-width: min(360px, calc(100vw - 24px)); max-height: min(260px, 45dvh); overflow-y: auto; padding: 12px; background: #fff; color: #312d2a; border: 1px solid #8f8983; border-radius: 4px; box-shadow: 0 3px 12px #0002; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 18px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-org-tooltip { background: #35322f; color: #f5f4f2; }
|
||||||
|
.oe-org-tooltip { pointer-events: none; }
|
||||||
|
.oe-org-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; z-index: 30; padding: 16px; }
|
||||||
|
.oe-org-confirm-panel { width: min(430px, 100%); background: #fff; color: #312d2a; border: 1px solid #8f8983; border-radius: 6px; padding: 24px; overflow-wrap: anywhere; }
|
||||||
|
.oe-org-confirm-panel h3 { margin: 0 0 12px; font-size: 18px; }
|
||||||
|
.oe-org-confirm-panel p { font-size: 13px; line-height: 20px; margin: 0 0 24px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel { background: #292725; color: #f5f4f2; }
|
||||||
|
.oe-org-skeleton { width: 236px; height: 146px; margin: 28px; border-radius: 4px; background: var(--org-border); animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .oe-org-skeleton { animation: none; } }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.oe-org-modal { padding: 8px; }
|
||||||
|
.oe-org-dialog { height: calc(100dvh - 16px); max-height: calc(100dvh - 16px); }
|
||||||
|
.oe-org-workspace { flex-direction: column; }
|
||||||
|
.oe-org-editor { flex: 0 1 55%; width: 100%; border-left: 0; border-top: 1px solid var(--org-border); }
|
||||||
|
.oe-org-viewport { min-height: 140px; }
|
||||||
|
.oe-org-toolbar { padding: 8px; gap: 6px; }
|
||||||
|
.oe-org-tools { gap: 2px; }
|
||||||
|
.oe-org-footer { padding: 8px; }
|
||||||
|
}
|
||||||
600
src/features/org-chart/view.js
Normal file
600
src/features/org-chart/view.js
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
function createOrgChartView({ environment, tableSorting, model, loadLayout, icon, searchableSelect, request, requestQueryAccountContact, requestPersistContact, requestOpportunitiesByContact, getPrincipal, modalId, notify }) {
|
||||||
|
const { document } = environment;
|
||||||
|
let active = null;
|
||||||
|
const nodeWidth = 236, nodeHeight = 146;
|
||||||
|
function el(tag, className = "", text) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
element.className = className;
|
||||||
|
if (text !== undefined) element.textContent = text;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function button(label, symbol, action, text = false) {
|
||||||
|
const item = el("button", "oe-org-button");
|
||||||
|
item.type = "button"; item.title = label; item.setAttribute("aria-label", label);
|
||||||
|
item.append(icon(symbol));
|
||||||
|
if (text) item.append(el("span", "", label));
|
||||||
|
item.addEventListener("click", action);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
function createButton(opportunity) {
|
||||||
|
const item = button("View customer organization chart", "chart", () => open(opportunity, item));
|
||||||
|
item.className = "opportunities-extension-action-button"; item.setAttribute("aria-haspopup", "dialog");
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
function close(force = false) {
|
||||||
|
if (!active) return false;
|
||||||
|
const entry = active;
|
||||||
|
if (entry.saving) return true;
|
||||||
|
if (!force && entry.hasChanges()) {
|
||||||
|
entry.confirm("Discard unsaved changes?", "Your organization chart has unsaved changes.", "Discard changes", () => close(true));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
entry.contactCleanup?.();
|
||||||
|
active = null; entry.edit?.nameCombo.destroy(); entry.observer?.disconnect(); entry.modal.remove();
|
||||||
|
entry.siblings.forEach(([element, inert]) => { element.inert = inert; });
|
||||||
|
if (entry.trigger?.isConnected) entry.trigger.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function open(opportunity, trigger) {
|
||||||
|
if (active) return;
|
||||||
|
const host = document.getElementById(modalId);
|
||||||
|
if (!host || host.hidden) return;
|
||||||
|
const customer = opportunity.CustomerAccount || {};
|
||||||
|
const customerId = String(customer.PartyId || customer.PartyNumber || ""), principal = getPrincipal();
|
||||||
|
const modal = el("section", "opportunities-extension-action-form-modal oe-org-modal");
|
||||||
|
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "oe-org-title"); modal.setAttribute("lang", "en");
|
||||||
|
const dialog = el("div", "opportunities-extension-action-form-dialog oe-org-dialog");
|
||||||
|
const header = el("header", "opportunities-extension-account-plans-header"), heading = el("div", "oe-org-heading");
|
||||||
|
const title = el("h2", "", "Organization chart"); title.id = "oe-org-title";
|
||||||
|
heading.append(title, el("p", "", customer.PartyUniqueName || "Customer"));
|
||||||
|
const dismiss = button("Close organization chart", "close", () => close());
|
||||||
|
dismiss.className = "opportunities-extension-icon-button"; header.append(heading, dismiss);
|
||||||
|
const toolbar = el("div", "oe-org-toolbar"), tools = el("div", "oe-org-tools"), count = el("span", "oe-org-count");
|
||||||
|
const add = button("Add person", "plus", () => guardEditor(() => editPerson(null)), true);
|
||||||
|
const expand = button("Expand all", "expand", () => foldAll(false)), collapse = button("Collapse all", "collapse", () => foldAll(true));
|
||||||
|
const zoomOut = button("Zoom out", "minus", () => setZoom(entry.zoom - .1)), zoomIn = button("Zoom in", "plus", () => setZoom(entry.zoom + .1));
|
||||||
|
const zoomValue = el("output", "oe-org-zoom", "100%"); zoomValue.setAttribute("aria-label", "Zoom level");
|
||||||
|
const fitButton = button("Fit chart to view", "fit", fit);
|
||||||
|
tools.append(expand, collapse, zoomOut, zoomValue, zoomIn, fitButton); toolbar.append(add, count, tools);
|
||||||
|
const workspace = el("div", "oe-org-workspace"), viewport = el("div", "oe-org-viewport"); viewport.tabIndex = 0;
|
||||||
|
viewport.setAttribute("role", "region"); viewport.setAttribute("aria-label", "Organization chart canvas");
|
||||||
|
const sizing = el("div", "oe-org-sizing"), canvas = el("div", "oe-org-canvas"); sizing.append(canvas); viewport.append(sizing);
|
||||||
|
const empty = el("div", "oe-org-empty"); empty.append(icon("chart"), el("h3", "", "No people yet"), button("Add first person", "plus", () => editPerson(null), true)); viewport.append(empty);
|
||||||
|
const editor = el("aside", "oe-org-editor"); editor.hidden = true; editor.setAttribute("aria-label", "Person details"); workspace.append(viewport, editor);
|
||||||
|
const status = el("p", "oe-org-status", "Loading organization chart..."); status.setAttribute("role", "status");
|
||||||
|
const retry = button("Retry loading", "plus", load, true); retry.hidden = true;
|
||||||
|
const footer = el("footer", "oe-org-footer"), save = button("Save chart", "save", saveChart, true); save.classList.add("oe-org-primary");
|
||||||
|
footer.append(status, retry, save); dialog.append(header, toolbar, workspace, footer); modal.append(dialog);
|
||||||
|
const tooltip = el("div", "oe-org-tooltip"); tooltip.hidden = true; tooltip.id = "oe-org-notes-tooltip"; tooltip.setAttribute("role", "tooltip"); modal.append(tooltip);
|
||||||
|
const siblings = Array.from(host.children).map(element => [element, element.inert]); siblings.forEach(([element]) => { element.inert = true; }); host.append(modal);
|
||||||
|
const entry = { modal, trigger, siblings, nodes: [], baseline: "[]", revision: 0, ready: false, saving: false,
|
||||||
|
zoom: 1, autoFit: true, width: 1, height: 1, selected: null, edit: null, confirmation: null, contacts: [], details: new Map() };
|
||||||
|
entry.hasChanges = () => JSON.stringify(entry.nodes) !== entry.baseline || editorDirty();
|
||||||
|
entry.confirm = confirm; active = entry;
|
||||||
|
function message(text, error = false) {
|
||||||
|
status.textContent = text; status.setAttribute("role", error ? "alert" : "status"); status.classList.toggle("oe-org-error", error);
|
||||||
|
}
|
||||||
|
function updateControls() {
|
||||||
|
const blocked = !entry.ready || entry.saving;
|
||||||
|
for (const control of [add, expand, collapse, zoomOut, zoomIn, fitButton]) control.disabled = blocked || (control !== add && !entry.nodes.length);
|
||||||
|
save.disabled = blocked || !entry.hasChanges(); dismiss.disabled = entry.saving;
|
||||||
|
toolbar.inert = workspace.inert = entry.saving;
|
||||||
|
}
|
||||||
|
function changed() { message("Unsaved changes"); updateControls(); }
|
||||||
|
function editorValue() {
|
||||||
|
if (!entry.edit) return null;
|
||||||
|
const { fields, person } = entry.edit;
|
||||||
|
const contact = entry.contacts.find(item => item.PartyId === fields.name.value);
|
||||||
|
return { ...person, ...(contact ? { contactPartyId: contact.PartyId, PartyNumber: contact.PartyNumber || (contact.PartyId === person.contactPartyId ? person.PartyNumber : undefined) } : {}),
|
||||||
|
name: contact ? contact.PartyName : fields.name.value === "__saved" ? person.name : "",
|
||||||
|
jobTitle: contact ? (typeof contact.JobTitle === "string" ? contact.JobTitle.trim() : "") : fields.name.value === "__saved" ? person.jobTitle || "" : "",
|
||||||
|
notes: fields.notes.value, parentId: fields.parent.value === "__root" ? null : fields.parent.value };
|
||||||
|
}
|
||||||
|
function editorDirty() { return !!entry.edit && JSON.stringify(editorValue()) !== entry.edit.baseline; }
|
||||||
|
function guardEditor(action) {
|
||||||
|
if (entry.saving) return;
|
||||||
|
if (editorDirty()) confirm("Discard person edits?", "This person has unapplied changes.", "Discard edits", () => { entry.edit?.nameCombo.destroy(); entry.edit = null; editor.hidden = true; action(); });
|
||||||
|
else action();
|
||||||
|
}
|
||||||
|
function confirm(titleText, bodyText, label, action) {
|
||||||
|
if (entry.confirmation) return;
|
||||||
|
const overlay = el("div", "oe-org-confirm"), panel = el("section", "oe-org-confirm-panel");
|
||||||
|
panel.setAttribute("role", "alertdialog"); panel.setAttribute("aria-modal", "true");
|
||||||
|
panel.setAttribute("aria-labelledby", "oe-org-confirm-title"); panel.setAttribute("aria-describedby", "oe-org-confirm-body");
|
||||||
|
const heading = el("h3", "", titleText); heading.id = "oe-org-confirm-title";
|
||||||
|
const body = el("p", "", bodyText); body.id = "oe-org-confirm-body";
|
||||||
|
const actions = el("div", "oe-org-form-actions"), previous = document.activeElement;
|
||||||
|
function finish() { entry.confirmation = null; overlay.remove(); dialog.inert = false; previous?.focus(); }
|
||||||
|
const cancel = button("Keep editing", "close", finish, true), proceed = button(label, "check", () => { finish(); action(); }, true);
|
||||||
|
actions.append(cancel, proceed); panel.append(heading, body, actions); overlay.append(panel); modal.append(overlay);
|
||||||
|
entry.confirmation = overlay; dialog.inert = true; tooltip.hidden = true;
|
||||||
|
overlay.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(); }
|
||||||
|
if (event.key === "Tab") { event.preventDefault(); (document.activeElement === cancel ? proceed : cancel).focus(); }
|
||||||
|
}); cancel.focus();
|
||||||
|
}
|
||||||
|
function discardEditor() {
|
||||||
|
entry.edit?.nameCombo.destroy(); entry.edit = null; editor.hidden = true; entry.selected = null; render(); updateControls();
|
||||||
|
}
|
||||||
|
function deletePerson(person) {
|
||||||
|
confirm("Delete person?", "Delete " + person.name + "? Direct reports will move to this person's manager.", "Delete person", () => {
|
||||||
|
entry.nodes = model.remove(entry.nodes, person.id);
|
||||||
|
entry.edit?.nameCombo.destroy(); entry.edit = null; entry.selected = null; editor.hidden = true;
|
||||||
|
changed(); render(); add.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function showContactOpportunities(person, trigger) {
|
||||||
|
if (entry.confirmation) return;
|
||||||
|
person = entry.nodes.find(node => node.id === person.id) || person;
|
||||||
|
const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-opportunities");
|
||||||
|
panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-contact-opportunities-title");
|
||||||
|
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities | " + person.name); title.id = "oe-contact-opportunities-title";
|
||||||
|
let closed = false, offset = 0, partyNumber = person.PartyNumber;
|
||||||
|
const current = () => !closed && active === entry;
|
||||||
|
function finish() { closed = true; overlay.remove(); entry.confirmation = null; entry.contactCleanup = null; dialog.inert = false; trigger.focus(); }
|
||||||
|
header.append(title, button("Close opportunities", "close", finish));
|
||||||
|
const status = el("p", "oe-org-status"), surface = el("div", "oe-contact-opportunities-table"), table = el("table"), head = el("thead"), row = el("tr"), body = el("tbody");
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
table.setAttribute("data-table-layout", "standard");
|
||||||
|
const columns = [
|
||||||
|
{ key: "OptyNumber", label: "OptyNumber" }, { key: "WinProb", label: "Win Probability", type: "number" },
|
||||||
|
{ key: "Name", label: "Name" }, { key: "CurrencyCode", label: "CurrencyCode" },
|
||||||
|
{ key: "Revenue", label: "Amount", type: "number" }, { key: "EffectiveDate", label: "Close date", type: "date" }, { key: "StatusCode", label: "StatusCode" }
|
||||||
|
];
|
||||||
|
const sorting = tableSorting.create("contact-opportunities", columns);
|
||||||
|
let allItems = [], loading = false;
|
||||||
|
for (const column of columns) { const th = el("th"); th.scope = "col"; if (column.key === "Revenue") th.setAttribute("data-align", "money"); sorting.header(th, column.key, column.label, renderRows); row.append(th); }
|
||||||
|
head.append(row); table.append(head, body); surface.append(table);
|
||||||
|
const footer = el("footer", "oe-org-form-actions");
|
||||||
|
const retry = button("Retry", "plus", () => load(), true);
|
||||||
|
footer.append(retry); panel.append(header, status, surface, footer); overlay.append(panel); modal.append(overlay);
|
||||||
|
entry.confirmation = overlay; entry.contactCleanup = finish; dialog.inert = true; tooltip.hidden = true;
|
||||||
|
overlay.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [...panel.querySelectorAll("button:not(:disabled), a")].filter(item => item.getClientRects().length);
|
||||||
|
const first = controls[0], last = controls.at(-1);
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function renderRows() {
|
||||||
|
body.replaceChildren();
|
||||||
|
table.setAttribute("aria-busy", String(loading));
|
||||||
|
for (const button of head.querySelectorAll("button")) button.disabled = loading;
|
||||||
|
if (loading) {
|
||||||
|
for (let index = 0; index < 5; index++) {
|
||||||
|
const row = el("tr"); row.setAttribute("aria-hidden", "true");
|
||||||
|
for (const column of columns) {
|
||||||
|
const cell = el("td"), bar = el("span", "oe-contact-skeleton");
|
||||||
|
if (column.key === "Revenue") cell.setAttribute("data-align", "money");
|
||||||
|
bar.style.width = column.key === "Name" ? "90%" : column.key === "WinProb" || column.key === "StatusCode" ? "45%" : "70%";
|
||||||
|
cell.append(bar); row.append(cell);
|
||||||
|
}
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of sorting.select(allItems)) {
|
||||||
|
const row = el("tr");
|
||||||
|
for (const key of ["OptyNumber", "WinProb", "Name", "CurrencyCode", "Revenue", "EffectiveDate", "StatusCode"]) {
|
||||||
|
const cell = el("td"); if (key === "Revenue") cell.setAttribute("data-align", "money"); let value = item[key] ?? "—";
|
||||||
|
if (key === "Name" && item.OptyNumber) {
|
||||||
|
const link = el("a", "", String(value)); link.href = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=" + encodeURIComponent(item.OptyNumber); link.target = "_blank"; link.rel = "noopener noreferrer"; cell.append(link);
|
||||||
|
} else {
|
||||||
|
if (key === "WinProb" && item[key] != null) value += "%";
|
||||||
|
if (key === "Revenue" && item[key] != null && Number.isFinite(Number(value))) value = Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
if (key === "EffectiveDate" && /^\d{4}-\d{2}-\d{2}/.test(String(value))) value = String(value).slice(0, 10).split("-").reverse().join("/");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
}
|
||||||
|
row.append(cell);
|
||||||
|
}
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
offset = 0; retry.hidden = true; status.classList.remove("oe-org-error"); status.textContent = "";
|
||||||
|
status.setAttribute("aria-label", "Loading opportunities");
|
||||||
|
const totalSkeleton = el("span", "oe-contact-skeleton oe-contact-skeleton-total"); totalSkeleton.setAttribute("aria-hidden", "true"); status.append(totalSkeleton);
|
||||||
|
let loaded = 0; allItems = []; loading = true; renderRows();
|
||||||
|
try {
|
||||||
|
while (current()) {
|
||||||
|
const data = await requestOpportunitiesByContact(partyNumber, offset, { contactPartyId: person.contactPartyId, contactName: person.name, isActive: current, async resolvePartyNumber() {
|
||||||
|
// Read the saved person, never replace a persisted identifier with a search result.
|
||||||
|
const chart = await request("load", { principal, customerId });
|
||||||
|
if (!current()) return;
|
||||||
|
const saved = chart.nodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId);
|
||||||
|
partyNumber = saved?.PartyNumber;
|
||||||
|
if (!partyNumber) throw new Error("This person has no PartyNumber saved in the organization chart. Open Person details to retrieve and save it before viewing opportunities.");
|
||||||
|
const currentPerson = entry.nodes.find(node => node.id === person.id);
|
||||||
|
if (currentPerson) entry.nodes = model.upsert(entry.nodes, { ...currentPerson, PartyNumber: partyNumber });
|
||||||
|
return partyNumber;
|
||||||
|
} });
|
||||||
|
if (!current()) return;
|
||||||
|
allItems.push(...data.items);
|
||||||
|
loaded += data.items.length;
|
||||||
|
if (!data.hasMore) { status.textContent = loaded ? `${loaded} opportunities` : "No opportunities found."; break; }
|
||||||
|
const count = Number(data.count ?? data.items.length), responseOffset = Number(data.offset ?? offset);
|
||||||
|
const nextOffset = responseOffset + count;
|
||||||
|
if (!data.items.length || !Number.isSafeInteger(nextOffset) || count <= 0 || nextOffset <= offset) throw new Error("The API returned hasMore without advancing the page. Please retry.");
|
||||||
|
offset = nextOffset;
|
||||||
|
status.setAttribute("aria-label", `Loading opportunities: ${loaded} loaded`);
|
||||||
|
}
|
||||||
|
} catch (error) { if (current()) { status.textContent = error.message; status.classList.add("oe-org-error"); retry.hidden = false; } }
|
||||||
|
finally { if (current()) { loading = false; status.removeAttribute("aria-label"); renderRows(); } }
|
||||||
|
}
|
||||||
|
header.querySelector("button").focus(); load();
|
||||||
|
}
|
||||||
|
function createContact(trigger) {
|
||||||
|
if (entry.confirmation) return;
|
||||||
|
const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-dialog");
|
||||||
|
panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true");
|
||||||
|
panel.setAttribute("aria-labelledby", "oe-contact-title");
|
||||||
|
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Create Contact"); title.id = "oe-contact-title";
|
||||||
|
const form = el("form", "oe-contact-form"), combos = [];
|
||||||
|
form.noValidate = true;
|
||||||
|
let validationShown = false;
|
||||||
|
let saving = false;
|
||||||
|
function finish() {
|
||||||
|
if (saving) return;
|
||||||
|
combos.forEach(combo => combo.destroy()); overlay.remove(); entry.confirmation = null;
|
||||||
|
entry.contactCleanup = null; dialog.inert = false; trigger.focus();
|
||||||
|
}
|
||||||
|
header.append(title, button("Close Create Contact", "close", finish));
|
||||||
|
for (const [label, type, autocomplete] of [["First Name", "text", "given-name"], ["Last Name", "text", "family-name"], ["Job Title", "text", "organization-title"], ["Email Address", "email", "email"]]) {
|
||||||
|
const field = el("label", "oe-org-field", label), input = el("input");
|
||||||
|
input.type = type; input.autocomplete = autocomplete; input.name = autocomplete;
|
||||||
|
if (["given-name", "family-name", "email"].includes(autocomplete)) { input.required = true; field.append(document.createTextNode(" (required)")); }
|
||||||
|
else field.append(document.createTextNode(" (optional)"));
|
||||||
|
input.setAttribute("aria-label", label); field.append(input); form.append(field);
|
||||||
|
}
|
||||||
|
function consent(parent, name, label, verb) {
|
||||||
|
const group = el("fieldset", "oe-contact-primary"); group.append(el("legend", "", label));
|
||||||
|
for (const [value, text] of [["none", "None"], ["deny", "Do Not " + verb], ["allow", "Ok to " + verb]]) {
|
||||||
|
const field = el("label"), radio = el("input"); radio.type = "radio"; radio.name = name; radio.value = value; radio.checked = value === "none";
|
||||||
|
field.append(radio, document.createTextNode(text)); group.append(field);
|
||||||
|
}
|
||||||
|
parent.append(group);
|
||||||
|
}
|
||||||
|
consent(form, "emailConsent", "Email preference", "Email");
|
||||||
|
const names = new Intl.DisplayNames(["en"], { type: "region" });
|
||||||
|
const countries = orgChartCallingCodes.map(([code, dial]) => ({ code, dial, name: names.of(code) })).sort((a, b) => a.name.localeCompare(b.name, "en"));
|
||||||
|
for (const label of ["Work Phone", "Mobile Phone"]) {
|
||||||
|
const group = el("fieldset", "oe-contact-phone"), legend = el("legend", "", label);
|
||||||
|
const countryField = el("label", "oe-org-field", "Country code"), select = el("select");
|
||||||
|
const placeholder = el("option", "", "Select country code"); placeholder.value = ""; select.append(placeholder);
|
||||||
|
for (const country of countries) {
|
||||||
|
const option = el("option", "", country.code + " · " + country.name + " (+" + country.dial + ")");
|
||||||
|
option.value = country.code; option.dataset.callingCode = country.dial; select.append(option);
|
||||||
|
}
|
||||||
|
select.name = label === "Work Phone" ? "workCountry" : "mobileCountry";
|
||||||
|
select.id = "oe-contact-" + select.name;
|
||||||
|
countryField.append(select);
|
||||||
|
const numberField = el("label", "oe-org-field", "Phone number"), input = el("input");
|
||||||
|
input.type = "tel"; input.autocomplete = "tel-national"; input.name = label === "Work Phone" ? "workPhone" : "mobilePhone";
|
||||||
|
input.setAttribute("aria-label", label); numberField.append(input);
|
||||||
|
group.append(legend, countryField, numberField); form.append(group);
|
||||||
|
if (label === "Work Phone") {
|
||||||
|
const extension = el("label", "oe-org-field", "Extension (optional)"), input = el("input"); input.name = "workExtension"; input.type = "tel"; extension.append(input); group.append(extension);
|
||||||
|
}
|
||||||
|
consent(group, label === "Work Phone" ? "workConsent" : "mobileConsent", "Contact preference", "Call");
|
||||||
|
function validatePhone() {
|
||||||
|
input.setCustomValidity(select.value && !input.value.trim() ? "Enter the phone number." : "");
|
||||||
|
combo.input.setCustomValidity(input.value.trim() && !select.value ? "Select a country code." : "");
|
||||||
|
}
|
||||||
|
const combo = searchableSelect(select, countryField, validatePhone, { floating: true, listWidth: 390, listMaxHeight: 260 });
|
||||||
|
input.addEventListener("input", validatePhone);
|
||||||
|
combo.input.setAttribute("aria-label", label + " country code"); combos.push(combo);
|
||||||
|
}
|
||||||
|
const result = el("p", "oe-org-status"); result.setAttribute("role", "status");
|
||||||
|
result.id = "oe-contact-validation";
|
||||||
|
function validateContact(focus = false) {
|
||||||
|
const invalid = [];
|
||||||
|
for (const input of form.querySelectorAll("input:not([type=radio])")) {
|
||||||
|
const valid = input.validity.valid && (!input.required || !!input.value.trim());
|
||||||
|
input.setAttribute("aria-invalid", String(!valid));
|
||||||
|
if (!valid) { input.setAttribute("aria-describedby", result.id); invalid.push(input); }
|
||||||
|
else input.removeAttribute("aria-describedby");
|
||||||
|
}
|
||||||
|
result.classList.toggle("oe-org-error", invalid.length > 0);
|
||||||
|
result.setAttribute("role", invalid.length ? "alert" : "status");
|
||||||
|
result.textContent = invalid.length ? "Complete the highlighted required fields with valid values." : "";
|
||||||
|
if (focus) invalid[0]?.focus();
|
||||||
|
return !invalid.length;
|
||||||
|
}
|
||||||
|
form.addEventListener("input", () => { if (validationShown && !saving) validateContact(); });
|
||||||
|
form.addEventListener("change", () => { if (validationShown && !saving) validateContact(); });
|
||||||
|
const actions = el("footer", "oe-org-form-actions"), saveContact = button("Save", "save", () => form.requestSubmit(), true);
|
||||||
|
saveContact.classList.add("oe-org-primary"); actions.append(button("Cancel", "close", finish, true), saveContact);
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault(); if (saving) return;
|
||||||
|
validationShown = true; if (!validateContact(true)) return;
|
||||||
|
const value = name => form.elements.namedItem(name)?.value || "";
|
||||||
|
const country = name => form.elements.namedItem(name).selectedOptions[0]?.dataset.callingCode || "";
|
||||||
|
const values = { firstName: value("given-name"), lastName: value("family-name"), jobTitle: value("organization-title"), email: value("email"),
|
||||||
|
emailConsent: value("emailConsent"), workConsent: value("workConsent"), mobileConsent: value("mobileConsent"),
|
||||||
|
workPhone: value("workPhone"), mobilePhone: value("mobilePhone"), workCountry: country("workCountry"), mobileCountry: country("mobileCountry"), workExtension: value("workExtension") };
|
||||||
|
saving = true; saveContact.disabled = true; form.inert = true; result.classList.remove("oe-org-error"); result.textContent = "Saving contact…";
|
||||||
|
try {
|
||||||
|
const data = await requestPersistContact(customer, values);
|
||||||
|
saving = false;
|
||||||
|
if (active !== entry) return;
|
||||||
|
finish(); notify("Contact created successfully.");
|
||||||
|
if (entry.edit && data?.PartyId != null) {
|
||||||
|
const contact = { PartyId: String(data.PartyId), PartyNumber: data.PartyNumber, PartyName: data.PartyName || [values.firstName, values.lastName].join(" "), JobTitle: data.JobTitle ?? values.jobTitle,
|
||||||
|
PrimaryEmail: { EmailAddress: data.EmailAddress ?? values.email } };
|
||||||
|
entry.contacts.push(contact);
|
||||||
|
const option = el("option", "", contact.PartyName); option.value = contact.PartyId;
|
||||||
|
entry.edit.fields.name.append(option); entry.edit.fields.name.value = contact.PartyId; entry.edit.nameCombo.sync(); showContactDetails(entry.edit, contact); updateControls();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (active === entry) { result.textContent = error.message || "Unable to create contact."; result.classList.add("oe-org-error"); notify(result.textContent, true); }
|
||||||
|
} finally { saving = false; saveContact.disabled = false; form.inert = false; }
|
||||||
|
});
|
||||||
|
panel.append(header, form, result, actions); overlay.append(panel); modal.append(overlay);
|
||||||
|
entry.confirmation = overlay; entry.contactCleanup = finish; dialog.inert = true; tooltip.hidden = true;
|
||||||
|
overlay.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); finish(); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [...panel.querySelectorAll("button, input")].filter(item => !item.disabled && item.getClientRects().length);
|
||||||
|
const first = controls[0], last = controls.at(-1);
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
form.querySelector("input").focus();
|
||||||
|
}
|
||||||
|
function editPerson(person, parentId = null) {
|
||||||
|
entry.edit?.nameCombo.destroy();
|
||||||
|
tooltip.hidden = true;
|
||||||
|
const existing = person || { id: environment.crypto.randomUUID(), parentId, name: "", notes: "", collapsed: false };
|
||||||
|
entry.selected = person?.id || null; editor.replaceChildren(); editor.hidden = false;
|
||||||
|
const editorHeader = el("div", "oe-org-editor-header");
|
||||||
|
editorHeader.append(el("h3", "", person ? "Person details" : "Add person"), button("Close person details", "close", () => guardEditor(discardEditor)));
|
||||||
|
const form = el("form", "oe-org-form"), fields = {};
|
||||||
|
const nameField = el("label", "oe-org-field", "Name *"), nameSelect = el("select");
|
||||||
|
nameSelect.id = "oe-org-name"; nameSelect.required = true; fields.name = nameSelect; nameField.append(nameSelect); form.append(nameField);
|
||||||
|
const placeholder = el("option", "", "Type to search contacts"); placeholder.value = ""; nameSelect.append(placeholder);
|
||||||
|
if (existing.name) { const saved = el("option", "", existing.name); saved.value = "__saved"; nameSelect.append(saved); nameSelect.value = "__saved"; }
|
||||||
|
const nameCombo = searchableSelect(nameSelect, nameField, () => {
|
||||||
|
nameCombo.input.setCustomValidity(""); entry.edit.detailsVersion++;
|
||||||
|
const contact = entry.contacts.find(item => item.PartyId === fields.name.value);
|
||||||
|
showContactDetails(entry.edit, contact); updateControls();
|
||||||
|
}, {
|
||||||
|
floating: true, listMaxHeight: 280, searchDelay: 350,
|
||||||
|
async search(keywords, isCurrent) {
|
||||||
|
const contacts = await requestQueryAccountContact(customer.PartyId, keywords, () => active === entry && isCurrent());
|
||||||
|
if (!isCurrent() || active !== entry) return [];
|
||||||
|
entry.contacts = contacts;
|
||||||
|
return contacts.map(contact => ({ value: contact.PartyId, label: contact.PartyName,
|
||||||
|
description: contact.JobTitle || "" }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nameCombo.input.setAttribute("aria-label", "Name");
|
||||||
|
const create = button("Create Contact", "contact", event => { event.preventDefault(); createContact(create); });
|
||||||
|
create.setAttribute("aria-haspopup", "dialog");
|
||||||
|
const wrapper = nameCombo.input.parentElement; wrapper.classList.add("oe-contact-name"); wrapper.append(create);
|
||||||
|
for (const [key, label, max] of [["role", "Job Title", 160], ["emailAddress", "Email address", 320], ["notes", "Notes", 4000]]) {
|
||||||
|
const field = el("label", "oe-org-field", label + (key === "notes" ? " (optional)" : " (from contact)"));
|
||||||
|
const input = el(key === "notes" ? "textarea" : "input"); input.id = "oe-org-" + key; input.value = (key === "role" ? existing.jobTitle : existing[key]) || ""; input.maxLength = max;
|
||||||
|
input.setAttribute("aria-label", label); if (key === "notes") input.rows = 7;
|
||||||
|
else { input.readOnly = true; input.placeholder = "Not provided"; }
|
||||||
|
field.append(input); fields[key] = input; form.append(field);
|
||||||
|
input.addEventListener("input", () => { input.setCustomValidity(""); updateControls(); });
|
||||||
|
}
|
||||||
|
const detailStatus = el("p", "oe-org-status"); detailStatus.setAttribute("role", "status");
|
||||||
|
const detailRetry = button("Retry contact details", "plus", () => loadPersonDetails(entry.edit), true); detailRetry.hidden = true;
|
||||||
|
form.append(detailStatus, detailRetry);
|
||||||
|
const reports = el("label", "oe-org-field", "Reports to"), select = el("select"); select.id = "oe-org-parent";
|
||||||
|
const top = el("option", "", "No manager (top level)"); top.value = "__root"; select.append(top);
|
||||||
|
const excluded = person ? model.descendants(entry.nodes, person.id) : new Set();
|
||||||
|
for (const candidate of entry.nodes.filter(item => !excluded.has(item.id))) {
|
||||||
|
const title = candidate.jobTitle;
|
||||||
|
const option = el("option", "", candidate.name + (title ? " - " + title : "")); option.value = candidate.id; select.append(option);
|
||||||
|
}
|
||||||
|
select.value = existing.parentId || "__root"; fields.parent = select; reports.append(select); form.append(reports);
|
||||||
|
const combo = searchableSelect(select, reports, updateControls, { floating: true }); combo.input.setAttribute("aria-label", "Reports to");
|
||||||
|
const actions = el("div", "oe-org-form-actions");
|
||||||
|
const apply = button(person ? "Apply changes" : "Add person", "check", () => {}, true); apply.type = "submit"; apply.classList.add("oe-org-primary");
|
||||||
|
actions.append(button("Cancel", "close", discardEditor, true), apply);
|
||||||
|
if (person) {
|
||||||
|
const remove = button("Delete person", "trash", () => deletePerson(person), true);
|
||||||
|
remove.classList.add("oe-org-danger"); form.append(remove);
|
||||||
|
}
|
||||||
|
form.append(actions); editor.append(editorHeader, form); entry.edit = { person: existing, fields, form, combo, nameCombo, baseline: "", detailsVersion: 0, detailStatus, detailRetry };
|
||||||
|
entry.edit.baseline = JSON.stringify(editorValue());
|
||||||
|
form.addEventListener("submit", event => { event.preventDefault(); if (applyEditor()) { render(); focusPerson(entry.selected); } });
|
||||||
|
render(); updateControls(); if (!nameCombo.input.disabled) nameCombo.input.focus(); else fields.role.focus();
|
||||||
|
if (person) loadPersonDetails(entry.edit);
|
||||||
|
}
|
||||||
|
function showContactDetails(edit, contact) {
|
||||||
|
edit.fields.role.value = typeof contact?.JobTitle === "string" ? contact.JobTitle : "";
|
||||||
|
edit.fields.emailAddress.value = typeof contact?.PrimaryEmail?.EmailAddress === "string" ? contact.PrimaryEmail.EmailAddress : "";
|
||||||
|
for (const key of ["role", "emailAddress"]) edit.fields[key].placeholder = "Not provided";
|
||||||
|
edit.detailStatus.textContent = ""; edit.detailRetry.hidden = true;
|
||||||
|
if (contact) entry.details.set(String(contact.PartyId), contact);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
async function persistPartyNumber(person, value) {
|
||||||
|
const number = String(value).trim();
|
||||||
|
if (!/^[A-Za-z0-9]{1,100}$/.test(number)) return;
|
||||||
|
const current = entry.nodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId);
|
||||||
|
if (!current) return;
|
||||||
|
entry.nodes = model.upsert(entry.nodes, { ...current, PartyNumber: number });
|
||||||
|
if (entry.edit?.person.id === person.id) {
|
||||||
|
entry.edit.person = { ...entry.edit.person, PartyNumber: number };
|
||||||
|
entry.edit.baseline = JSON.stringify({ ...JSON.parse(entry.edit.baseline), PartyNumber: number });
|
||||||
|
}
|
||||||
|
const savedNodes = JSON.parse(entry.baseline);
|
||||||
|
const saved = savedNodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId);
|
||||||
|
if (!saved || saved.PartyNumber === number || entry.saving) { updateControls(); return; }
|
||||||
|
saved.PartyNumber = number;
|
||||||
|
entry.saving = true; updateControls();
|
||||||
|
try {
|
||||||
|
const chart = await request("save", { principal, customerId, nodes: savedNodes, revision: entry.revision });
|
||||||
|
if (active !== entry) return;
|
||||||
|
entry.revision = chart.revision; entry.baseline = JSON.stringify(chart.nodes);
|
||||||
|
} finally { if (active === entry) { entry.saving = false; updateControls(); } }
|
||||||
|
}
|
||||||
|
async function loadPersonDetails(edit) {
|
||||||
|
if (!edit) return;
|
||||||
|
const version = ++edit.detailsVersion;
|
||||||
|
const isCurrent = () => active === entry && entry.edit === edit && edit.detailsVersion === version;
|
||||||
|
entry.details.delete(edit.person.contactPartyId);
|
||||||
|
showContactDetails(edit, null);
|
||||||
|
if (!edit.person.contactPartyId) { edit.detailStatus.textContent = "Select a contact to load Job Title and email address."; return; }
|
||||||
|
edit.detailStatus.textContent = "Loading contact details...";
|
||||||
|
for (const key of ["role", "emailAddress"]) edit.fields[key].placeholder = "Loading...";
|
||||||
|
try {
|
||||||
|
let contacts = await requestQueryAccountContact(customer.PartyId, edit.person.name, isCurrent);
|
||||||
|
if (isCurrent() && !edit.person.PartyNumber && !contacts.find(item => String(item.PartyId) === edit.person.contactPartyId)?.PartyNumber) {
|
||||||
|
contacts = await requestQueryAccountContact(customer.PartyId, edit.person.name, isCurrent, edit.person.contactPartyId);
|
||||||
|
}
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
const contact = contacts.find(item => String(item.PartyId) === edit.person.contactPartyId);
|
||||||
|
if (contact) {
|
||||||
|
const current = entry.nodes.find(node => node.id === edit.person.id);
|
||||||
|
const jobTitle = typeof contact.JobTitle === "string" ? contact.JobTitle.trim() : "";
|
||||||
|
if (current && (current.name !== contact.PartyName.trim() || current.jobTitle !== jobTitle)) {
|
||||||
|
entry.nodes = model.upsert(entry.nodes, { ...current, name: contact.PartyName, jobTitle });
|
||||||
|
edit.person = entry.nodes.find(node => node.id === current.id);
|
||||||
|
const baseline = JSON.parse(edit.baseline);
|
||||||
|
edit.baseline = JSON.stringify({ ...baseline, name: edit.person.name, jobTitle });
|
||||||
|
edit.fields.name.querySelector('option[value="__saved"]').textContent = edit.person.name;
|
||||||
|
edit.nameCombo.sync(); changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showContactDetails(edit, contact);
|
||||||
|
if (contact?.PartyNumber) await persistPartyNumber(edit.person, contact.PartyNumber);
|
||||||
|
if (!contact) edit.detailStatus.textContent = "Contact details are unavailable. Select a contact to update this person.";
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
showContactDetails(edit, null); edit.detailStatus.textContent = error.message || "Unable to load contact details.";
|
||||||
|
edit.detailRetry.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyEditor() {
|
||||||
|
if (!entry.edit) return true;
|
||||||
|
const { form, fields } = entry.edit;
|
||||||
|
if (!editorValue().name) { message("Select a contact in Name.", true); entry.edit.nameCombo.input.focus(); return false; }
|
||||||
|
if (!form.reportValidity()) return false;
|
||||||
|
try {
|
||||||
|
const person = editorValue();
|
||||||
|
person.collapsed = entry.nodes.find(node => node.id === person.id)?.collapsed ?? person.collapsed;
|
||||||
|
entry.nodes = model.upsert(entry.nodes, person);
|
||||||
|
if (person.parentId) entry.nodes = entry.nodes.map(node => node.id === person.parentId ? { ...node, collapsed: false } : node);
|
||||||
|
entry.edit.nameCombo.destroy(); entry.edit = null; editor.hidden = true; entry.selected = person.id; changed(); return true;
|
||||||
|
} catch (error) { message(error.message, true); return false; }
|
||||||
|
}
|
||||||
|
function focusPerson(id) { canvas.querySelector('[data-person-id="' + id + '"] .oe-org-person')?.focus(); }
|
||||||
|
function showNotes(person, anchor) {
|
||||||
|
const card = anchor.closest(".oe-org-node");
|
||||||
|
if (!person.notes.trim() || entry.saving || active !== entry || !card?.isConnected) return;
|
||||||
|
tooltip.textContent = person.notes; tooltip.hidden = false;
|
||||||
|
const box = card.getBoundingClientRect(), rect = tooltip.getBoundingClientRect();
|
||||||
|
const width = document.documentElement.clientWidth, height = document.documentElement.clientHeight;
|
||||||
|
tooltip.style.left = Math.max(12, Math.min(box.left, width - rect.width - 12)) + "px";
|
||||||
|
tooltip.style.top = Math.max(12, box.bottom + rect.height + 8 > height ? box.top - rect.height - 8 : box.bottom + 8) + "px";
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
tooltip.hidden = true; canvas.replaceChildren();
|
||||||
|
const visible = entry.ready && entry.nodes.length;
|
||||||
|
empty.hidden = !entry.ready || !!visible; sizing.hidden = !visible;
|
||||||
|
count.textContent = entry.nodes.length + (entry.nodes.length === 1 ? " person" : " people");
|
||||||
|
if (!visible) return;
|
||||||
|
const d3 = loadLayout(), root = d3.hierarchy(model.hierarchy(entry.nodes));
|
||||||
|
d3.tree().nodeSize([nodeWidth + 28, nodeHeight + 54]).separation(() => 1)(root);
|
||||||
|
const nodes = root.descendants().filter(node => node.depth > 0), left = Math.min(...nodes.map(node => node.x)) - nodeWidth / 2 - 28;
|
||||||
|
entry.width = Math.max(...nodes.map(node => node.x)) - left + nodeWidth / 2 + 28;
|
||||||
|
entry.height = Math.max(...nodes.map(node => (node.depth - 1) * (nodeHeight + 54))) + nodeHeight + 56;
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.classList.add("oe-org-lines"); svg.setAttribute("width", entry.width); svg.setAttribute("height", entry.height); svg.setAttribute("aria-hidden", "true"); canvas.append(svg);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const x = node.x - left - nodeWidth / 2, y = (node.depth - 1) * (nodeHeight + 54) + 28;
|
||||||
|
if (node.parent.depth > 0) {
|
||||||
|
const px = node.parent.x - left, py = y - 54, path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
path.setAttribute("d", "M" + px + "," + py + " V" + (py + 27) + " H" + (x + nodeWidth / 2) + " V" + y); svg.append(path);
|
||||||
|
}
|
||||||
|
const person = node.data, card = el("article", "oe-org-node");
|
||||||
|
card.setAttribute("data-person-id", person.id); card.setAttribute("data-selected", String(entry.selected === person.id)); card.style.left = x + "px"; card.style.top = y + "px";
|
||||||
|
const main = button("Edit " + person.name, "chart", () => guardEditor(() => editPerson(entry.nodes.find(item => item.id === person.id))));
|
||||||
|
main.className = "oe-org-person"; main.removeAttribute("title"); main.replaceChildren(el("strong", "", person.name), el("span", "", person.jobTitle || "Job Title not provided"));
|
||||||
|
card.addEventListener("mouseenter", () => showNotes(person, main)); card.addEventListener("mouseleave", () => { tooltip.hidden = true; });
|
||||||
|
main.addEventListener("focus", () => showNotes(person, main)); main.addEventListener("blur", () => { tooltip.hidden = true; });
|
||||||
|
if (person.notes.trim()) main.setAttribute("aria-describedby", tooltip.id);
|
||||||
|
const actions = el("div", "oe-org-node-actions");
|
||||||
|
actions.append(button("Add report to " + person.name, "plus", () => guardEditor(() => editPerson(null, person.id))));
|
||||||
|
const remove = button("Delete " + person.name, "trash", () => guardEditor(() => deletePerson(person)));
|
||||||
|
remove.classList.add("oe-org-danger"); actions.append(remove);
|
||||||
|
const opportunities = button("View opportunities for " + person.name, "opportunities", () => showContactOpportunities(person, opportunities));
|
||||||
|
opportunities.setAttribute("aria-haspopup", "dialog"); actions.append(opportunities);
|
||||||
|
if (person.notes.trim()) { const marker = el("span", "oe-org-note-marker"); marker.append(icon("note")); marker.title = "Has notes"; actions.append(marker); }
|
||||||
|
const reports = entry.nodes.filter(item => item.parentId === person.id).length;
|
||||||
|
if (reports) {
|
||||||
|
const toggle = button((person.collapsed ? "Expand" : "Collapse") + " reports for " + person.name, person.collapsed ? "down" : "up", () => guardEditor(() => {
|
||||||
|
entry.nodes = entry.nodes.map(item => item.id === person.id ? { ...item, collapsed: !item.collapsed } : item); changed(); render(); focusPerson(person.id);
|
||||||
|
}));
|
||||||
|
toggle.setAttribute("aria-expanded", String(!person.collapsed)); toggle.append(el("span", "", String(reports))); actions.append(toggle);
|
||||||
|
}
|
||||||
|
card.append(main, actions); canvas.append(card);
|
||||||
|
}
|
||||||
|
if (entry.autoFit) fit(); else setZoom(entry.zoom, true);
|
||||||
|
}
|
||||||
|
function setZoom(value, preserveFit = false) {
|
||||||
|
if (!preserveFit) entry.autoFit = false;
|
||||||
|
entry.zoom = Math.max(.25, Math.min(1.5, value)); canvas.style.width = entry.width + "px"; canvas.style.height = entry.height + "px";
|
||||||
|
canvas.style.transform = "scale(" + entry.zoom + ")"; sizing.style.width = entry.width * entry.zoom + "px"; sizing.style.height = entry.height * entry.zoom + "px";
|
||||||
|
zoomValue.textContent = Math.round(entry.zoom * 100) + "%";
|
||||||
|
}
|
||||||
|
function fit() {
|
||||||
|
if (!entry.nodes.length) return;
|
||||||
|
entry.autoFit = true;
|
||||||
|
setZoom(Math.min(1, (viewport.clientWidth - 24) / entry.width, (viewport.clientHeight - 24) / entry.height), true); viewport.scrollTo({ left: 0, top: 0 });
|
||||||
|
}
|
||||||
|
function foldAll(collapsed) { guardEditor(() => { entry.nodes = entry.nodes.map(person => ({ ...person, collapsed })); changed(); render(); fit(); }); }
|
||||||
|
async function load() {
|
||||||
|
retry.hidden = true; entry.ready = false; updateControls(); modal.setAttribute("aria-busy", "true"); empty.hidden = true;
|
||||||
|
canvas.replaceChildren(el("div", "oe-org-skeleton")); sizing.hidden = false; message("Loading organization chart...");
|
||||||
|
try {
|
||||||
|
if (!customerId || !principal) throw new Error("The current user and customer must be available before opening an organization chart.");
|
||||||
|
const chart = await request("load", { principal, customerId }); if (active !== entry) return;
|
||||||
|
entry.nodes = model.normalize(chart.nodes); entry.baseline = JSON.stringify(entry.nodes); entry.revision = chart.revision; entry.ready = true;
|
||||||
|
message(chart.updatedAt ? "Saved locally: " + new Date(chart.updatedAt).toLocaleString("en-US") : "Not saved yet"); render(); fit();
|
||||||
|
} catch (error) { if (active === entry) { canvas.replaceChildren(); message(error.message || "Unable to load organization chart.", true); retry.hidden = false; } }
|
||||||
|
finally { if (active === entry) { modal.setAttribute("aria-busy", "false"); updateControls(); } }
|
||||||
|
}
|
||||||
|
async function saveChart() {
|
||||||
|
if (!entry.ready || entry.saving) return;
|
||||||
|
if (!applyEditor()) return;
|
||||||
|
render(); entry.saving = true; updateControls(); message("Saving locally...");
|
||||||
|
try {
|
||||||
|
const chart = await request("save", { principal, customerId, nodes: entry.nodes, revision: entry.revision }); if (active !== entry) return;
|
||||||
|
entry.revision = chart.revision; entry.baseline = JSON.stringify(entry.nodes); message("Saved locally: " + new Date(chart.updatedAt).toLocaleString("en-US"));
|
||||||
|
notify("Organization chart saved locally.");
|
||||||
|
} catch (error) { if (active === entry) { message(error.message || "Unable to save organization chart.", true); notify("Organization chart could not be saved.", true); } }
|
||||||
|
finally { if (active === entry) { entry.saving = false; updateControls(); } }
|
||||||
|
}
|
||||||
|
let drag = null;
|
||||||
|
viewport.addEventListener("pointerdown", event => {
|
||||||
|
if (event.button !== 0 || event.target.closest("button, input, textarea, .oe-org-node")) return;
|
||||||
|
drag = { x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop }; viewport.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
viewport.addEventListener("pointermove", event => { if (drag) { viewport.scrollLeft = drag.left - event.clientX + drag.x; viewport.scrollTop = drag.top - event.clientY + drag.y; } });
|
||||||
|
viewport.addEventListener("pointerup", () => { drag = null; }); viewport.addEventListener("pointercancel", () => { drag = null; });
|
||||||
|
viewport.addEventListener("scroll", () => { tooltip.hidden = true; });
|
||||||
|
if (environment.window?.ResizeObserver) { entry.observer = new environment.window.ResizeObserver(() => { tooltip.hidden = true; if (entry.autoFit) fit(); }); entry.observer.observe(viewport); }
|
||||||
|
modal.addEventListener("keydown", event => {
|
||||||
|
if (entry.confirmation) return;
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); tooltip.hidden = true; close(); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [...modal.querySelectorAll("button, input, textarea, [tabindex='0']")].filter(item => !item.disabled && !item.closest("[hidden], [inert]") && item.getClientRects().length);
|
||||||
|
const first = controls[0], last = controls.at(-1);
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dismiss.focus(); load();
|
||||||
|
}
|
||||||
|
return { createButton, open, close };
|
||||||
|
}
|
||||||
69
src/features/preferences/request-cache.js
Normal file
69
src/features/preferences/request-cache.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
function createFormRequestCache({ getHours, getUserId }) {
|
||||||
|
const entries = new Map();
|
||||||
|
const pending = new Map();
|
||||||
|
let generation = 0;
|
||||||
|
let owner = "";
|
||||||
|
const queries = new Set([
|
||||||
|
"requestWorkloads",
|
||||||
|
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
|
||||||
|
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
|
||||||
|
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
|
||||||
|
]);
|
||||||
|
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest", "requestWorkloadForecastUpdate"]);
|
||||||
|
function invalidate() { generation++; entries.clear(); pending.clear(); }
|
||||||
|
function maxAge() {
|
||||||
|
const hours = Number(getHours());
|
||||||
|
return (Number.isFinite(hours) && hours > 0 ? Math.min(hours, 168) : 24) * 3600000;
|
||||||
|
}
|
||||||
|
function copy(value) { return JSON.parse(JSON.stringify(value)); }
|
||||||
|
function stable(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(stable);
|
||||||
|
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function send(message, transport) {
|
||||||
|
const userId = String(getUserId() || "").trim().toLowerCase();
|
||||||
|
if (userId !== owner) { invalidate(); owner = userId; }
|
||||||
|
const operation = String(message.type || "").split(".").pop();
|
||||||
|
const duration = maxAge();
|
||||||
|
const request = queries.has(operation) || writes.has(operation) || operation === "requestResourceUser"
|
||||||
|
? { ...message, cacheMaxAgeMs: duration } : message;
|
||||||
|
if (operation === "invalidateProductsCache") invalidate();
|
||||||
|
// Only explicitly known read operations are reusable. Writes and token generation always execute.
|
||||||
|
if (!queries.has(operation) || !userId) {
|
||||||
|
const result = await transport(request);
|
||||||
|
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Workloads are scoped to the user/account, not the rotating WWB token or the selected Reference.
|
||||||
|
const key = JSON.stringify(stable(operation === "requestWorkloads"
|
||||||
|
? { type: message.type, customerId: String(message.customerId), principal: String(message.principal || userId).trim().toLowerCase() }
|
||||||
|
: message));
|
||||||
|
const hit = entries.get(key);
|
||||||
|
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
|
||||||
|
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
|
||||||
|
}
|
||||||
|
entries.delete(key);
|
||||||
|
if (pending.has(key)) {
|
||||||
|
const result = await pending.get(key);
|
||||||
|
return { ...copy(result), debugRequests: [] };
|
||||||
|
}
|
||||||
|
const version = generation;
|
||||||
|
const promise = transport(request).then(result => {
|
||||||
|
const workload = operation === "requestWorkloads";
|
||||||
|
const fetchedAt = workload && Number.isFinite(result?.cachedAt) ? result.cachedAt : Date.now();
|
||||||
|
const cacheable = !workload || (Array.isArray(result?.workloads) && (!result.fromCache || Number.isFinite(result.cachedAt)));
|
||||||
|
if (result?.ok && cacheable && version === generation) {
|
||||||
|
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
|
||||||
|
if (entries.size >= 500) entries.delete(entries.keys().next().value);
|
||||||
|
// Reusing a persistent workload entry must not restart its original TTL.
|
||||||
|
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
pending.set(key, promise);
|
||||||
|
try { return await promise; }
|
||||||
|
finally { if (pending.get(key) === promise) pending.delete(key); }
|
||||||
|
}
|
||||||
|
return { send, invalidate };
|
||||||
|
}
|
||||||
75
src/features/preferences/table-sorting.js
Normal file
75
src/features/preferences/table-sorting.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
function createTableSorting({ environment }) {
|
||||||
|
const memory = new Map();
|
||||||
|
const storageKey = id => `opportunities-extension-table-sort-v1-${id}`;
|
||||||
|
function read(id, columns) {
|
||||||
|
let saved = memory.get(id);
|
||||||
|
if (!saved) {
|
||||||
|
try { saved = JSON.parse(environment.localStorage?.getItem(storageKey(id)) || "null"); } catch { /* Storage is optional. */ }
|
||||||
|
}
|
||||||
|
return saved && columns.some(column => column.key === saved.key && column.sortable !== false)
|
||||||
|
&& ["ascending", "descending"].includes(saved.direction)
|
||||||
|
? { key: saved.key, direction: saved.direction } : { key: "", direction: "ascending" };
|
||||||
|
}
|
||||||
|
function save(id, state) {
|
||||||
|
memory.set(id, { ...state });
|
||||||
|
try { environment.localStorage?.setItem(storageKey(id), JSON.stringify(state)); } catch { /* Retain the page-session preference. */ }
|
||||||
|
}
|
||||||
|
function value(raw, type) {
|
||||||
|
if (raw == null || String(raw).trim() === "") return null;
|
||||||
|
if (type === "number") return Number.isFinite(Number(raw)) ? Number(raw) : null;
|
||||||
|
if (type === "date") {
|
||||||
|
const timestamp = Date.parse(raw);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
function create(id, columns) {
|
||||||
|
let state;
|
||||||
|
const headers = new Map();
|
||||||
|
const get = () => state || (state = read(id, columns));
|
||||||
|
function toggle(key) {
|
||||||
|
if (!columns.some(column => column.key === key && column.sortable !== false)) return;
|
||||||
|
const previous = get();
|
||||||
|
state = { key, direction: previous.key === key && previous.direction === "ascending" ? "descending" : "ascending" };
|
||||||
|
save(id, state);
|
||||||
|
headers.forEach(update);
|
||||||
|
}
|
||||||
|
function select(items) {
|
||||||
|
const selected = get(), column = columns.find(column => column.key === selected.key);
|
||||||
|
if (!column) return [...items];
|
||||||
|
const direction = selected.direction === "descending" ? -1 : 1;
|
||||||
|
return items.map((item, index) => ({ item, index, value: value(column.value ? column.value(item) : item[column.key], column.type) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.value === null || b.value === null) return a.value === b.value ? a.index - b.index : a.value === null ? 1 : -1;
|
||||||
|
const result = typeof a.value === "number" ? a.value - b.value : a.value.localeCompare(b.value, undefined, { numeric: true, sensitivity: "base" });
|
||||||
|
return result * direction || a.index - b.index;
|
||||||
|
}).map(record => record.item);
|
||||||
|
}
|
||||||
|
function update({ th, button, key, label }) {
|
||||||
|
const selected = get();
|
||||||
|
const direction = selected.key === key ? selected.direction : "none";
|
||||||
|
th.setAttribute("aria-sort", direction);
|
||||||
|
button.setAttribute("data-sort-direction", direction);
|
||||||
|
button.setAttribute("aria-label", `Sort by ${label}${direction === "none" ? "" : `, ${direction}`}`);
|
||||||
|
}
|
||||||
|
function header(th, key, label, onChange, disabled = false) {
|
||||||
|
const button = environment.document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-sort-button";
|
||||||
|
button.disabled = disabled;
|
||||||
|
button.title = `Sort by ${label}`;
|
||||||
|
button.setAttribute("data-sort-key", key);
|
||||||
|
const text = environment.document.createElement("span");
|
||||||
|
text.className = "opportunities-extension-sort-label";
|
||||||
|
text.textContent = label;
|
||||||
|
button.append(text);
|
||||||
|
const record = { th, button, key, label };
|
||||||
|
headers.set(key, record); update(record);
|
||||||
|
th.append(button);
|
||||||
|
button.addEventListener("click", () => { toggle(key); onChange?.(key); });
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
return { get, toggle, select, header };
|
||||||
|
}
|
||||||
|
return { read, save, create };
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ function createPreferencesView({
|
|||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
closeButton.type = "button";
|
closeButton.type = "button";
|
||||||
closeButton.className = "opportunities-extension-icon-button";
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
closeButton.setAttribute("aria-label", "Fechar preferências");
|
closeButton.setAttribute("aria-label", "Close preferences");
|
||||||
closeButton.title = "Close";
|
closeButton.title = "Close";
|
||||||
closeButton.append(createCloseIcon());
|
closeButton.append(createCloseIcon());
|
||||||
closeButton.addEventListener("click", closePreferencesModal);
|
closeButton.addEventListener("click", closePreferencesModal);
|
||||||
@@ -102,11 +102,11 @@ function createPreferencesView({
|
|||||||
cacheField.className = "opportunities-extension-preferences-cache-field";
|
cacheField.className = "opportunities-extension-preferences-cache-field";
|
||||||
|
|
||||||
const cacheLabel = document.createElement("span");
|
const cacheLabel = document.createElement("span");
|
||||||
cacheLabel.textContent = "Product cache duration";
|
cacheLabel.textContent = "Cache duration";
|
||||||
|
|
||||||
const cacheSelect = document.createElement("select");
|
const cacheSelect = document.createElement("select");
|
||||||
cacheSelect.name = "opptyProductsCacheHours";
|
cacheSelect.name = "opptyProductsCacheHours";
|
||||||
cacheSelect.setAttribute("aria-label", "Product cache duration");
|
cacheSelect.setAttribute("aria-label", "Cache duration");
|
||||||
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
||||||
const optionElement = document.createElement("option");
|
const optionElement = document.createElement("option");
|
||||||
optionElement.value = String(option.value);
|
optionElement.value = String(option.value);
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -24,15 +33,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
await saveProductsCache(cache).catch(() => {});
|
||||||
};
|
}
|
||||||
await saveProductsCache(cache).catch(() => {});
|
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,5 +86,5 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ function createProductsContentJobs({
|
|||||||
preferencesState,
|
preferencesState,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
loadWorkloadActions,
|
beginProductActivities,
|
||||||
|
completeProductActivities,
|
||||||
|
loadWorkloads,
|
||||||
|
joinWorkloads,
|
||||||
sendRuntimeMessage,
|
sendRuntimeMessage,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
@@ -52,6 +55,7 @@ function createProductsContentJobs({
|
|||||||
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
productsContentState.currentProductsRequestId = requestId;
|
productsContentState.currentProductsRequestId = requestId;
|
||||||
|
beginProductActivities(requestId, token, optyNumbers);
|
||||||
productsContentState.opptyProductsByNumber = new Map();
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
productsContentState.expandedProductOptyNumbers = new Set();
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -63,6 +67,8 @@ function createProductsContentJobs({
|
|||||||
};
|
};
|
||||||
renderProductsProgress();
|
renderProductsProgress();
|
||||||
|
|
||||||
|
loadWorkloads(opportunities, requestId);
|
||||||
|
|
||||||
if (optyNumbers.length === 0) {
|
if (optyNumbers.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,11 +125,7 @@ function createProductsContentJobs({
|
|||||||
cachedAt: Number(message.cachedAt) || 0,
|
cachedAt: Number(message.cachedAt) || 0,
|
||||||
error: message.error || ""
|
error: message.error || ""
|
||||||
});
|
});
|
||||||
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
|
joinWorkloads(message.optyNumber);
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
|
|
||||||
productsContentState.productsLastCachedAt = Number(message.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -134,6 +136,7 @@ function createProductsContentJobs({
|
|||||||
cacheHits: Number(message.cacheHits) || 0
|
cacheHits: Number(message.cacheHits) || 0
|
||||||
};
|
};
|
||||||
renderProductsProgress();
|
renderProductsProgress();
|
||||||
|
if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId);
|
||||||
queueProductsTableRender();
|
queueProductsTableRender();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -186,7 +189,7 @@ function createProductsContentJobs({
|
|||||||
timeStyle: "short"
|
timeStyle: "short"
|
||||||
}).format(new Date(timestamp));
|
}).format(new Date(timestamp));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Date(timestamp).toLocaleString();
|
return new Date(timestamp).toLocaleString("en-GB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ function createProductsContentState() {
|
|||||||
currentProductsRequestId: "",
|
currentProductsRequestId: "",
|
||||||
productsRenderQueued: false,
|
productsRenderQueued: false,
|
||||||
opptyProductsByNumber: new Map(),
|
opptyProductsByNumber: new Map(),
|
||||||
|
workloadCustomerIds: [],
|
||||||
|
workloadsByCustomer: new Map(),
|
||||||
expandedProductOptyNumbers: new Set(),
|
expandedProductOptyNumbers: new Set(),
|
||||||
productsProgress: {
|
productsProgress: {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
|
|||||||
@@ -1,28 +1,55 @@
|
|||||||
function createProductsDetails({
|
function createProductsDetails({
|
||||||
OPPORTUNITIES_COLUMNS,
|
OPPORTUNITIES_COLUMNS,
|
||||||
createAiSummaryAlert,
|
setBadgeContent,
|
||||||
|
createProjectionIndicator,
|
||||||
|
openProjection,
|
||||||
formatOracleResponseDate,
|
formatOracleResponseDate,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
requestGenerateAiSummary,
|
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
function createProductsRampBadge(productsResult) {
|
function isBooking(products) {
|
||||||
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
|
return Boolean(products?.length) && products.every(item => !String(item?.WorkloadName_c ?? "").trim() || String(item.WorkloadName_c).trim() === "-");
|
||||||
|
}
|
||||||
|
function createProductsRampBadge(productsResult, opportunity) {
|
||||||
|
const products = Array.isArray(productsResult.items) ? productsResult.items : [];
|
||||||
|
if (isBooking(products)) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "opportunities-extension-products-ramp";
|
||||||
|
badge.setAttribute("data-booking", "true");
|
||||||
|
badge.textContent = "Booking";
|
||||||
|
badge.title = "Booking: no product has a Workload assigned.";
|
||||||
|
badge.setAttribute("aria-label", badge.title);
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
const values = products
|
||||||
.map(item => item && item.RampMonths_c)
|
.map(item => item && item.RampMonths_c)
|
||||||
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
|
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
|
||||||
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
|
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
|
||||||
if (!values.length) return null;
|
if (!values.length) return null;
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("button");
|
||||||
|
badge.type = "button";
|
||||||
badge.className = "opportunities-extension-products-ramp";
|
badge.className = "opportunities-extension-products-ramp";
|
||||||
badge.setAttribute("data-missing", String(values.includes("N/D")));
|
badge.setAttribute("data-missing", String(values.includes("N/A")));
|
||||||
badge.textContent = `RM ${values.join(",")}`;
|
setBadgeContent(badge, "ramp", values.join(" | "));
|
||||||
badge.title = `Ramp Months: ${values.join(", ")}`;
|
badge.title = `Ramp Months: ${values.join(", ")}`;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
|
badge.setAttribute("aria-haspopup", "dialog");
|
||||||
|
badge.addEventListener("click", event => {
|
||||||
|
event.preventDefault(); event.stopPropagation();
|
||||||
|
openProjection(productsResult.items, opportunity, badge);
|
||||||
|
});
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandProducts(optyNumber) {
|
||||||
|
const number = String(optyNumber);
|
||||||
|
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
|
||||||
|
productsContentState.expandedProductOptyNumbers.add(number);
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
}
|
||||||
|
|
||||||
function createProductsCountButton(optyNumber, productsResult) {
|
function createProductsCountButton(optyNumber, productsResult) {
|
||||||
const normalizedOptyNumber = String(optyNumber);
|
const normalizedOptyNumber = String(optyNumber);
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
@@ -31,7 +58,7 @@ function createProductsDetails({
|
|||||||
|
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "opportunities-extension-products-count";
|
button.className = "opportunities-extension-products-count";
|
||||||
button.textContent = String(count);
|
setBadgeContent(button, "products", count);
|
||||||
button.setAttribute("aria-expanded", String(isExpanded));
|
button.setAttribute("aria-expanded", String(isExpanded));
|
||||||
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
||||||
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
||||||
@@ -43,8 +70,8 @@ function createProductsDetails({
|
|||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||||
} else {
|
} else {
|
||||||
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
|
expandProducts(normalizedOptyNumber);
|
||||||
requestGenerateAiSummary(normalizedOptyNumber);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
@@ -76,7 +103,6 @@ function createProductsDetails({
|
|||||||
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||||
heading.append(title, summary);
|
heading.append(title, summary);
|
||||||
panel.append(heading);
|
panel.append(heading);
|
||||||
panel.append(createAiSummaryAlert(optyNumber));
|
|
||||||
|
|
||||||
if (productsResult && productsResult.error) {
|
if (productsResult && productsResult.error) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
@@ -91,6 +117,7 @@ function createProductsDetails({
|
|||||||
} else {
|
} else {
|
||||||
const table = document.createElement("table");
|
const table = document.createElement("table");
|
||||||
table.className = "opportunities-extension-products-table";
|
table.className = "opportunities-extension-products-table";
|
||||||
|
table.setAttribute("data-table-layout", "standard");
|
||||||
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
|
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -104,7 +131,8 @@ function createProductsDetails({
|
|||||||
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||||
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
||||||
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
|
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
|
||||||
? "N/D" : formatProductNumber(item.RampMonths_c) }
|
? "N/A" : formatProductNumber(item.RampMonths_c) },
|
||||||
|
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
|
||||||
];
|
];
|
||||||
|
|
||||||
const head = document.createElement("thead");
|
const head = document.createElement("thead");
|
||||||
@@ -112,6 +140,7 @@ function createProductsDetails({
|
|||||||
columns.forEach((column) => {
|
columns.forEach((column) => {
|
||||||
const header = document.createElement("th");
|
const header = document.createElement("th");
|
||||||
header.scope = "col";
|
header.scope = "col";
|
||||||
|
if (column.label === "Amount") header.setAttribute("data-align", "money");
|
||||||
header.textContent = column.label;
|
header.textContent = column.label;
|
||||||
headerRow.append(header);
|
headerRow.append(header);
|
||||||
});
|
});
|
||||||
@@ -122,7 +151,11 @@ function createProductsDetails({
|
|||||||
const productRow = document.createElement("tr");
|
const productRow = document.createElement("tr");
|
||||||
columns.forEach((column) => {
|
columns.forEach((column) => {
|
||||||
const productCell = document.createElement("td");
|
const productCell = document.createElement("td");
|
||||||
productCell.textContent = String(column.value(product));
|
if (column.label === "Amount") productCell.setAttribute("data-align", "money");
|
||||||
|
if (column.render) {
|
||||||
|
productCell.className = "opportunities-extension-projection-cell";
|
||||||
|
productCell.append(column.render(product));
|
||||||
|
} else productCell.textContent = String(column.value(product));
|
||||||
productRow.append(productCell);
|
productRow.append(productCell);
|
||||||
});
|
});
|
||||||
body.append(productRow);
|
body.append(productRow);
|
||||||
@@ -164,5 +197,5 @@ function createProductsDetails({
|
|||||||
return Number.isFinite(number) ? `${number}%` : "-";
|
return Number.isFinite(number) ? `${number}%` : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
return { isBooking, expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/features/products/forecast-update.js
Normal file
47
src/features/products/forecast-update.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
function createWorkloadForecastUpdate({ compare, ensureCurrentUser, getCurrentUserId, requestAuthorization,
|
||||||
|
sendRuntimeMessage, messageTypes, appendBackgroundRequestLog, productsContentState, queueProductsTableRender }) {
|
||||||
|
const pending = new Set();
|
||||||
|
async function save(product) {
|
||||||
|
const id = String(product?.workload?.id ?? "");
|
||||||
|
if (!/^\d+$/.test(id) || id !== String(product.RevnId)) throw new Error("The product does not have a matching workload ID.");
|
||||||
|
if (pending.has(id)) throw new Error("Estimates are already being updated.");
|
||||||
|
const comparison = compare(product);
|
||||||
|
if (comparison.status !== "divergent") throw new Error("No forecast differences are available to update.");
|
||||||
|
pending.add(id);
|
||||||
|
try {
|
||||||
|
await ensureCurrentUser();
|
||||||
|
const updatedBy = getCurrentUserId();
|
||||||
|
if (typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("The current user ID is unavailable.");
|
||||||
|
// Keep zero months before consumption unchanged, unless they also need correcting.
|
||||||
|
const payload = comparison.rows.filter(row => row.index >= 1 || row.divergent)
|
||||||
|
.map(row => ({ year: row.year, month: row.month, amount: row.calculated, updatedBy }));
|
||||||
|
const authorizations = await requestAuthorization();
|
||||||
|
const response = await sendRuntimeMessage({ type: messageTypes.requestWorkloadForecastUpdate,
|
||||||
|
workloadId: id, payload, authorization: authorizations?.workloadActions });
|
||||||
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
|
if (!response?.ok) throw new Error(response?.error || "Unable to update workload estimates.");
|
||||||
|
const amounts = new Map(payload.map(item => [`${item.year}-${item.month}`, item.amount]));
|
||||||
|
const update = workload => {
|
||||||
|
if (String(workload?.id) !== id) return workload;
|
||||||
|
return { ...workload, forecast: workload.forecast.map(month => {
|
||||||
|
const key = `${Number(month.year)}-${Number(month.month)}`;
|
||||||
|
return amounts.has(key) ? { ...month, adjustedConsumptionAmount: amounts.get(key) } : month;
|
||||||
|
}) };
|
||||||
|
};
|
||||||
|
product.workload = update(product.workload);
|
||||||
|
for (const record of productsContentState.workloadsByCustomer.values()) {
|
||||||
|
if (record.status !== "ready") continue;
|
||||||
|
record.workloads = record.workloads.map(update);
|
||||||
|
record.byId = new Map(record.workloads.map(workload => [String(workload.id), workload]));
|
||||||
|
}
|
||||||
|
for (const record of productsContentState.opptyProductsByNumber.values()) {
|
||||||
|
record.items = record.items.map(item => ({ ...item, workload: update(item.workload) }));
|
||||||
|
}
|
||||||
|
queueProductsTableRender();
|
||||||
|
return response;
|
||||||
|
} finally {
|
||||||
|
pending.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { save };
|
||||||
|
}
|
||||||
248
src/features/products/ramp-projection-view.js
Normal file
248
src/features/products/ramp-projection-view.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment, tableSorting }) {
|
||||||
|
const { document } = environment;
|
||||||
|
let active = null;
|
||||||
|
function node(tag, className, text) {
|
||||||
|
const item = document.createElement(tag);
|
||||||
|
if (className) item.className = className;
|
||||||
|
if (text !== undefined) item.textContent = text;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
function close(restoreFocus = true) {
|
||||||
|
if (!active) return false;
|
||||||
|
if (active.saving && restoreFocus) return true;
|
||||||
|
const previous = active;
|
||||||
|
active = null;
|
||||||
|
previous.modal.remove();
|
||||||
|
previous.siblings.forEach(([element, inert]) => { element.inert = inert; });
|
||||||
|
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function createIndicator(product, opportunity) {
|
||||||
|
const result = compare(product);
|
||||||
|
if (!product?.workload) {
|
||||||
|
const disabled = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
|
||||||
|
disabled.type = "button";
|
||||||
|
disabled.disabled = true;
|
||||||
|
disabled.title = "No Workload is available for this product.";
|
||||||
|
disabled.setAttribute("aria-label", disabled.title);
|
||||||
|
disabled.append(createAlertIcon());
|
||||||
|
return disabled;
|
||||||
|
}
|
||||||
|
if (result.status !== "divergent") {
|
||||||
|
const text = node("span", "opportunities-extension-projection-status", result.status === "matched" ? "OK" : "-");
|
||||||
|
text.title = result.status === "matched" ? "Monthly forecast matches the ramp calculation." : result.reason;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const button = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
|
||||||
|
button.type = "button";
|
||||||
|
button.title = `Consumption forecast differs in ${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"}. View comparison.`;
|
||||||
|
button.setAttribute("aria-label", button.title);
|
||||||
|
button.setAttribute("aria-haspopup", "dialog");
|
||||||
|
button.append(createAlertIcon());
|
||||||
|
button.addEventListener("click", event => {
|
||||||
|
event.preventDefault(); event.stopPropagation();
|
||||||
|
open(product, opportunity, button);
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
function createOpportunityIndicator(productsResult, opportunity, isExpanded) {
|
||||||
|
const products = productsResult?.items || [];
|
||||||
|
const count = products.filter(product => compare(product).status === "divergent").length;
|
||||||
|
if (!count) return null;
|
||||||
|
const singleProduct = products.length === 1;
|
||||||
|
const indicator = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert opportunities-extension-opportunity-projection-alert");
|
||||||
|
indicator.type = "button";
|
||||||
|
indicator.title = `Consumption forecast differs in ${count} product${count === 1 ? "" : "s"}. ${singleProduct ? "View comparison." : "View products."}`;
|
||||||
|
if (singleProduct) indicator.setAttribute("aria-haspopup", "dialog");
|
||||||
|
else indicator.setAttribute("aria-expanded", String(Boolean(isExpanded)));
|
||||||
|
indicator.setAttribute("aria-label", indicator.title);
|
||||||
|
indicator.append(createAlertIcon());
|
||||||
|
indicator.addEventListener("click", event => {
|
||||||
|
event.preventDefault(); event.stopPropagation();
|
||||||
|
if (singleProduct) open(products[0], opportunity, indicator);
|
||||||
|
else expandProducts(opportunity.OptyNumber);
|
||||||
|
});
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
function open(product, opportunity, trigger, products = []) {
|
||||||
|
const host = document.getElementById(MODAL_ID);
|
||||||
|
const result = compare(product);
|
||||||
|
if (!host || host.hidden || !product) return;
|
||||||
|
close(false);
|
||||||
|
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "opportunities-extension-projection-title");
|
||||||
|
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-projection-dialog");
|
||||||
|
const header = node("header", "opportunities-extension-account-plans-header");
|
||||||
|
const heading = node("div", "opportunities-extension-action-form-heading");
|
||||||
|
const title = node("h2", "", "Consumption projection");
|
||||||
|
title.id = "opportunities-extension-projection-title";
|
||||||
|
heading.append(title, node("p", "", `${opportunity.OptyNumber} - ${product.WorkloadName_c || product.workload.name || product.ProdGroupName || "Workload"}`));
|
||||||
|
const dismiss = node("button", "opportunities-extension-icon-button");
|
||||||
|
dismiss.type = "button";
|
||||||
|
dismiss.title = "Close consumption projection";
|
||||||
|
dismiss.setAttribute("aria-label", dismiss.title);
|
||||||
|
dismiss.append(createCloseIcon());
|
||||||
|
dismiss.addEventListener("click", () => close());
|
||||||
|
header.append(heading, dismiss);
|
||||||
|
const body = node("div", "opportunities-extension-projection-body");
|
||||||
|
let productSelect = null;
|
||||||
|
if (products.length > 1) {
|
||||||
|
const label = node("label", "", "Product ");
|
||||||
|
productSelect = node("select", "opportunities-extension-projection-product");
|
||||||
|
productSelect.setAttribute("aria-label", "Product");
|
||||||
|
products.forEach((item, index) => {
|
||||||
|
const option = node("option", "", `${item.ProdGroupName || "Product"} | ${item.WorkloadName_c || item.RevnId || index + 1}`);
|
||||||
|
option.value = String(index); option.selected = item === product;
|
||||||
|
productSelect.append(option);
|
||||||
|
});
|
||||||
|
productSelect.addEventListener("change", () => open(products[Number(productSelect.value)], opportunity, trigger, products));
|
||||||
|
label.append(productSelect); body.append(label);
|
||||||
|
}
|
||||||
|
const details = node("dl", "opportunities-extension-projection-context");
|
||||||
|
const currency = product.RevnAmountCurcyCode || "USD";
|
||||||
|
for (const [label, value] of [
|
||||||
|
["Customer", opportunity.CustomerAccount?.PartyUniqueName || "-"], ["Product", product.ProdGroupName || "-"],
|
||||||
|
["Annual value", result.annual == null ? "-" : formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp ?? "-")],
|
||||||
|
["Consumption start", result.start ? formatDate(result.start) : "-"], ["First month", result.firstMonthDays == null ? "-" : `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
|
||||||
|
]) details.append(node("dt", "", label), node("dd", "", value));
|
||||||
|
body.append(details);
|
||||||
|
const summary = node("p", "opportunities-extension-projection-summary", `${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"} with a difference`);
|
||||||
|
body.append(summary);
|
||||||
|
if (result.reason) body.append(node("p", "opportunities-extension-projection-notice", result.reason));
|
||||||
|
const viewport = node("div", "opportunities-extension-projection-viewport");
|
||||||
|
viewport.tabIndex = 0;
|
||||||
|
viewport.setAttribute("role", "region");
|
||||||
|
viewport.setAttribute("aria-label", "Monthly consumption comparison");
|
||||||
|
const table = node("table", "opportunities-extension-projection-table");
|
||||||
|
table.setAttribute("data-table-layout", "standard");
|
||||||
|
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
|
||||||
|
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
|
||||||
|
const sorting = tableSorting.create("consumption-projection", [
|
||||||
|
{ key: "month", type: "number", value: item => item.year * 12 + item.month },
|
||||||
|
...["actual", "calculated", "difference"].map(key => ({ key, type: "number" }))
|
||||||
|
]);
|
||||||
|
const rowNodes = new Map();
|
||||||
|
const renderRows = () => tbody.replaceChildren(...sorting.select(result.rows).map(item => rowNodes.get(item)));
|
||||||
|
for (const [key, label] of [["month", "Month"], ["actual", "Forecast"], ["calculated", "Calculated"], ["difference", "Difference"]]) {
|
||||||
|
const th = node("th"); th.scope = "col"; if (key !== "month") th.setAttribute("data-align", "money");
|
||||||
|
sorting.header(th, key, label, renderRows); headRow.append(th);
|
||||||
|
}
|
||||||
|
head.append(headRow);
|
||||||
|
for (const item of result.rows) {
|
||||||
|
const tr = node("tr");
|
||||||
|
tr.setAttribute("data-divergent", String(item.divergent));
|
||||||
|
const month = node("th", "", `${String(item.month).padStart(2, "0")}/${item.year}`);
|
||||||
|
month.scope = "row";
|
||||||
|
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
|
||||||
|
tr.append(month);
|
||||||
|
for (const value of [item.actual, item.calculated, item.difference]) { const cell = node("td", "", value === null ? "-" : formatAmount(value, currency)); cell.setAttribute("data-align", "money"); tr.append(cell); }
|
||||||
|
rowNodes.set(item, tr);
|
||||||
|
}
|
||||||
|
renderRows();
|
||||||
|
table.append(caption, head, tbody);
|
||||||
|
viewport.append(table);
|
||||||
|
body.append(viewport);
|
||||||
|
const errorMessage = node("p", "opportunities-extension-projection-error");
|
||||||
|
errorMessage.setAttribute("role", "alert");
|
||||||
|
errorMessage.hidden = true;
|
||||||
|
body.append(errorMessage);
|
||||||
|
const footer = node("footer", "opportunities-extension-projection-footer");
|
||||||
|
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
|
||||||
|
updateButton.disabled = result.status !== "divergent";
|
||||||
|
updateButton.type = "button";
|
||||||
|
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
|
||||||
|
dialog.append(header, body, footer);
|
||||||
|
modal.append(dialog);
|
||||||
|
const siblings = Array.from(host.children).map(element => [element, element.inert]);
|
||||||
|
siblings.forEach(([element]) => { element.inert = true; });
|
||||||
|
const current = { modal, siblings, trigger, saving: false };
|
||||||
|
active = current;
|
||||||
|
async function updateEstimates() {
|
||||||
|
if (current.saving) return;
|
||||||
|
current.saving = true;
|
||||||
|
updateButton.disabled = true;
|
||||||
|
dismiss.disabled = true;
|
||||||
|
updateButton.textContent = "Updating...";
|
||||||
|
modal.setAttribute("aria-busy", "true");
|
||||||
|
errorMessage.hidden = true;
|
||||||
|
try {
|
||||||
|
await saveEstimates(product);
|
||||||
|
current.saving = false;
|
||||||
|
if (active === current) close();
|
||||||
|
notify("Workload estimates updated successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.message || "Unable to update workload estimates.";
|
||||||
|
if (active === current) {
|
||||||
|
errorMessage.textContent = message;
|
||||||
|
errorMessage.hidden = false;
|
||||||
|
}
|
||||||
|
notify(message, true);
|
||||||
|
} finally {
|
||||||
|
current.saving = false;
|
||||||
|
updateButton.disabled = false;
|
||||||
|
dismiss.disabled = false;
|
||||||
|
updateButton.textContent = "Update estimates";
|
||||||
|
modal.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateButton.addEventListener("click", () => {
|
||||||
|
if (current.saving || current.confirmation) return;
|
||||||
|
const overlay = node("div", "opportunities-extension-action-form-modal");
|
||||||
|
const confirmation = node("section", "opportunities-extension-action-form-dialog opportunities-extension-projection-confirm");
|
||||||
|
confirmation.setAttribute("role", "alertdialog");
|
||||||
|
confirmation.setAttribute("aria-modal", "true");
|
||||||
|
confirmation.setAttribute("aria-labelledby", "projection-confirm-title");
|
||||||
|
confirmation.setAttribute("aria-describedby", "projection-confirm-message");
|
||||||
|
const title = node("h2", "", "Update estimates");
|
||||||
|
title.id = "projection-confirm-title";
|
||||||
|
const message = node("p", "", "The estimates will be updated directly in the SPA using the values in the Calculated column. Would you like to confirm the update?");
|
||||||
|
message.id = "projection-confirm-message";
|
||||||
|
const actions = node("div", "opportunities-extension-projection-confirm-actions");
|
||||||
|
const cancel = node("button", "opportunities-extension-projection-cancel", "Cancel");
|
||||||
|
const confirm = node("button", "opportunities-extension-projection-update", "Confirm update");
|
||||||
|
cancel.type = confirm.type = "button";
|
||||||
|
function dismissConfirmation() {
|
||||||
|
overlay.remove();
|
||||||
|
current.confirmation = null;
|
||||||
|
dialog.inert = false;
|
||||||
|
updateButton.focus();
|
||||||
|
}
|
||||||
|
cancel.addEventListener("click", dismissConfirmation);
|
||||||
|
confirm.addEventListener("click", () => {
|
||||||
|
if (current.confirmation !== overlay || active !== current) return;
|
||||||
|
dismissConfirmation();
|
||||||
|
return updateEstimates();
|
||||||
|
});
|
||||||
|
overlay.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault(); event.stopPropagation(); dismissConfirmation();
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
event.preventDefault(); event.stopPropagation();
|
||||||
|
(document.activeElement === cancel ? confirm : cancel).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.append(cancel, confirm);
|
||||||
|
confirmation.append(title, message, actions);
|
||||||
|
overlay.append(confirmation);
|
||||||
|
current.confirmation = overlay;
|
||||||
|
dialog.inert = true;
|
||||||
|
modal.append(overlay);
|
||||||
|
cancel.focus();
|
||||||
|
});
|
||||||
|
host.append(modal);
|
||||||
|
modal.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [dismiss, productSelect, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => control && !control.disabled);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
return { createIndicator, createOpportunityIndicator, open, close };
|
||||||
|
}
|
||||||
61
src/features/products/ramp-projection.css
Normal file
61
src/features/products/ramp-projection.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
.opportunities-extension-products-table th:last-child { width: 90px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-align: center; }
|
||||||
|
.opportunities-extension-products-table .opportunities-extension-projection-cell { text-align: center; vertical-align: middle; }
|
||||||
|
.opportunities-extension-projection-alert { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 0; color: #976000; width: 30px; height: 30px; flex: 0 0 30px; vertical-align: middle; }
|
||||||
|
.opportunities-extension-projection-alert svg { display: block; flex: none; width: 19px; height: 19px; }
|
||||||
|
.opportunities-extension-cell-actions > .opportunities-extension-opportunity-projection-alert { width: 20px; height: 20px; flex-basis: 20px; }
|
||||||
|
.opportunities-extension-projection-alert:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-projection-alert:disabled { color: #8f8a85; opacity: .55; cursor: not-allowed; background: transparent; }
|
||||||
|
.opportunities-extension-products-ramp { cursor: pointer; font-family: inherit; }
|
||||||
|
.opportunities-extension-products-ramp[data-booking="true"] { cursor: default; color: #714016; background: #fff0df; border-color: #c58b50; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-booking="true"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
|
||||||
|
.opportunities-extension-products-ramp:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-projection-product { max-width: 100%; margin: 0 0 16px; padding: 8px; font: inherit; color: inherit; background: inherit; }
|
||||||
|
.opportunities-extension-action-form-dialog.opportunities-extension-projection-dialog { width: min(850px, 100%); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.opportunities-extension-projection-dialog > header { flex: 0 0 auto; padding: 18px 20px; }
|
||||||
|
.opportunities-extension-projection-dialog .opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-projection-body { min-height: 0; overflow: auto; padding: 16px 20px 0; }
|
||||||
|
.opportunities-extension-projection-context { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr); gap: 8px 12px; margin: 0 0 16px; font-size: 12px; }
|
||||||
|
.opportunities-extension-projection-context dt { font-weight: 600; }
|
||||||
|
.opportunities-extension-projection-context dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-projection-summary { font-size: 13px; font-weight: 600; color: #815400; margin: 0 0 12px; }
|
||||||
|
.opportunities-extension-projection-notice { font-size: 12px; }
|
||||||
|
.opportunities-extension-projection-viewport { overflow-x: auto; }
|
||||||
|
.opportunities-extension-projection-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
|
||||||
|
.opportunities-extension-projection-table th:first-child { text-align: left; }
|
||||||
|
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
|
||||||
|
.opportunities-extension-projection-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-end; }
|
||||||
|
.opportunities-extension-projection-table th:first-child .opportunities-extension-sort-button { justify-content: flex-start; }
|
||||||
|
.opportunities-extension-projection-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
|
||||||
|
.opportunities-extension-projection-table tbody th { font-weight: 400; }
|
||||||
|
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
|
||||||
|
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }
|
||||||
|
.opportunities-extension-projection-prorated { display: inline; color: #625d58; font-size: 10px; }
|
||||||
|
.opportunities-extension-projection-footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0; padding: 14px 20px; font-size: 11px; color: #625d58; }
|
||||||
|
.opportunities-extension-projection-update { flex: none; padding: 8px 12px; border: 1px solid #006879; border-radius: 4px; background: #006879; color: #fff; font: inherit; font-size: 12px; cursor: pointer; }
|
||||||
|
.opportunities-extension-projection-update:hover:not(:disabled) { background: #005563; }
|
||||||
|
.opportunities-extension-projection-update:disabled { opacity: .6; cursor: wait; }
|
||||||
|
.opportunities-extension-projection-update:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-projection-confirm { padding: 20px; }
|
||||||
|
.opportunities-extension-projection-confirm h2 { margin: 0 0 12px; font-size: 18px; }
|
||||||
|
.opportunities-extension-projection-confirm p { margin: 0 0 20px; font-size: 13px; line-height: 1.5; }
|
||||||
|
.opportunities-extension-projection-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.opportunities-extension-projection-cancel { padding: 8px 12px; border: 1px solid #8b8580; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
|
||||||
|
.opportunities-extension-projection-error { color: #b3261e; font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-error { color: #ffb4ab; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-alert,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-summary,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #ffd080; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table thead th { background: #34312e; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table th,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table td { border-color: #504b46; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] { background: #3e3425; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-prorated,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-footer { color: #c9c3bc; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.opportunities-extension-projection-context { grid-template-columns: max-content minmax(0, 1fr); }
|
||||||
|
.opportunities-extension-projection-dialog > header { padding: 14px 12px; }
|
||||||
|
.opportunities-extension-projection-body { padding: 12px 12px 0; }
|
||||||
|
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 8px 6px; }
|
||||||
|
.opportunities-extension-projection-footer { padding: 12px; }
|
||||||
|
}
|
||||||
63
src/features/products/ramp-projection.js
Normal file
63
src/features/products/ramp-projection.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
function createRampProjection() {
|
||||||
|
function number(value) {
|
||||||
|
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
|
||||||
|
const result = Number(value);
|
||||||
|
return Number.isFinite(result) ? result : null;
|
||||||
|
}
|
||||||
|
function startDate(value) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/.exec(String(value ?? ""));
|
||||||
|
if (!match) return null;
|
||||||
|
const [, year, month, day] = match.map(Number);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
|
||||||
|
return { year, month, day, days: new Date(Date.UTC(year, month, 0)).getUTCDate() };
|
||||||
|
}
|
||||||
|
function monthlyAmount(annual, ramp, month) {
|
||||||
|
if (month < 1) return 0;
|
||||||
|
// calculadora rampa.xlsx, rampas!D6:P17: exact values, before currency rounding.
|
||||||
|
return (2 * annual / (24 - ramp)) * Math.min(month / (ramp + 1), 1);
|
||||||
|
}
|
||||||
|
function cents(value) { return Math.round((value + Number.EPSILON * Math.abs(value)) * 100); }
|
||||||
|
function compare(product) {
|
||||||
|
const workload = product?.workload;
|
||||||
|
const unavailable = reason => ({ status: "unavailable", reason, rows: [], divergentCount: 0 });
|
||||||
|
if (!workload) return unavailable("Workload data is not available for this product.");
|
||||||
|
const annual = number(workload.adjustedACR);
|
||||||
|
const ramp = number(product.RampMonths_c);
|
||||||
|
const start = startDate(workload.consumptionStartDate);
|
||||||
|
if (annual === null || annual < 0) return unavailable("Annual value (adjustedACR) is missing or invalid.");
|
||||||
|
if (ramp === null || !Number.isInteger(ramp) || ramp < 0 || ramp > 11) {
|
||||||
|
return unavailable("RampMonths_c must be an integer from 0 to 11, as covered by the reference workbook.");
|
||||||
|
}
|
||||||
|
if (!start) return unavailable("Consumption start date is missing or invalid.");
|
||||||
|
if (!Array.isArray(workload.forecast) || !workload.forecast.length) return unavailable("Monthly forecast data is not available.");
|
||||||
|
const periods = new Set();
|
||||||
|
const rows = workload.forecast.map(forecast => {
|
||||||
|
const year = number(forecast?.year), month = number(forecast?.month);
|
||||||
|
const actual = number(forecast?.adjustedConsumptionAmount);
|
||||||
|
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12) return null;
|
||||||
|
const key = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
if (periods.has(key)) return null;
|
||||||
|
periods.add(key);
|
||||||
|
const index = (year - start.year) * 12 + month - start.month + 1;
|
||||||
|
const prorated = index === 1 && start.day > 1;
|
||||||
|
const exact = monthlyAmount(annual, ramp, index) * (index === 1 ? (start.days - start.day + 1) / start.days : 1);
|
||||||
|
const calculatedCents = cents(exact);
|
||||||
|
const actualCents = actual === null ? null : cents(actual);
|
||||||
|
if (!Number.isSafeInteger(calculatedCents) || (actualCents !== null && !Number.isSafeInteger(actualCents))) return null;
|
||||||
|
const differenceCents = actualCents === null ? null : actualCents - calculatedCents;
|
||||||
|
return { key, year, month, index, prorated, actual: actualCents === null ? null : actualCents / 100,
|
||||||
|
calculated: calculatedCents / 100, difference: differenceCents === null ? null : differenceCents / 100,
|
||||||
|
divergent: differenceCents !== null && differenceCents !== 0 };
|
||||||
|
});
|
||||||
|
if (rows.some(row => row === null)) return unavailable("Forecast contains invalid or duplicate months, or values outside the supported numeric range.");
|
||||||
|
rows.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
const divergentCount = rows.filter(row => row.divergent).length;
|
||||||
|
const missingCount = rows.filter(row => row.actual === null).length;
|
||||||
|
return { status: divergentCount ? "divergent" : missingCount ? "unavailable" : "matched",
|
||||||
|
reason: missingCount ? "Some months have no adjusted consumption amount." : "", rows, divergentCount, missingCount,
|
||||||
|
annual, ramp, start: `${start.year}-${String(start.month).padStart(2, "0")}-${String(start.day).padStart(2, "0")}`,
|
||||||
|
firstMonthDays: start.days - start.day + 1, firstMonthTotalDays: start.days };
|
||||||
|
}
|
||||||
|
return { compare, monthlyAmount };
|
||||||
|
}
|
||||||
51
src/features/products/workloads-cache.js
Normal file
51
src/features/products/workloads-cache.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
|
||||||
|
const KEY = "opportunitiesExtension.workloadsCache.v1";
|
||||||
|
let cachePromise = null;
|
||||||
|
let writePromise = Promise.resolve();
|
||||||
|
let generation = 0;
|
||||||
|
const inFlight = new Map();
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
if (!cachePromise) cachePromise = storageLocalGet(KEY)
|
||||||
|
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
|
||||||
|
.catch(() => ({}));
|
||||||
|
return cachePromise;
|
||||||
|
}
|
||||||
|
function write(cache) {
|
||||||
|
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
|
||||||
|
return writePromise;
|
||||||
|
}
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
inFlight.clear();
|
||||||
|
cachePromise = Promise.resolve({});
|
||||||
|
await write({});
|
||||||
|
}
|
||||||
|
async function get(customerId, token, maxAgeMs, principal) {
|
||||||
|
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
|
||||||
|
// isMyWorkload is user-specific. Never persist a result without a known principal.
|
||||||
|
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
|
||||||
|
const key = JSON.stringify([scope, customerId]);
|
||||||
|
const currentGeneration = generation;
|
||||||
|
const cache = await read();
|
||||||
|
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
|
||||||
|
const entry = cache[key];
|
||||||
|
const age = Date.now() - Number(entry?.fetchedAt);
|
||||||
|
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
|
||||||
|
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
|
||||||
|
}
|
||||||
|
if (!inFlight.has(key)) {
|
||||||
|
const flight = fetchAllWorkloads(customerId, token).then(async result => {
|
||||||
|
const cachedAt = Date.now();
|
||||||
|
if (generation === currentGeneration) {
|
||||||
|
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
|
||||||
|
await write(cache).catch(() => {});
|
||||||
|
}
|
||||||
|
return { ...result, cachedAt, fromCache: false };
|
||||||
|
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
|
||||||
|
inFlight.set(key, flight);
|
||||||
|
}
|
||||||
|
return inFlight.get(key);
|
||||||
|
}
|
||||||
|
return { get, invalidate };
|
||||||
|
}
|
||||||
78
src/features/products/workloads.js
Normal file
78
src/features/products/workloads.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
function createProductWorkloads({ productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, getPrincipal, queueProductsTableRender }) {
|
||||||
|
let cycle = null;
|
||||||
|
function reset() {
|
||||||
|
cycle = null;
|
||||||
|
productsContentState.workloadCustomerIds = [];
|
||||||
|
productsContentState.workloadsByCustomer = new Map();
|
||||||
|
}
|
||||||
|
function isCurrent(current) {
|
||||||
|
return cycle === current && current.requestId === productsContentState.currentProductsRequestId
|
||||||
|
&& current.version === opportunitiesQueryState.periodRequestVersion;
|
||||||
|
}
|
||||||
|
function joinProducts(optyNumber) {
|
||||||
|
if (!cycle || !isCurrent(cycle)) return;
|
||||||
|
const key = String(optyNumber);
|
||||||
|
const products = productsContentState.opptyProductsByNumber.get(key);
|
||||||
|
if (!products) return;
|
||||||
|
const customerId = cycle.customerByOpportunity.get(key);
|
||||||
|
const record = productsContentState.workloadsByCustomer.get(customerId);
|
||||||
|
products.items = products.items.map(product => ({ ...product,
|
||||||
|
workload: record?.status === "ready" ? record.byId.get(String(product.RevnId ?? "")) || null : null }));
|
||||||
|
}
|
||||||
|
async function load(opportunities, requestId) {
|
||||||
|
reset();
|
||||||
|
const current = { requestId, version: opportunitiesQueryState.periodRequestVersion,
|
||||||
|
customerByOpportunity: new Map(), opportunitiesByCustomer: new Map() };
|
||||||
|
cycle = current;
|
||||||
|
for (const opportunity of opportunities) {
|
||||||
|
const customerId = String(opportunity?.CustomerAccount?.PartyId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(customerId)) continue;
|
||||||
|
if (!current.opportunitiesByCustomer.has(customerId)) current.opportunitiesByCustomer.set(customerId, new Set());
|
||||||
|
if (opportunity.OptyNumber) {
|
||||||
|
const number = String(opportunity.OptyNumber);
|
||||||
|
current.customerByOpportunity.set(number, customerId);
|
||||||
|
current.opportunitiesByCustomer.get(customerId).add(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = [...current.opportunitiesByCustomer.keys()];
|
||||||
|
productsContentState.workloadCustomerIds = ids;
|
||||||
|
const records = productsContentState.workloadsByCustomer;
|
||||||
|
for (const id of ids) records.set(id, { status: "loading", workloads: [], byId: new Map() });
|
||||||
|
if (!ids.length || !isCurrent(current)) return;
|
||||||
|
const principal = getPrincipal();
|
||||||
|
const cacheMaxAgeMs = preferencesState.opptyProductsCacheHours * 60 * 60 * 1000;
|
||||||
|
try {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
if (!isCurrent(current)) return;
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (isCurrent(current) && next < ids.length) {
|
||||||
|
const customerId = ids[next++];
|
||||||
|
const record = records.get(customerId);
|
||||||
|
try {
|
||||||
|
const result = await sendRuntimeMessage({ type: messageTypes.requestWorkloads, customerId,
|
||||||
|
authorization: authorizations?.workloadActions, cacheMaxAgeMs, principal });
|
||||||
|
if (!isCurrent(current)) return;
|
||||||
|
appendBackgroundRequestLog(result?.debugRequests);
|
||||||
|
if (!result?.ok || !Array.isArray(result.workloads)) throw new Error(result?.error || "Unable to load workloads.");
|
||||||
|
Object.assign(record, { status: "ready", workloads: result.workloads,
|
||||||
|
byId: new Map(result.workloads.map(workload => [String(workload.id), workload])),
|
||||||
|
fromCache: Boolean(result.fromCache), cachedAt: Number(result.cachedAt) || 0 });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent(current)) return;
|
||||||
|
record.status = "error";
|
||||||
|
record.error = error.message || "Unable to load workloads.";
|
||||||
|
}
|
||||||
|
for (const number of current.opportunitiesByCustomer.get(customerId)) joinProducts(number);
|
||||||
|
queueProductsTableRender();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker));
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent(current)) return;
|
||||||
|
for (const record of records.values()) { record.status = "error"; record.error = error.message || "Unable to load workloads."; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { load, joinProducts, reset };
|
||||||
|
}
|
||||||
@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
try {
|
try {
|
||||||
const planNumber = String(plan.subPlanNum ?? "").trim();
|
const planNumber = String(plan.subPlanNum ?? "").trim();
|
||||||
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
||||||
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
|
if (!pending.has(planNumber)) {
|
||||||
|
const promise = request(planNumber).finally(() => { if (pending.get(planNumber) === promise) pending.delete(planNumber); });
|
||||||
|
pending.set(planNumber, promise);
|
||||||
|
}
|
||||||
handle.showItems(await pending.get(planNumber));
|
handle.showItems(await pending.get(planNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handle.showError(error.message || "Unable to load rate card.");
|
handle.showError(error.message || "Unable to load rate card.");
|
||||||
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
button.addEventListener("click", () => showRatecard(plan, button));
|
button.addEventListener("click", () => showRatecard(plan, button));
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createRatecardButton, showRatecard };
|
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,3 +139,8 @@
|
|||||||
.opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton,
|
.opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton,
|
||||||
.opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; }
|
.opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-ratecard-toolbar { flex-wrap: wrap; }
|
||||||
|
.opportunities-extension-ratecard-order-filter { position: relative; min-width: 0; }
|
||||||
|
.opportunities-extension-ratecard-order-filter .opportunities-extension-customer-filter-trigger { min-height: 38px; padding-block: 8px; font-size: 13px; }
|
||||||
|
.opportunities-extension-ratecard-order-filter .opportunities-extension-customer-filter-panel { width: min(320px, calc(100vw - 80px)); max-height: 45vh; overflow: auto; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, environment }) {
|
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, tableSorting, environment }) {
|
||||||
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
|
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let closing = null;
|
let closing = null;
|
||||||
@@ -65,12 +65,80 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
search.setAttribute("aria-label", "Search rate card");
|
search.setAttribute("aria-label", "Search rate card");
|
||||||
const count = element("span", "count", "Loading rate card...");
|
const count = element("span", "count", "Loading rate card...");
|
||||||
count.setAttribute("role", "status");
|
count.setAttribute("role", "status");
|
||||||
toolbar.append(search, count);
|
const orderFilter = element("div", "order-filter");
|
||||||
|
const filterElement = (tag, suffix, text) => {
|
||||||
|
const node = element(tag, null, text);
|
||||||
|
node.className = `opportunities-extension-customer-filter-${suffix}`;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
const orderButton = filterElement("button", "trigger", "All order numbers");
|
||||||
|
orderButton.type = "button";
|
||||||
|
orderButton.disabled = true;
|
||||||
|
orderButton.setAttribute("aria-label", "Filter by Order Number");
|
||||||
|
orderButton.setAttribute("aria-expanded", "false");
|
||||||
|
const orderPanel = filterElement("section", "panel");
|
||||||
|
orderPanel.id = "opportunities-extension-ratecard-orders";
|
||||||
|
orderButton.setAttribute("aria-controls", orderPanel.id);
|
||||||
|
orderPanel.hidden = true;
|
||||||
|
const orderSearch = filterElement("input", "search");
|
||||||
|
orderSearch.type = "search";
|
||||||
|
orderSearch.placeholder = "Search order numbers";
|
||||||
|
orderSearch.setAttribute("aria-label", "Search order numbers");
|
||||||
|
const allLabel = filterElement("label", "select-all");
|
||||||
|
const allOrders = element("input");
|
||||||
|
allOrders.type = "checkbox";
|
||||||
|
allLabel.append(allOrders, element("span", null, "Select all"));
|
||||||
|
const orderList = filterElement("div", "list");
|
||||||
|
orderPanel.append(orderSearch, allLabel, orderList);
|
||||||
|
orderFilter.append(orderButton, orderPanel);
|
||||||
|
toolbar.append(search, orderFilter, count);
|
||||||
|
let orders = [], selectedOrders = new Set();
|
||||||
|
const orderKey = item => String(item.orderNumber ?? "").trim();
|
||||||
|
function closeOrders() {
|
||||||
|
orderPanel.hidden = true;
|
||||||
|
orderButton.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
function updateOrders() {
|
||||||
|
const all = orders.length > 0 && selectedOrders.size === orders.length;
|
||||||
|
allOrders.checked = all;
|
||||||
|
allOrders.indeterminate = selectedOrders.size > 0 && !all;
|
||||||
|
orderButton.textContent = all ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
|
||||||
|
orderList.textContent = "";
|
||||||
|
const query = orderSearch.value.trim().toLowerCase();
|
||||||
|
orders.filter(value => (value || "Not provided").toLowerCase().includes(query)).forEach(value => {
|
||||||
|
const label = filterElement("label", "option");
|
||||||
|
const checkbox = element("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.checked = selectedOrders.has(value);
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
|
||||||
|
allOrders.checked = selectedOrders.size === orders.length;
|
||||||
|
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
|
||||||
|
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
label.append(checkbox, element("span", null, value || "Not provided"));
|
||||||
|
orderList.append(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
orderButton.addEventListener("click", () => {
|
||||||
|
orderPanel.hidden = !orderPanel.hidden;
|
||||||
|
orderButton.setAttribute("aria-expanded", String(!orderPanel.hidden));
|
||||||
|
if (!orderPanel.hidden) orderSearch.focus();
|
||||||
|
});
|
||||||
|
orderSearch.addEventListener("input", updateOrders);
|
||||||
|
allOrders.addEventListener("change", () => {
|
||||||
|
selectedOrders = new Set(allOrders.checked ? orders : []);
|
||||||
|
updateOrders();
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
root.addEventListener("click", event => { if (!orderFilter.contains(event.target)) closeOrders(); });
|
||||||
const viewport = element("div", "viewport");
|
const viewport = element("div", "viewport");
|
||||||
viewport.tabIndex = 0;
|
viewport.tabIndex = 0;
|
||||||
viewport.setAttribute("role", "region");
|
viewport.setAttribute("role", "region");
|
||||||
viewport.setAttribute("aria-label", "Rate card items");
|
viewport.setAttribute("aria-label", "Rate card items");
|
||||||
const table = element("table", "table");
|
const table = element("table", "table");
|
||||||
|
table.setAttribute("data-table-layout", "standard");
|
||||||
const caption = element("caption", null, "Rate card items");
|
const caption = element("caption", null, "Rate card items");
|
||||||
caption.className = "opportunities-extension-visually-hidden";
|
caption.className = "opportunities-extension-visually-hidden";
|
||||||
const colgroup = element("colgroup");
|
const colgroup = element("colgroup");
|
||||||
@@ -78,6 +146,9 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
const headRow = element("tr");
|
const headRow = element("tr");
|
||||||
const sortHeaders = [];
|
const sortHeaders = [];
|
||||||
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
|
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
|
||||||
|
const savedSort = tableSorting.read("rate-card", columns);
|
||||||
|
current.sortKey = savedSort.key;
|
||||||
|
current.direction = savedSort.direction === "descending" ? "desc" : "asc";
|
||||||
active = current;
|
active = current;
|
||||||
background.inert = true;
|
background.inert = true;
|
||||||
const alive = () => active === current && root.isConnected && !parent.hidden;
|
const alive = () => active === current && root.isConnected && !parent.hidden;
|
||||||
@@ -87,6 +158,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
colgroup.append(col);
|
colgroup.append(col);
|
||||||
const th = element("th");
|
const th = element("th");
|
||||||
th.scope = "col";
|
th.scope = "col";
|
||||||
|
if (column.type === "price") th.setAttribute("data-align", "money");
|
||||||
th.setAttribute("aria-sort", "none");
|
th.setAttribute("aria-sort", "none");
|
||||||
const sort = element("button", "sort");
|
const sort = element("button", "sort");
|
||||||
sort.type = "button";
|
sort.type = "button";
|
||||||
@@ -98,6 +170,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
sort.addEventListener("click", () => {
|
sort.addEventListener("click", () => {
|
||||||
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
|
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
|
||||||
current.sortKey = column.key;
|
current.sortKey = column.key;
|
||||||
|
tableSorting.save("rate-card", { key: current.sortKey, direction: current.direction === "desc" ? "descending" : "ascending" });
|
||||||
updateSortHeaders();
|
updateSortHeaders();
|
||||||
if (current.status === "ready") renderRows();
|
if (current.status === "ready") renderRows();
|
||||||
});
|
});
|
||||||
@@ -106,6 +179,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
headRow.append(th);
|
headRow.append(th);
|
||||||
});
|
});
|
||||||
head.append(headRow);
|
head.append(headRow);
|
||||||
|
updateSortHeaders();
|
||||||
const body = element("tbody");
|
const body = element("tbody");
|
||||||
table.append(caption, colgroup, head, body);
|
table.append(caption, colgroup, head, body);
|
||||||
viewport.append(table);
|
viewport.append(table);
|
||||||
@@ -134,7 +208,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
function renderRows() {
|
function renderRows() {
|
||||||
if (!alive()) return;
|
if (!alive()) return;
|
||||||
body.textContent = "";
|
body.textContent = "";
|
||||||
const visible = selectItems(current.items, search.value, current.sortKey, current.direction);
|
const visible = selectItems(current.items.filter(item => selectedOrders.has(orderKey(item))), search.value, current.sortKey, current.direction);
|
||||||
count.textContent = `${visible.length} of ${current.items.length} items`;
|
count.textContent = `${visible.length} of ${current.items.length} items`;
|
||||||
if (!visible.length) {
|
if (!visible.length) {
|
||||||
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
|
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
|
||||||
@@ -143,6 +217,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
const row = element("tr");
|
const row = element("tr");
|
||||||
columns.forEach(column => {
|
columns.forEach(column => {
|
||||||
const cell = element("td", null, formatValue(item, column));
|
const cell = element("td", null, formatValue(item, column));
|
||||||
|
if (column.type === "price") cell.setAttribute("data-align", "money");
|
||||||
if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric";
|
if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric";
|
||||||
row.append(cell);
|
row.append(cell);
|
||||||
});
|
});
|
||||||
@@ -167,9 +242,12 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (!orderPanel.hidden) { closeOrders(); orderButton.focus(); return; }
|
||||||
closeRatecardDrawer();
|
closeRatecardDrawer();
|
||||||
} else if (event.key === "Tab") {
|
} else if (event.key === "Tab") {
|
||||||
const controls = [close, search, viewport, ...headRow.querySelectorAll("button")];
|
const controls = [close, search, ...(!orderButton.disabled ? [orderButton] : []),
|
||||||
|
...(!orderPanel.hidden ? [orderSearch, allOrders, ...orderList.querySelectorAll("input")] : []),
|
||||||
|
viewport, ...headRow.querySelectorAll("button")];
|
||||||
const index = controls.indexOf(document.activeElement);
|
const index = controls.indexOf(document.activeElement);
|
||||||
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
|
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -191,6 +269,10 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
showItems(items) {
|
showItems(items) {
|
||||||
if (!alive()) return;
|
if (!alive()) return;
|
||||||
current.items = items;
|
current.items = items;
|
||||||
|
orders = [...new Set(items.map(orderKey))].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
||||||
|
selectedOrders = new Set(orders);
|
||||||
|
orderButton.disabled = !orders.length;
|
||||||
|
updateOrders();
|
||||||
current.status = "ready";
|
current.status = "ready";
|
||||||
panel.setAttribute("aria-busy", "false");
|
panel.setAttribute("aria-busy", "false");
|
||||||
renderRows();
|
renderRows();
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
if (!pending.has(partyNumber)) {
|
||||||
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
|
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||||
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createServiceRequestsButton };
|
return { createServiceRequestsButton, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,5 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
|
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
|
||||||
}
|
}
|
||||||
|
.opportunities-extension-service-requests-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-service-requests-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user