# PRD — bitool_frontend

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/bitool_frontend.git`, branch `master` at `937e09cb` (2026-09-22).

Silicon Ark had no prior PRD in `bitool_frontend/`. `bi_tool_frontend_phase6/prd.md` describes branch `phase_6`, not this default branch. The source repo has `README.md` (Create React App template plus a short session note), `DEVELOPER_DOCUMENTATION.md`, `CHANGELOG.md`, and `NewKnowledgeBase.md`. Where those disagree with `master`, the requirement states the code.

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned**, **Unknown**.

## Product purpose

**Documented** (Home copy in `src/pages/Home.js`, `DEVELOPER_DOCUMENTATION.md`): SAWIS BI for the South African wine industry. The Home vision text says the aim is to be the indispensable data technology resource to the wine industry, with online, real-time access to relevant information.

**Implemented on `master`:** a public report browser for exports, grape production, and vineyard status, plus a signed-in layer for currency, an RSA exports report, and price ranges.

**Unknown / Requires Confirmation:** whether production users are on `master` or on `qa` (domestic sales and admin). Organisational policy for who may see value data is not in this repo. The UI gates are below.

## Users and roles

`master` has two client states. It does not read `admin_role`. Role numbers 0–4 are documented for `phase_6` in `bi_tool_frontend_phase6/prd.md` and are not in this branch.

| Role | What they can do | Evidence | Provenance |
|---|---|---|---|
| Visitor | Home, Exports overview and volume tables, Grape Production, Vineyard Status. Sees a Value tab and a Price Range tab that ask them to log in. No Reports tab | `AppRoutes.js`, `Reports.js`, `PricesRanges.jsx`, `LogoutValue.jsx` | **Implemented** |
| Signed-in user | Same pages. Exports tab label becomes "Volume / Value". Currency columns on volume tables. Reports tab. Price Range tables (up to five) saved against their email. Navbar shows the part of the email before `@` | `Reports.js`, `Tables.jsx`, `PricesRanges.jsx`, `Navbar.js`, `priceRangeStore.js` | **Implemented** |
| Registered SAWIS account holder | Creates an account or resets a password on `www.sawis.co.za`, outside this app | `LoginForm.jsx` links | **Documented** in the link targets. Those sites were not inspected |

There is no route guard. Anyone who can open the SPA can open every path. Signed-in behaviour is a different set of components and `/auth/` URLs.

## Main workflows

### W1 — Open the app and resume a session

Trigger: `src/index.js` renders `App`. `authStore.js` calls `autoLogin()` before the first React render.

Steps:

1. If cookies `jwtToken` and `biUser` are both present, set `user` to `{ email, token }`.
2. Else if `document.cookie` contains `:::`, split on `+` and read token and email from the `:::` fields. **Implemented** legacy parser. Whether any current host still sets that cookie is **Unknown**.
3. Google Analytics 4 (`G-1RFV9ZJG9K`) initialises after 3 seconds. Failure is logged and does not block the app.
4. The service worker stays unregistered (`serviceWorkerRegistration.unregister()`).

Output: navbar shows "Login" or the email local-part.

### W2 — Read the public home page

Trigger: visitor opens `/`.

The page shows a banner, three cards (Export Reports to `/reports`, Grape Production to `/grape-production`, Vineyard Status to `/vineyard-status`), and the SAWIS vision, promise, and four values (Competitive, Confidentiality, Confidence, Creative).

Provenance: **Documented** in the page copy and **Implemented** in `Home.js`.

### W3 — Sign in

Trigger: visitor opens the navbar modal and completes the sign-in form.

Inputs:

- Email (input type `email`, required). Placeholder text is "Username".
- Password (required).
- Google reCAPTCHA checkbox. The Sign In button stays disabled until `onChange` returns a value.
- Disclaimer. Sign In reveals the disclaimer. Accept calls login with `typeOfLogin` `1`. Cancel closes the modal.

Request: `POST {REACT_APP_API_BASE_URL}/loginAuth` with JSON body `{ email, password, typeOfLogin }`. The reCAPTCHA value is not in that body. No `Content-Type` header is set. No `Authorization` header.

Success (`res.ok` and JSON): store `user`, set cookies `jwtToken` and `biUser` (`path=/`, no `Secure`, `SameSite`, or `Max-Age` in the assignment), set `disclaimerAccepted` for 30 days, close the open dialog.

Failure: return `{ success: false, message }` and show `error.message` ("Failed to log in" when the response is not ok). The form does not map API error text beyond that.

The Sign In button is a submit button inside the form, and the form `onSubmit` is `handleLogin` without a login type. A submit therefore calls login with `typeOfLogin` undefined as well as opening the disclaimer. **Requires confirmation** whether the API accepts that first call.

Forgot password opens `https://www.sawis.co.za/index.php`. Create account opens `https://www.sawis.co.za/register.php?l=en`.

Provenance: **Implemented** in `LoginForm.jsx` and `authStore.js`. The disclaimer sentences are **Documented** in the component and again in the footer modal (`Layout.js`).

### W4 — Sign out

Trigger: signed-in user confirms "Yes" on the sign-out modal.

`logout()` removes `disclaimerAccepted`, expires `jwtToken` and `biUser`, and sets `user` to null. Exports returns to the guest tab set because `Reports.js` resets `activeTab` to `overview` when the token changes.

Provenance: **Implemented**.

### W5 — Browse export overview and volume

Trigger: user opens `/reports`. `Sidebar.js` loads filters. When `filters.Year.selectedValue` is set, `Reports.js` calls `getReportingData`.

Filter load:

- Guest: `GET {API}/getFilters`.
- Signed-in, and `REACT_APP_USE_AUTH_HEADERS` is not `"false"`: `GET {API}/auth/getFilters` with `Authorization: <token>`.

Initial filter state from that response (`filterStore.getAvailableFilters`):

| Filter | Initial value |
|---|---|
| Producer / Role Player Details | `["Industry"]` when that option exists, otherwise `[]` |
| Year | `LatestYearMonth[0].LatestYear` |
| Ending Month | month name at `LatestMonth` (1-based index into the sorted month list) |
| Reporting Period (Months) | `12` (slider min 1, max 12) |
| Product Type, Country, Package Type, Cultivar/Type | empty arrays |

Changing a dropdown updates local sidebar state. The store, and therefore the charts, update when the user clicks Apply. Clear writes immediately: producer becomes the first option, year becomes the first option, ending month becomes `defaultMonth`, period becomes 12, and the four multi-selects become empty. Select-all countries writes immediately and does not wait for Apply (`Sidebar.js` `selectAllCountries`).

Reporting payload (POST JSON):

- `producer_details`: selected list, or `["Industry"]` when the selection is missing.
- `year`: integer.
- `comparisonYear`: store `compareToYear`.
- `month`: integer parsed from the ending-month value, or from the first token before a space.
- `period`: slider value.
- `productTypeFilterList`, `packageTypeFilterList`, `cultivarFilterList`: empty when every option is selected, otherwise the selected objects. **Inferred** that an empty list means "all" for those three. Country always sends the selected list, including an empty selection.
- A duplicate in-flight request with the same payload and the same signed-in/guest flag is not sent again.

Parallel requests:

| Path | Auth prefix | Used for |
|---|---|---|
| `POST /getChartData` | no | Export market per product type chart |
| `POST /getYearOverYearComparison` | no | Export market volume by product type chart |
| `POST /getTotale_Liters` | no | Total, packaged, and bulk litre tiles |
| `POST /getExports` | no | Exports chart |
| `POST /getCultivarChartData` | no | Cultivar / type chart |
| `POST /getTop25Data` | no | Top export countries chart |
| `POST /tables/getExportMarketPerProductTypeTableData` | `/auth` when signed in | First volume table |
| `POST /tables/getTOP25TableData` | `/auth` when signed in | Top countries table |
| `POST /tables/getExportMarketVolumeByProductTypeTableData` | `/auth` when signed in | Volume by product type |
| `POST /tables/getCultivarTypeExportsTableData` | `/auth` when signed in | Cultivar table |
| `POST /tables/getExportsTableData` | `/auth` when signed in | Exports table |

Leaving `/reports` aborts reporting, filter, and price-range requests.

Overview tiles and charts (`Overview.jsx`): Total Export (l), Packaged (l), Bulk (l), then the five chart titles above. Package subtitle is the single selected package type, or `PACKAGED/BULK` when the selection is not exactly one value.

Volume tables (`Tables.jsx`), in order:

1. Export market per product type (L) — country by cultivar. A signed-in user whose producer selection is non-empty and does not include `Industry` gets `ProducerCompareYearPivotTable`. Any other signed-in user gets `CompareYearPivotTable`. A guest gets `PivotTable`.
2. Exports (L) — package type by period. `ProducerPivotTable` when the producer selection is non-empty and does not include `Industry`. Otherwise `PivotTable`. This split does not check `user`.
3. Cultivar / Type Exported (L) — product type by period. Same producer split as item 2.
4. Export market volume by product type (L) — period by product type. Same producer split as item 2.
5. Top Export Countries as countries selected (L) — country by product type. Metric label "Total Litres". Same producer split as item 2.

Signed-in table headings append "for selected currencies". A banner tells the signed-in user to enable currencies from the table options. Guests see "Log in to compare volume data with currencies". The separate Value tab (`LogoutValue.jsx`) is only rendered when `user` is absent and contains that same prompt, with no table.

Each table can be downloaded to Excel.

Provenance: **Implemented**. The "all options means empty filter list" rule is **Inferred** from the payload builder.

### W6 — Price range (signed in)

Trigger: signed-in user opens the Price Range tab. The sidebar closes for this tab (`Reports.js`).

1. `POST /auth/getPriceRangeFilterHistory` with `{ email }`.
2. If the body parses to an array, it replaces the in-memory tables. The string `"null"` or a non-array leaves the seed row in place.
3. `POST /auth/getPriceRangeData` once per table, body `{ newPRFilters }`.

Seed row before history returns: year 2023, month 1, end year 2023, end month 12, cultivar Chardonnay, country UNITED KINGDOM, currency Rand, `group_by_year` `"0"`. Year options kept from the filter response are those with `YearName >= 2014` (`setPriceRangeOptions`, and only when `getAvailableFilters` was called with a user).

The tab copy says the user can add up to five tables. At five, the add button is replaced by a limit message. Apply on a table saves history (`POST /auth/setPriceRangeFilterHistory`) and reloads. Delete does the same with the row removed.

Guests see "Log in to compare price ranges for exports" and no request runs (`PricesRanges.jsx` returns early without a token).

Excel download name includes the period and "Yearly" when `group_by_year === "1"`, otherwise "Monthly".

Provenance: **Implemented**.

### W7 — RSA exports report (signed in)

Trigger: signed-in user opens the Reports tab. `GeneratedReports.jsx` renders nothing when `user` is absent.

`GeneratedReportPivotTable.jsx`:

- On entering the tab, `GET /auth/getCountryGroup` and `POST /auth/reports/getRSAExportsReport`.
- Title: "All Countries Individual Countries or Countries Groups".
- The user can compare years and switch between selected countries and country groups.
- A response with `data.messages` clears the table and shows `data.messages.error`.
- If filters change while the tab is not active and a report is already on screen, the note becomes "\*Filters have been changed, please generate report again."
- Excel file name: `All Countries Individual Countries or Countries Groups.xlsx`.

`Authorization` comes from `authHeaders(user)` (`Authorization: token` when a token exists). This call does not use `checkUnauthorizedAndLogout`. A 401 on this report does not follow the session rule in `authSession.js`.

Provenance: **Implemented**.

### W8 — Grape production and vineyard status

Trigger: user opens `/grape-production` or `/vineyard-status`. The two pages are the same structure with different endpoints.

1. `GET /getGrapeProductionFilters` or `GET /getVineyardStatusFilters`. No auth header.
2. If the JSON array is empty or the request throws, use `filtersFallback` in `src/lib/constants.js` (Year 2021–2025, a fixed region list, a long cultivar list, age bands, and White/Red).
3. Initial selection: single-selects take the first value; Year takes the previous calendar year when that value is in the list, otherwise the first value; other multi-selects start empty.
4. `GET /getGrapeProductionReports` or `GET /getVineyardStatusReports` runs only after `filters.length > 0`.
5. Each item is a `GenericPivotTable`. The first item auto-generates once on mount: `POST {API}/{item.URL}` with the selected filters. Keys are lower-cased (`Year` becomes `year`). Later Apply changes regenerate tables that have already generated.
6. The user can Generate Report, Clear, and, when the payload has both `WithSultana` and `WithoutSultana` keys, toggle sultana. Notes in parentheses render in red.
7. Sidebar Apply commits local selections. Clear on the Year filter, and Reset, set Year to the previous calendar year (`getFullYear() - 1`) and reset other fields. Multi-select `max` of `0` means unlimited; a positive `max` blocks further picks (`SidebarFilters.jsx`).

These requests do not send the JWT and do not call `checkUnauthorizedAndLogout`.

Provenance: **Implemented**. Report names and URLs come from the API. They are not hard-coded, so the catalogue of grape and vineyard reports is **Unknown** until the API responds. The fallback cultivar list is a client safety net, not evidence of the live catalogue.

### W9 — Session expiry while reporting

Trigger: a reporting or price-range response has HTTP status 401 while `user` is set.

`checkUnauthorizedAndLogout` logs out once and alerts "Your session has expired, please log in again." A second 401 in the same turn does not alert again.

HTTP 403, 404, 500, network failures, JSON parse failures, and aborted requests do not log the user out. `authSession.test.js` covers 403, 404, 500, a single 401, and concurrent 401s.

Provenance: **Documented** in `README.md`, `CHANGELOG.md`, and `NewKnowledgeBase.md`, and **Implemented** in `authSession.js`, `filterStore.js`, and `priceRangeStore.js`.

## Integrations

| System | Purpose | Direction | Auth | Failure |
|---|---|---|---|---|
| BI HTTP API | Filters, charts, tables, login, price range, RSA report, grape and vineyard reports | Browser to API | Raw JWT on selected `/auth/` calls. Login is anonymous | Reporting and price range: 401 logs out. Other errors log to the console and leave the session. Grape, vineyard, and the RSA report do not use the 401 helper |
| Google reCAPTCHA | Enable the Sign In button | Browser to Google | Site key `REACT_APP_RECAPTCHA_DATA_SITE_KEY` | Empty key in the committed `env` file. Widget behaviour with an empty key was not run |
| Google Analytics 4 | Page measurement | Browser to Google | Measurement id in source | `initialize()` errors are logged |
| sawis.co.za | Register and forgot password | Browser navigation | Unknown | Not handled in this repo |
| bi@sawis.co.za | Contact | `mailto:` | — | Mail client |
| dawineonline.co.za | Footer source attribution | Link | — | — |
| Label and BIQA deploy URLs | `master_deploy.php` triggers a remote `deploy.php` | This PHP file to those hosts | Query token. Values are not copied into Silicon Ark | Curl runs with SSL verification off. The BI-labelled case uses the BIQA URL |

## Constraints

- Client routes are public.
- Price-range UI stops at five tables.
- Export reporting period slider is 1 to 12 months.
- Price-range year options drop years before 2014.
- Chart number labels use K / M / B (`formatNumber`). Table integers use `fr-FR` grouping (`formatNumberForTable`). Totals use two decimals and a space as the thousands separator (`formatNumberForTableTotal`).
- `REACT_APP_USE_AUTH_HEADERS=false` omits the `Authorization` header. The `/auth/` path prefix still follows whether `user` is set.

## Requirements

### FR-001 — Public report navigation

The visitor can open Home, Exports, Grape Production, and Vineyard Status from the navbar and from the home cards, with no account.

Evidence: `AppRoutes.js`, `Navbar.js`, `Home.js`.

Implementation: **Implemented**. Confidence: High.

### FR-002 — Export filters drive overview and volume

After filters load, the Exports page requests the overview and table endpoints in W5. Apply commits sidebar edits. Clear restores the reset row described in W5.

Evidence: `Sidebar.js`, `filterStore.js`, `Reports.js`, `Overview.jsx`, `Tables.jsx`.

Implementation: **Implemented**. Confidence: High.

### FR-003 — Guest value and price range are prompts

A visitor sees the Value tab and the Price Range tab as login prompts. They do not receive currency tables or price-range data.

Evidence: `LogoutValue.jsx`, `PricesRanges.jsx`, `Reports.js`.

Implementation: **Implemented**. Confidence: High.

### FR-004 — Signed-in value, currencies, and RSA report

A signed-in user sees Volume / Value, currency controls on the volume tables, the Reports tab, and price-range data loaded and saved under their email.

Evidence: `Reports.js`, `Tables.jsx`, `GeneratedReports.jsx`, `priceRangeStore.js`.

Implementation: **Implemented**. Confidence: High. The exact currency list is returned by the API (`priceRangesList`), not hard-coded, except the seed currency "Rand".

### FR-005 — Login, disclaimer, and session cookie

Sign-in requires email, password, and a completed reCAPTCHA before the button enables. Accept sends `typeOfLogin: 1`. Success stores `jwtToken` and `biUser` and a 30-day `disclaimerAccepted` cookie.

Evidence: `LoginForm.jsx`, `authStore.js`.

Implementation: **Implemented**. Confidence: High. Whether the reCAPTCHA token must be verified by the API is **Unknown** — the client does not send it.

### FR-006 — Logout only on HTTP 401

Reporting and price-range failures clear the session only for HTTP 401, once per burst, with the expiry alert.

Evidence: `authSession.js`, `authSession.test.js`, `CHANGELOG.md`, `NewKnowledgeBase.md`.

Implementation: **Implemented**. Confidence: High. The RSA report, grape, and vineyard calls are outside this helper.

### FR-007 — Grape and vineyard reports

Each page loads filters, then a report list, and renders one pivot per list item. The first report generates on mount. Filters apply on Apply. A failed filter call uses `filtersFallback`.

Evidence: `GrapeProduction.js`, `VineyardStatus.js`, `grapeProductionStore.js`, `vineyardStatusStore.js`, `GenericPivotTable.jsx`, `SidebarFilters.jsx`, `constants.js`.

Implementation: **Implemented**. Confidence: High for the client flow. Report definitions: **Unknown** until the API responds.

### FR-008 — Excel download

Volume tables, the RSA report, grape and vineyard pivots, and price-range tables expose a download that writes an `.xlsx` from the on-screen table.

Evidence: `helpers.js` `downloadTableAsExcel`, `@linways/table-to-excel` usages in the pivot components, `PriceRange.jsx`.

Implementation: **Implemented**. Confidence: High.

### FR-009 — Disclaimer remains available

The footer opens the same disclaimer text without signing in. Login Accept is the path that sets `disclaimerAccepted`.

Evidence: `Layout.js`, `LoginForm.jsx`.

Implementation: **Implemented**. Confidence: High. The cookie is set on success and is not read to block the app, so a returning visitor is not asked again by this client.

## Planned behaviour

No roadmap file on `master` describes unbuilt work. `CHANGELOG.md` "Unreleased" describes the logout and audit changes that are already in `937e09cb`.

Domestic sales, admin roles, and the Vite build are **Implemented on `qa`**, not on `master`. See `project-plan.md`. They are not requirements of this default branch until someone confirms they should be.

## Unknown / requires confirmation

- Production, QA, and label hosts, and which git branch each runs.
- API error contract for login (the client shows a generic failure message).
- Whether `typeOfLogin` values other than `1` exist.
- Whether an empty product, package, or cultivar list means "all" on the server.
- The live grape and vineyard report catalogue.
- Whether the legacy `:::` cookie is still issued.
- Backend enforcement of the five-table price-range limit and of reCAPTCHA.
