Plan · Formulation - Formulas - Regulatory screening - Ticket 1 : Screening tab and Alerts view (frontend + backend)
Objective
A1: add a read-only Screening tab to the existing formula detail tabs. Extend FormulaDescriptionTabs with one entry; reuse RawMaterialPanel as is, SimpleDataTable for the raw materials x alerts matrix with runtime-built alert columns, and the existing getAlertLabels and getWarnings clients for data the backend already serves. Create, mock-first, only what nothing serves: screening types carrying MP-691's pre-mix and 'Not included' model locally, mock composition fixtures, pure aggregation and verdict libs, one mock api function (no apiClient, TODO(MP-727)) and one read-only hook, plus the screening components. Extract the raw-material panel host out of CompositionTab so both tabs share one exit lifecycle instead of a copy. Left out: the backend endpoint and the Java aggregation (Backend stage), any change to the live FormulaLine model, a standalone screening page, and the Origins and Natural indices views (MP-728).
Current state
Ready for reviewReady for your review in Jira (Ready for Mock Review) since 4 hours.
Next action
A person: Ready for review — non-blocking recommendations are available.
Accepted route
Mock stage · approach A1 · next: Mock builder
accepted
Scope and acceptance criteria
User interface
- A new screening folder in the formulas feature: types, mock fixtures, two pure lib modules, one mock api function, one hook and five components.
- Two existing components edited: formula-description-tabs.tsx gains a third tab entry, composition-tab.tsx hands its panel host to a new sibling component.
- No change to the formula detail page, to types/index.ts, to the composition grid's data layer or to anything under backend/.
API and backend
- None at this stage: no file under backend/ is touched and AC10's gradle gates are unaffected.
- Expected at the Backend stage of this ticket: GET /v1/formulas/{formulaId}/screening/raw-materials serving the aggregated list this mock defines, with the aggregation itself in the formula composition service.
- No new persistence: raw-material warnings and alert labels are already stored and already served.
Acceptance criteria
- The types compile and the fixtures cover a nested pre-mix, a raw material used both directly and via a pre-mix, a QSP line, an empty line and both 'Not included' levels; types/index.ts is untouched.
- On the fixtures the aggregation returns one row per raw material, pre-mixes expanded at every level, 'Not included' removed, the rest renormalised to 100 and sorted descending; the verdict and summary helpers follow AC7 and AC8.
- The api file imports no apiClient and carries TODO(MP-727) with the future endpoint; the hook returns rows, active alert labels, the full initial selection, derived cells, loading and error, reading labels and warnings from the existing clients.
- The view shows name, total percentage and phases plus one column per selected alert with wordless pictograms, hover detail, a verdict under each header and the single-alert sentence in the verdict colour.
- npm run build passes; Screening appears between Composition and History; clicking a name opens the shared panel with the table still visible; the Composition tab's panel opens, animates and closes exactly as before.
- id: RC1 · check: The formula detail page shows a Screening tab between Composition and History, it opens from ?tab=screening, and it renders the same read-only view whatever the grid mode and whatever the formula status (no edit affordance anywhere in the tab).
- id: RC2 · check: On the mock fixtures each raw material appears on exactly one row with its commercial name, total percentage and phases joined as 'A, C'; the empty line produces no row, the QSP line counts with its derived percentage, and rows are ordered by total percentage, highest first.
- id: RC3 · check: Pre-mix lines are expanded at every nesting level: each contained raw material contributes parent % x child % / 100, shows 'via pre-mix <code>', and a raw material used both directly and through a pre-mix has one row whose percentage is the sum of both contributions.
- id: RC4 · check: On an ordinary formula every 'Not included' line is dropped, including inside pre-mixes at any level; on the pre-mix fixture only the 'this pre-mix' lines are dropped and the 'final formula' ones are kept; after removal the remaining percentages total 100.
- id: RC5 · check: The alert selector lists the active alert labels from the real alert-label client, every one is selected when the view opens, each can be toggled, and leaving and returning to the tab restores the full selection rather than the previous one.
- id: RC6 · check: Every cell is a pictogram with no word, with the five distinct states GREEN, RED, QUESTION, NA and no-status-entered (an orange dashed marker clearly different from the orange question mark), and hovering a cell shows the status, the notes and the date of the warning's last update.
- id: RC7 · check: Each selected alert shows a verdict under its own column header: red when any raw material is RED, otherwise orange when any is QUESTION or has no status entered, otherwise green; NA alone stays green and no overall verdict across alerts is rendered anywhere.
- id: RC8 · check: With exactly one alert selected a sentence above the table counts the raw materials by status in the verdict's colour (red / to check / not entered out of the total); with none or several selected the sentence is absent.
- id: RC9 · check: Clicking a raw-material name opens the composition grid's RawMaterialPanel unchanged, with its tabs and its 'Open full page' link, while the Screening table stays visible beside it; closing it returns focus to the name button.
- id: RC10 · check: The Composition tab is unchanged in behaviour after the panel host extraction: opening, animating, closing with X or Escape, the reduced-motion path and focus returning to the opening cell all still work, and no composition grid logic moved with the host.
- id: RC11 · check: Mock discipline: api/formula-screening.ts and lib/screening-mock-data.ts import no @/shared/lib/api-client, the api function carries TODO(MP-727) naming GET /v1/formulas/{formulaId}/screening/raw-materials, and the alert labels and warnings come from the existing raw-materials clients rather than from mock data.
- id: RC12 · check: MP-691's model appears only in types/screening.ts and the screening fixtures: types/index.ts, FormulaLine, FormulaLineKind and the composition api are untouched, so the live grid keeps its model.
- id: RC13 · check: The aggregation and verdict modules are pure (no React, no api, no side effects) and hold every AC2-AC4 and AC7-AC8 rule, so the Backend stage can port them; no rule is buried inside a component.
- id: RC14 · check: npm run build passes in frontend/, and no file under backend/ and no Flyway migration is touched by this stage.
Assumptions and constraints
- Frontend only at this stage: no file under backend/ and no Flyway migration.
- api/formula-screening.ts and lib/screening-mock-data.ts import no @/shared/lib/api-client; the api function carries TODO(MP-727) naming the future endpoint.
- Alert labels and raw-material warnings are read through the existing raw-materials clients, never mocked.
- types/index.ts and the composition grid's FormulaLine model are not edited: MP-691's shape lives only in types/screening.ts.
- The Screening tab is read-only in every mode and every status, and takes no edit affordance.
- No new npm package, and no new shared component: the matrix uses SimpleDataTable and shared ui primitives.
- The Composition tab's panel behaviour must be observably unchanged after the host extraction.
Risks, dependencies and expected result
- R1The mock's pre-mix and 'Not included' shape may differ from the model MP-691 merges, since PR #143 is not on main and is not readable from this run.
Mitigation: Keep that shape only in types/screening.ts and the fixtures, never in the live FormulaLine, so the re-map when MP-691 lands is confined to api/formula-screening.ts.
- R2Extracting the panel host edits the live MP-690 Composition tab and could regress the drawer's animation, its removal timeout or focus restoration.
Mitigation: Move the state, the effect and the aside verbatim behind the host, keep focus restoration in each tab, and verify the Composition tab's open, close, Escape and reduced-motion paths (RC10).
- R3The hook issues one warnings request per aggregated raw material, which grows with the formula.
Mitigation: The fixtures keep the row count small at this stage, the calls run in one Promise.all, and the Backend stage may fold the cells into the screening endpoint, changing the hook only.
- R4AlertLabelResponse.active is optional, so an undefined flag could silently hide labels from the AC5 selection.
Mitigation: Treat undefined as active (keep active !== false) and confirm the served default at the Backend stage.
- R5The frontend aggregation is a stage artefact: the Backend stage ports it to Java and the flip deletes it, so rules buried in components would be lost or duplicated.
Mitigation: Keep every rule in the two pure lib modules with no React or api import, and review for rules leaking into components (RC13).
Expected result
Mock builder takes over the Mock stage from this plan.
Milestones
- 1Screening types and mock composition fixtures
Mock builder
Done when: The types compile and the fixtures cover a nested pre-mix, a raw material used both directly and via a pre-mix, a QSP line, an empty line and both 'Not included' levels; types/index.ts is untouched.
- 2Aggregation and verdict rules as pure functions
Mock builder · after step 1
Done when: On the fixtures the aggregation returns one row per raw material, pre-mixes expanded at every level, 'Not included' removed, the rest renormalised to 100 and sorted descending; the verdict and summary helpers follow AC7 and AC8.
- 3Mock api and the screening hook
Mock builder · after step 2
Done when: The api file imports no apiClient and carries TODO(MP-727) with the future endpoint; the hook returns rows, active alert labels, the full initial selection, derived cells, loading and error, reading labels and warnings from the existing clients.
- 4Alert selector, matrix table, cells and summary
Mock builder · after step 3
Done when: The view shows name, total percentage and phases plus one column per selected alert with wordless pictograms, hover detail, a verdict under each header and the single-alert sentence in the verdict colour.
- 5Panel host extraction and the Screening tab entry
Mock builder · after step 4
Done when: npm run build passes; Screening appears between Composition and History; clicking a name opens the shared panel with the table still visible; the Composition tab's panel opens, animates and closes exactly as before.
Technical details
Decision, design and alternatives4
- Selected
- A1
- Architecture
- Target: the formulas feature of the formulation domain, frontend/src/domains/formulation/features/formulas. Nothing under backend/ changes at this stage. 1. TYPES — types/screening.ts (new). ScreeningLineKind = "RAW_MATERIAL" | "PREMIX". ScreeningNotIncluded = null | "THIS_PREMIX" | "FINAL_FORMULA". ScreeningCompositionLine { id, kind, rawMaterialId: string | null, commercialName: string, premixCode: string | null, premixLines: ScreeningCompositionLine[] | null, phase: string, percentage: number | null, isQsp: boolean, notIncluded: ScreeningNotIncluded }. ScreeningRawMaterialRow { rawMaterialId, commercialName, totalPercentage: number, phases: string[], viaPremixCodes: string[] }. ScreeningRawMaterials { formulaId, isPremix: boolean, rows: ScreeningRawMaterialRow[] }. ScreeningCellStatus = Pictogram | "NOT_ENTERED" (Pictogram imported from the raw-materials warnings client). ScreeningCell { rawMaterialId, alertLabelId, status: ScreeningCellStatus, notes: string, lastUpdatedAt: string | null }. THIS FILE IS WHERE, AND THE ONLY PLACE WHERE, THE MP-691 DATA MODEL IS REPRESENTED. At the indexed base the line kind is a union of one value and no line carries a 'Not included' marker (E4, E5); the feature's own doc states that pre-mix lines, nested resolution and the exclusion level are MP-691 (E19), whose PR #143 is not on main and is not readable from this run. The screening types therefore mirror that model locally — "PREMIX" as a second kind, premixLines for the nesting, notIncluded for the level — and the live FormulaLine in types/index.ts is NOT edited. The backend-wired composition grid keeps its model, MP-691 can land without a conflict in it, and when it does land api/formula-screening.ts is the single place that re-maps the served lines onto these types. 2. MOCK FIXTURES — lib/screening-mock-data.ts (new). One mock formula composition as ScreeningCompositionLine[], plus a second fixture for a formula that is itself a pre-mix (AC4's second rule). The set must exercise every aggregation rule: a raw material in two phases, a raw material used directly and inside a pre-mix, a pre-mix containing a nested pre-mix, a QSP line, an empty line (rawMaterialId null), a line marked THIS_PREMIX and a line marked FINAL_FORMULA. The file is named screening-mock-data.ts rather than the usual lib/mock-data.ts because this feature is otherwise fully backend-wired and a bare mock-data.ts would read as 'the formulas feature is mock-first', which it is not. 3. AGGREGATION — lib/screening-aggregation.ts (new), pure functions only, in the style of composition-math.ts (E6). aggregateScreeningRows(lines, { isPremix }) applies, in order: (a) drop empty lines (rawMaterialId null and no premixLines) — AC2; (b) resolve the QSP line's percentage as 100 minus the sum of the other top-level lines, exactly as summariseComposition does (E6) — AC2; (c) expand pre-mix lines recursively at every nesting level, each contained raw material contributing parentPercentage x childPercentage / 100 and carrying the code of the pre-mix it came through — AC3; (d) remove 'Not included' lines — on a formula that is not a pre-mix every notIncluded line at every level, on a formula that is a pre-mix only the THIS_PREMIX ones — AC4; (e) renormalise the remaining contributions so they total 100 (each contribution divided by the surviving total, times 100) — AC4; (f) merge contributions of the same rawMaterialId into one row, summing percentages and taking the union of phases and of viaPremixCodes — AC2, AC3; (g) sort by totalPercentage descending — AC2. Rounding follows composition-math's two-decimal rule. 4. VERDICTS — lib/screening-verdict.ts (new), pure functions. alertVerdict(cells of one alert) returns "RED" when any cell is RED, otherwise "ORANGE" when any is QUESTION or NOT_ENTERED, otherwise "GREEN" — AC7; NA therefore counts as green and there is no cross-alert verdict. summariseAlert(cells, rowCount) returns the counts behind AC8's sentence (red, to check = QUESTION, not entered, total) together with the verdict so the sentence takes the verdict colour. 5. MOCK API — api/formula-screening.ts (new). async getFormulaScreeningRawMaterials(formulaId: string): Promise<ScreeningRawMaterials>, resolving the fixtures through aggregateScreeningRows. It imports NO apiClient and carries // TODO(MP-727): replace with apiClient GET /v1/formulas/{formulaId}/screening/raw-materials. This function is the contract boundary: at the flip it is the only file that changes on the data side. 6. HOOK — description/hooks/use-formula-screening.ts (new). useFormulaScreening(formulaId) owns the whole view's data and returns { rows, alertLabels, selectedAlertIds, toggleAlert, cellsByRawMaterial, loading, error, refetch }. It loads, in this order: the aggregated rows from the mock api; the alert labels from the existing getAlertLabels client (E7, E8), keeping those whose active flag is not false and sorting by name; then, for the loaded rows, getWarnings per raw material from the existing warnings client (E9), in one Promise.all. Cells are derived: for a row and an alert, the warning whose alertLabel.id matches gives status, notes and modifiedDate ?? createdDate; no matching warning gives status NOT_ENTERED with no date. The selection starts as every active alert and lives in plain useState — it is deliberately not persisted (AC5). Errors follow the feature's existing contract: the hook is the single error surface, holds an Error in error, and the tab renders a retry; a failed warnings call for one material leaves that material's cells NOT_ENTERED rather than failing the view. 7. COMPONENTS — description/components/screening/ (new folder, beside composition/). screening-tab.tsx: the tab orchestrator. Calls the hook, renders the loading skeleton, the error state with retry, the alert selector, the summary and the table, and hosts the raw-material panel. It takes selectedRawMaterial and onSelectRawMaterial from FormulaDescriptionTabs, exactly as CompositionTab does, and takes no mode or locked prop at all: the tab is read-only in every mode and status (AC1). alert-selector.tsx: the checkbox list of active alert labels over shared ui primitives. screening-table.tsx: SimpleDataTable (E17) with columns built at render — a first column for the commercial name rendered as a button that reports the material to the tab (AC9), a total-percentage column, a phases column ('A, C'), and one column per selected alert whose header renders the alert name with its verdict beneath it (AC7). Pagination props are not passed: the verdicts and the summary are computed over the whole set, so the table shows every row. screening-pictogram.tsx: the cell. Five states, no word (AC6) — GREEN a green smiley, RED a red exclamation mark, QUESTION an orange question mark, NA a green 'N/A', NOT_ENTERED an orange dashed marker visibly different from QUESTION — each with an accessible label and a shared Tooltip carrying the status, the notes and the last update date. A new component rather than the raw-material page's helper, which prints a word beside the emoji and has no 'not entered' state (E10). screening-summary.tsx: the AC8 sentence, rendered only when exactly one alert is selected, in the verdict's colour. 8. THE PANEL — composition/raw-material-panel-host.tsx (new) and composition/composition-tab.tsx (modified). The host takes { selected: SelectedRawMaterial | null, onClosed?: () => void } and holds what CompositionTab holds today: the exitingMaterial state, the timeout that guarantees removal, the derived panelMaterial / panelExiting pair and the fixed aside that renders RawMaterialPanel keyed by material id (E13, E14). Its behaviour is moved verbatim, not rewritten. CompositionTab renders the host instead of its own aside and keeps its own focus restoration to the opening cell in onClosed; ScreeningTab renders the same host and restores focus to the name button it opened from. RawMaterialPanel itself is reused unchanged (E12), and the page needs no change: it already reserves the drawer's width whenever a material is selected, whatever the active tab (E15, E16). 9. THE TAB ENTRY — description/components/formula-description-tabs.tsx (modified). TabValue becomes "composition" | "screening" | "history"; the ?tab= guard accepts the new value; one TabsTrigger labelled Screening is inserted between Composition and History (AC1) and one TabsContent renders ScreeningTab with selectedRawMaterial and onSelectRawMaterial passed through. The existing Composition and History entries are untouched. DATA FLOW: page (owns the panel selection) -> FormulaDescriptionTabs -> ScreeningTab -> useFormulaScreening -> mock api (aggregated rows) + getAlertLabels + getWarnings per row -> pure verdict helpers -> SimpleDataTable and the summary; clicking a name sets the page's selection, which the panel host turns into the drawer while the table stays beside it. WHAT THE LATER STAGES INHERIT: the response shape of getFormulaScreeningRawMaterials is the contract for GET /v1/formulas/{id}/screening/raw-materials, and lib/screening-aggregation.ts is the written specification of the rules FormulaCompositionService has to implement at the Backend stage; at the flip the hook calls the endpoint and the fixtures, the mock api and the frontend aggregation are deleted.
- Rationale
- The ticket asks for a tab right after Composition (AC1), and the page already composes its tabs in one component where a third entry is one trigger plus one content block (E1, E2, E3) — which rules out A4, whose standalone page would also have to re-create the drawer width reservation the detail page already owns (E15). A3 is excluded by the evidence that alert labels and raw-material warnings are served today (E7, E8, E9): mocking them would be a duplication and a flip that changes nothing, so only the aggregated raw-material list — which has no source at all at the indexed base (E4, E5, E19) — is mock-first. Between A1 and A2 the question is where the panel host lives. The panel component itself is reusable unchanged (E12), but its host is two pieces of state, a timeout that guarantees removal and a fixed aside, all inside CompositionTab (E13, E14); copying them into the Screening tab would duplicate the one piece of logic in this feature whose failure mode is a drawer stuck on screen. A1 extracts them into a sibling component with the same behaviour and keeps focus restoration in each tab, so the Composition tab's observable behaviour is unchanged while the Screening tab gets the same panel for free. The matrix needs no new table: SimpleDataTable takes columns and data with pagination optional (E17), and one column definition per selected alert is built at render.
Design by area
User interface
SLICE 1 — types and fixtures. types/screening.ts declares ScreeningLineKind ("RAW_MATERIAL" | "PREMIX"), ScreeningNotIncluded (null | "THIS_PREMIX" | "FINAL_FORMULA"), ScreeningCompositionLine (id, kind, rawMaterialId, commercialName, premixCode, premixLines, phase, percentage, isQsp, notIncluded), ScreeningRawMaterialRow (rawMaterialId, commercialName, totalPercentage, phases, viaPremixCodes), ScreeningRawMaterials (formulaId, isPremix, rows), ScreeningCellStatus (Pictogram | "NOT_ENTERED") and ScreeningCell (rawMaterialId, alertLabelId, status, notes, lastUpdatedAt). Pictogram is imported from the raw-materials warnings client so the two cannot drift. lib/screening-mock-data.ts holds two fixtures — an ordinary formula and a formula that is itself a pre-mix — covering a raw material in two phases, a raw material used both directly and inside a pre-mix, a pre-mix containing a nested pre-mix, a QSP line, an empty line, a THIS_PREMIX line and a FINAL_FORMULA line. The MP-691 model is represented here and nowhere else; the live FormulaLine is not touched.
SLICE 2 — pure rules. lib/screening-aggregation.ts exports aggregateScreeningRows(lines, { isPremix }): drop empty lines; resolve the QSP percentage as 100 minus the sum of the other top-level lines; expand pre-mixes recursively, each contained raw material contributing parent % x child % / 100 and carrying the pre-mix code it came through; remove 'Not included' lines (all levels on an ordinary formula, only THIS_PREMIX on a pre-mix); renormalise the survivors to 100; merge rows by rawMaterialId, summing percentages and taking the union of phases and pre-mix codes; sort by total percentage descending. Two-decimal rounding as in composition-math.ts. lib/screening-verdict.ts exports alertVerdict (RED if any RED, else ORANGE if any QUESTION or NOT_ENTERED, else GREEN) and summariseAlert (red / to check / not entered / total plus the verdict). Both modules are pure: no React, no api, no side effects, so the Backend stage can port them line by line.
SLICE 3 — data access. api/formula-screening.ts exports async getFormulaScreeningRawMaterials(formulaId) returning ScreeningRawMaterials from the fixtures through aggregateScreeningRows, with no apiClient import and a TODO(MP-727) naming GET /v1/formulas/{formulaId}/screening/raw-materials. description/hooks/use-formula-screening.ts returns { rows, alertLabels, selectedAlertIds, toggleAlert, cellsByRawMaterial, loading, error, refetch }: it loads the rows from the mock api, the alert labels from the existing getAlertLabels (keeping active !== false, sorted by name), then the warnings of the loaded rows with one getWarnings call per raw material in a single Promise.all, and derives the cells (matching warning gives status, notes and modifiedDate ?? createdDate; no match gives NOT_ENTERED). The selection starts as every active alert and is plain useState, not persisted. The hook is the single error surface: one Error in error for a failed rows or labels load, and a failed warnings call for one material leaves that material's cells NOT_ENTERED instead of failing the view.
SLICE 4 — the view. screening/screening-tab.tsx orchestrates: loading skeleton, error state with retry, alert selector, summary, table, and the panel host. screening/alert-selector.tsx is a checkbox list of the active labels over shared ui primitives. screening/screening-table.tsx renders SimpleDataTable with columns built at render — commercial name as a button, total percentage, phases joined as 'A, C', then one column per selected alert whose header shows the alert name with its verdict beneath — and passes no pagination props, because the verdicts and the summary are computed over every row. screening/screening-pictogram.tsx renders the five wordless states (green smiley, red exclamation mark, orange question mark, green N/A, orange dashed marker) each with an accessible label and a tooltip carrying status, notes and last update date. screening/screening-summary.tsx renders the single-alert sentence in the verdict colour, and only when exactly one alert is selected.
SLICE 5 — the panel and the tab. composition/raw-material-panel-host.tsx takes { selected, onClosed } and holds, moved verbatim from CompositionTab, the exitingMaterial state, the removal timeout, the derived panelMaterial / panelExiting pair and the fixed aside that renders RawMaterialPanel keyed by material id. CompositionTab renders the host and keeps its focus restoration to the opening cell through onClosed; ScreeningTab renders the same host and restores focus to the name button. RawMaterialPanel is reused unchanged. formula-description-tabs.tsx widens TabValue to "composition" | "screening" | "history", accepts the new ?tab= value, inserts one TabsTrigger labelled Screening between Composition and History and one TabsContent rendering ScreeningTab with selectedRawMaterial and onSelectRawMaterial passed through. The formula detail page is not edited: it already reserves the drawer width whenever a material is selected, whatever the active tab.API and backend
Decided at the Backend stage. What this stage fixes for it: (1) the response shape — ScreeningRawMaterials { formulaId, isPremix, rows: [{ rawMaterialId, commercialName, totalPercentage, phases, viaPremixCodes }] }, rows sorted by total percentage descending and totalling 100, deliberately not paginated because the verdict per alert and the single-alert summary are computed over the whole set; (2) the aggregation rules, written as pure functions in lib/screening-aggregation.ts — drop empty lines, resolve the QSP line as 100 minus the rest, expand pre-mixes recursively with parent % x child % / 100 and the pre-mix code, remove 'Not included' lines (every level on an ordinary formula, only 'this pre-mix' on a pre-mix), renormalise the survivors to 100, merge by raw material and sort — which are the specification the Java service has to implement; (3) the decision that alert labels and raw-material warnings are not re-served: the Screening view reads the existing endpoints, so the Backend stage adds the aggregation endpoint and may, as an optimisation, fold the per-raw-material warning fan-out into it. The aggregation depends on MP-691's pre-mix lines and 'Not included' level being available server-side (PR #143), which is a precondition of the Backend stage, not of this one.Alternatives considered
- A1 — A Screening tab in the existing formula detail tabs: new screening types, mock composition fixtures, pure aggregation and verdict libs, one mock api function and one read-only hook; alert labels and raw-material warnings are read through the existing real clients; the matrix is SimpleDataTable with runtime-built alert columns; the raw-material panel host is extracted out of CompositionTab into a sibling component both tabs use.
Pros: One tab entry plus one tab component in the component the page already composes (E1, E2); Only the aggregated list is mocked; alert labels and warnings keep calling the endpoints that already serve them (E7, E8, E9); The panel exit lifecycle keeps a single home instead of being copied (E13, E14); The flip at the Backend stage replaces one api file. Cons: Touches the live MP-690 Composition tab to move its panel host; One warnings request per aggregated raw material until the Backend stage folds the cells into the endpoint.
- A2 — Same tab and same mock data layer, but the Screening tab keeps its own copy of the panel host: its own exiting state, its own timeout and its own fixed aside, leaving CompositionTab untouched.
Pros: No edit to the live composition tab; Smallest diff outside the new folder. Cons: Copies a subtle animation and timeout lifecycle into a second place (E13, E14), which the frontend guidance calls a duplication; The two copies drift the first time either is fixed.
- A3 — Mock the whole screening payload in one api function: aggregated rows, alert labels and every cell status, ignoring the alert-label and warning endpoints that already serve that data.
Pros: One fetch, no fan-out; The mock is self-contained. Cons: Mocks data the backend already serves (E7, E8, E9), which the mock-stage guidance forbids; The flip would replace working calls with the same calls, so the mock proves nothing about them.
- A4 — A standalone /formulas/{id}/screening page with a thin route re-export instead of a tab.
Pros: Independent route, no change to the tab component. Cons: AC1 asks for a tab directly after Composition; A second place to read formula data; The side panel's width reservation already lives on the detail page (E15), so the page would have to be rebuilt around the drawer.
API1
API contract
User interface
Mock at this stage: getFormulaScreeningRawMaterials(formulaId) -> ScreeningRawMaterials { formulaId: string, isPremix: boolean, rows: ScreeningRawMaterialRow[] }, with ScreeningRawMaterialRow { rawMaterialId: string (UUID), commercialName: string, totalPercentage: number (2 decimals, the rows total 100), phases: string[], viaPremixCodes: string[] }, rows sorted by totalPercentage descending. The Backend stage replaces it with GET /v1/formulas/{formulaId}/screening/raw-materials returning the same shape. It is deliberately not a PaginatedResponse: the verdict per alert (AC7) and the single-alert summary (AC8) are computed over the whole set, so a page of rows would make both wrong. Alert labels keep GET /v1/reference-data/alert-labels through getAlertLabels, and cell data keeps GET /v1/raw-materials/{id}/warnings through getWarnings; the Backend stage may later fold the cells into the screening endpoint, which would change the hook only.Backend design
API and backend
Decided at the Backend stage. What this stage fixes for it: (1) the response shape — ScreeningRawMaterials { formulaId, isPremix, rows: [{ rawMaterialId, commercialName, totalPercentage, phases, viaPremixCodes }] }, rows sorted by total percentage descending and totalling 100, deliberately not paginated because the verdict per alert and the single-alert summary are computed over the whole set; (2) the aggregation rules, written as pure functions in lib/screening-aggregation.ts — drop empty lines, resolve the QSP line as 100 minus the rest, expand pre-mixes recursively with parent % x child % / 100 and the pre-mix code, remove 'Not included' lines (every level on an ordinary formula, only 'this pre-mix' on a pre-mix), renormalise the survivors to 100, merge by raw material and sort — which are the specification the Java service has to implement; (3) the decision that alert labels and raw-material warnings are not re-served: the Screening view reads the existing endpoints, so the Backend stage adds the aggregation endpoint and may, as an optimisation, fold the per-raw-material warning fan-out into it. The aggregation depends on MP-691's pre-mix lines and 'Not included' level being available server-side (PR #143), which is a precondition of the Backend stage, not of this one.Backend changes
No file changes listed.
Data and database1
Data contract
User interface
No persistence is created or changed: alerts are already stored as raw-material warnings with their alert label, pictogram (GREEN|RED|QUESTION|NA), notes and audit dates. The screening view adds one derived state, NOT_ENTERED, which exists only in the client and means 'no warning row for this raw material and this alert'; it is never written. The mock composition fixtures stand in for MP-691's pre-mix lines and 'Not included' levels until PR #143 lands, and live only in types/screening.ts and lib/screening-mock-data.ts.
Database changes
No file changes listed.
Files and components14
| Area | File | Change | Why |
|---|---|---|---|
| User interface | frontend/src/domains/formulation/features/formulas/types/screening.ts | create | The screening types, including the local representation of MP-691's pre-mix and 'Not included' model, kept out of the live FormulaLine. |
| User interface | frontend/src/domains/formulation/features/formulas/lib/screening-mock-data.ts | create | Mock composition fixtures exercising every AC2-AC4 rule, including a nested pre-mix and both exclusion levels. |
| User interface | frontend/src/domains/formulation/features/formulas/lib/screening-aggregation.ts | create | The pure aggregation: empty lines, QSP, pre-mix expansion, 'Not included' removal, renormalisation, merge and sort. |
| User interface | frontend/src/domains/formulation/features/formulas/lib/screening-verdict.ts | create | The pure verdict per alert (AC7) and the single-alert counts (AC8). |
| User interface | frontend/src/domains/formulation/features/formulas/api/formula-screening.ts | create | The mock api function that fixes the future endpoint's response shape; no apiClient import, TODO(MP-727). |
| User interface | frontend/src/domains/formulation/features/formulas/description/hooks/use-formula-screening.ts | create | The read-only view hook: rows, active alert labels, selection and derived cells, over the mock api and the two real clients. |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/screening/screening-tab.tsx | create | The tab orchestrator, read-only in every mode and status, and the host of the raw-material panel. |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/screening/alert-selector.tsx | create | The choice of alerts, all selected on open, not remembered (AC5). |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/screening/screening-table.tsx | create | The raw materials x alerts matrix over SimpleDataTable with runtime-built alert columns and the verdict under each header. |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/screening/screening-pictogram.tsx | create | The five wordless cell states with the hover detail (AC6). |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/screening/screening-summary.tsx | create | The single-alert summary sentence in the verdict colour (AC8). |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/composition/raw-material-panel-host.tsx | create | The panel host moved out of CompositionTab so both tabs mount the drawer through one lifecycle. |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/composition/composition-tab.tsx | modify | Render the extracted host instead of its own aside and exit state, keeping its focus restoration. |
| User interface | frontend/src/domains/formulation/features/formulas/description/components/formula-description-tabs.tsx | modify | One Screening tab entry between Composition and History, the widened tab union and the ?tab= guard. |
Files named by the steps
frontend/src/domains/formulation/features/formulas/types/screening.tsfrontend/src/domains/formulation/features/formulas/lib/screening-mock-data.tsfrontend/src/domains/formulation/features/formulas/lib/screening-aggregation.tsfrontend/src/domains/formulation/features/formulas/lib/screening-verdict.tsfrontend/src/domains/formulation/features/formulas/api/formula-screening.tsfrontend/src/domains/formulation/features/formulas/description/hooks/use-formula-screening.tsfrontend/src/domains/formulation/features/formulas/description/components/screening/screening-tab.tsxfrontend/src/domains/formulation/features/formulas/description/components/screening/alert-selector.tsxfrontend/src/domains/formulation/features/formulas/description/components/screening/screening-table.tsxfrontend/src/domains/formulation/features/formulas/description/components/screening/screening-pictogram.tsxfrontend/src/domains/formulation/features/formulas/description/components/screening/screening-summary.tsxfrontend/src/domains/formulation/features/formulas/description/components/composition/raw-material-panel-host.tsxfrontend/src/domains/formulation/features/formulas/description/components/composition/composition-tab.tsxfrontend/src/domains/formulation/features/formulas/description/components/formula-description-tabs.tsx
Components the review expects
- kind: create · path: frontend/src/domains/formulation/features/formulas/types/screening.ts
- kind: create · path: frontend/src/domains/formulation/features/formulas/lib/screening-mock-data.ts
- kind: create · path: frontend/src/domains/formulation/features/formulas/lib/screening-aggregation.ts
- kind: create · path: frontend/src/domains/formulation/features/formulas/lib/screening-verdict.ts
- kind: create · path: frontend/src/domains/formulation/features/formulas/api/formula-screening.ts
- kind: create · path: frontend/src/domains/formulation/features/formulas/description/hooks/use-formula-screening.ts
- kind: create · path: [redacted]-tab.tsx
- kind: create · path: [redacted]-selector.tsx
- kind: create · path: [redacted]-table.tsx
- kind: create · path: [redacted]-pictogram.tsx
- kind: create · path: [redacted]-summary.tsx
- kind: create · path: [redacted]-material-panel-host.tsx
- kind: modify · path: [redacted]-tab.tsx
- kind: modify · path: [redacted]-description-tabs.tsx
Tests and review checks14
Review checks
- id: RC1 · check: The formula detail page shows a Screening tab between Composition and History, it opens from ?tab=screening, and it renders the same read-only view whatever the grid mode and whatever the formula status (no edit affordance anywhere in the tab).
- id: RC2 · check: On the mock fixtures each raw material appears on exactly one row with its commercial name, total percentage and phases joined as 'A, C'; the empty line produces no row, the QSP line counts with its derived percentage, and rows are ordered by total percentage, highest first.
- id: RC3 · check: Pre-mix lines are expanded at every nesting level: each contained raw material contributes parent % x child % / 100, shows 'via pre-mix <code>', and a raw material used both directly and through a pre-mix has one row whose percentage is the sum of both contributions.
- id: RC4 · check: On an ordinary formula every 'Not included' line is dropped, including inside pre-mixes at any level; on the pre-mix fixture only the 'this pre-mix' lines are dropped and the 'final formula' ones are kept; after removal the remaining percentages total 100.
- id: RC5 · check: The alert selector lists the active alert labels from the real alert-label client, every one is selected when the view opens, each can be toggled, and leaving and returning to the tab restores the full selection rather than the previous one.
- id: RC6 · check: Every cell is a pictogram with no word, with the five distinct states GREEN, RED, QUESTION, NA and no-status-entered (an orange dashed marker clearly different from the orange question mark), and hovering a cell shows the status, the notes and the date of the warning's last update.
- id: RC7 · check: Each selected alert shows a verdict under its own column header: red when any raw material is RED, otherwise orange when any is QUESTION or has no status entered, otherwise green; NA alone stays green and no overall verdict across alerts is rendered anywhere.
- id: RC8 · check: With exactly one alert selected a sentence above the table counts the raw materials by status in the verdict's colour (red / to check / not entered out of the total); with none or several selected the sentence is absent.
- id: RC9 · check: Clicking a raw-material name opens the composition grid's RawMaterialPanel unchanged, with its tabs and its 'Open full page' link, while the Screening table stays visible beside it; closing it returns focus to the name button.
- id: RC10 · check: The Composition tab is unchanged in behaviour after the panel host extraction: opening, animating, closing with X or Escape, the reduced-motion path and focus returning to the opening cell all still work, and no composition grid logic moved with the host.
- id: RC11 · check: Mock discipline: api/formula-screening.ts and lib/screening-mock-data.ts import no @/shared/lib/api-client, the api function carries TODO(MP-727) naming GET /v1/formulas/{formulaId}/screening/raw-materials, and the alert labels and warnings come from the existing raw-materials clients rather than from mock data.
- id: RC12 · check: MP-691's model appears only in types/screening.ts and the screening fixtures: types/index.ts, FormulaLine, FormulaLineKind and the composition api are untouched, so the live grid keeps its model.
- id: RC13 · check: The aggregation and verdict modules are pure (no React, no api, no side effects) and hold every AC2-AC4 and AC7-AC8 rule, so the Backend stage can port them; no rule is buried inside a component.
- id: RC14 · check: npm run build passes in frontend/, and no file under backend/ and no Flyway migration is touched by this stage.
Test files
No test files named.
Reuse to verify
- area: frontend · element: FormulaDescriptionTabs tab list · action: extend · why: The Screening tab is one trigger and one content block in the existing tabs component; a second tab container or a new tab host must not appear.
- area: frontend · element: RawMaterialPanel and its host · action: reuse · why: The panel is mounted unchanged, and its exit lifecycle must exist once, in the extracted host, not copied into the Screening tab.
- area: frontend · element: SimpleDataTable (@/shared/components) · action: reuse · why: The matrix is built from runtime column definitions; a hand-rolled table or a new shared table component would duplicate it.
- area: frontend · element: getAlertLabels and getWarnings (raw-materials clients) · action: reuse · why: Both endpoints already serve this data; mock alert labels or mock warning statuses would be a duplication and a flip that changes nothing.
- area: frontend · element: Standalone /formulas/{id}/screening page (rejected alternative A4) · action: create · why: Rejected: AC1 asks for a tab after Composition. No route file under app/(protected) and no sidebar entry should appear.
- area: frontend · element: Pictogram rendering helpers on the raw-material page · action: create · why: A new screening cell is justified only because the existing helper prints a word and has no 'not entered' state; the raw-material page's own table must not be changed to serve this view.
Handoff and bindings18
Mock builder takes over the Mock stage from this plan.
- RAG revision
- 6ff9aee4994f
- Catalog
- ead4cae607ae
- Base SHA (RAG indexed)
- 3830b223cba4
- Feature base
- 3830b223cba4
- Main
- 3830b223cba4
- Main overlay
- none
- Freshness
- EXACT
- RAG mode / profile
- required
- Stage binding
- 03bfe04bbcea
- Context manifest
- 17ec52a7df7f
- Policy binding
- 43c012486058
- Writes
- disabled
- TicketSnapshot schema
- ticket-snapshot/v2
- TicketSnapshot hash
- 5db900fc8d74
- TicketSnapshot size
- 8918 B
- TicketSnapshot fetched
- 2026-10-17T05:50:00Z
- FeatureContract schema
- feature-contract/v2.1
- FeatureContract hash
- 28a37eefd51e
Evidence and provenance19
- Contract
feature-contract/v2.1·28a37eefd51e0b1819a60c71b76f18d0fe3a82760e949119915058a3a7a3a281- Aggregate
28a37eefd51e0b1819a60c71b76f18d0fe3a82760e949119915058a3a7a3a281- RAG
- RAG · EXACT
Cited evidence
| Id | Source | Where | Claim |
|---|---|---|---|
| E1 | git-base | frontend/src/domains/formulation/features/formulas/description/components/formula-description-tabs.tsx:110-142 @ 3830b22 | The formula detail page renders exactly two tabs, Composition and History, from one TabsList; a third tab is one TabsTrigger plus one TabsContent. |
| E2 | git-base | frontend/src/domains/formulation/features/formulas/description/components/formula-description-tabs.tsx:63-79 @ 3830b22 | The active tab is a two-member union synced to the ?tab= query param, so adding Screening means extending that union and the param guard. |
| E3 | catalog | frontend/src/domains/formulation/features/formulas/description/components/formula-description-tabs.tsx:65-142 @ 3830b22 | The catalog fe.component record FormulaDescriptionTabs is the only tab-list element of the formula detail page at the indexed base. |
| E4 | git-base | frontend/src/domains/formulation/features/formulas/types/index.ts:285-291 @ 3830b22 | FormulaLineKind is a union of one value, RAW_MATERIAL; its comment records that PREMIX is added when MP-691 lands, so the base model has no pre-mix line. |
| E5 | git-base | frontend/src/domains/formulation/features/formulas/types/index.ts:374-391 @ 3830b22 | A FormulaLine carries rawMaterialId (null on an empty row), commercialName, phase, percentage and isQsp, and no field marking a line as 'Not included'. |
| E6 | git-base | frontend/src/domains/formulation/features/formulas/lib/composition-math.ts:56-94 @ 3830b22 | summariseComposition and effectivePercent derive the QSP row's percentage as 100 minus the subtotal in a pure lib module; the precedent and the rule the aggregation needs for the QSP line. |
| E7 | git-base | frontend/src/domains/raw-materials/api/alert-labels.ts:20-68 @ 3830b22 | AlertLabelResponse carries name and an optional active flag, and getAlertLabels fetches the real GET /v1/reference-data/alert-labels through apiClient. |
| E8 | catalog | frontend/src/domains/raw-materials/api/alert-labels.ts:56-68 @ 3830b22 | The catalog records getAlertLabels as client-calls of AlertLabelController's GET route, so alert labels are already served and mocking them would duplicate a live call. |
| E9 | git-base | frontend/src/domains/raw-materials/api/raw-material-warnings.ts:18-81 @ 3830b22 | Pictogram is GREEN|RED|QUESTION|NA; a warning carries alertLabel, notes, createdDate and modifiedDate, and getWarnings reads them per raw material from the real endpoint. |
| E10 | git-base | frontend/src/domains/raw-materials/features/composition/components/alerts-section/warnings-card/warnings-table.tsx:50-63 @ 3830b22 | The raw-material page prints each pictogram as an emoji plus a word and has no 'no status entered' state, so it cannot serve AC6's wordless five-state cell. |
| E11 | git-base | frontend/src/domains/raw-materials/hooks/composition/alerts-section/use-warnings.ts:23-64 @ 3830b22 | useWarnings is a single-raw-material CRUD hook with pagination state and toasts, not a read-only loader of warnings for many raw materials at once. |
| E12 | git-base | frontend/src/domains/formulation/features/formulas/description/components/composition/raw-material-panel.tsx:28-83 @ 3830b22 | RawMaterialPanel takes SelectedRawMaterial {id,name,code} plus exiting, onExited and onClose, and its sections fetch their own data from the id, so another tab can mount it unchanged. |
| E13 | git-base | frontend/src/domains/formulation/features/formulas/description/components/composition/composition-tab.tsx:736-758 @ 3830b22 | The panel host — the fixed aside, the key on the material id and the exiting/aria-hidden wiring — sits inside CompositionTab today. |
| E14 | git-base | frontend/src/domains/formulation/features/formulas/description/components/composition/composition-tab.tsx:249-290 @ 3830b22 | The exit lifecycle (exitingMaterial state plus the timeout that guarantees the drawer is removed) is also inside CompositionTab; a second tab showing the panel needs the same lifecycle. |
| E15 | git-base | frontend/src/domains/formulation/features/formulas/description/pages/formula-description-page.tsx:128-147 @ 3830b22 | The page, not the tab, reserves the drawer's width whenever a material is selected, and the reservation does not depend on which tab is active. |
| E16 | git-base | frontend/src/domains/formulation/features/formulas/description/pages/formula-description-page.tsx:186-195 @ 3830b22 | The page owns the panel selection and passes selectedRawMaterial / onSelectRawMaterial into FormulaDescriptionTabs, so a new tab inherits the selection with no page change. |
| E17 | rag | frontend/src/shared/components/simple-data-table.tsx:43-159 @ 3830b22 | SimpleDataTable takes columns and data with every pagination prop optional, plus loading, emptyMessage, onRowClick and rowClassName; runtime-built column definitions fit the alert columns. |
| E18 | git-base | frontend/src/app/(protected)/raw-materials/[id]/layout.tsx:48-126 @ 3830b22 | The raw-material detail page the ticket names as the analogue drives its tabs from one TABS_CONFIG list of id, label, icon and component. |
| E19 | git-base | frontend/src/domains/formulation/features/formulas/CLAUDE.md:1282-1293 @ 3830b22 | The feature's own documentation states that pre-mix as an ingredient, nested pre-mix resolution and the 'Not included' exclusion level are MP-691 and are not in this code; the grid was built for raw-material rows only. |
Reuse and anti-duplication
| Area | Element | Action | Justification |
|---|---|---|---|
| frontend | FormulaDescriptionTabs tab list (description/components/formula-description-tabs.tsx) (fe.component:[redacted]-description-tabs.tsx#FormulaDescriptionTabs) | extend | The page composes its tabs in this one component, so Screening is one TabsTrigger, one TabsContent and a widened tab union plus ?tab= guard; Composition and History are untouched. |
| frontend | RawMaterialPanel (description/components/composition/raw-material-panel.tsx) (fe.component:[redacted]-material-panel.tsx#RawMaterialPanel) | reuse | It takes SelectedRawMaterial plus exiting/onExited/onClose and its sections fetch their own data from the id, which is exactly what AC9 asks for; it is mounted unchanged. |
| frontend | Raw-material panel host (fixed aside and exit lifecycle inside CompositionTab) (fe.component:[redacted]-tab.tsx#CompositionTab) | extend | The host is two pieces of state, a removal timeout and a fixed aside living inside CompositionTab; it is moved verbatim into a sibling component both tabs render, rather than copied into the Screening tab, and CompositionTab keeps its own focus restoration. |
| frontend | SimpleDataTable (@/shared/components) | reuse | It takes columns and data with every pagination prop optional and supports loading, empty and row styling, so the matrix is one runtime-built column definition per selected alert; a bespoke table would duplicate it. |
| frontend | getAlertLabels (raw-materials/api/alert-labels.ts) (fe.api-client:frontend/src/domains/raw-materials/api/alert-labels.ts#getAlertLabels) | reuse | Alert labels with their active flag are already served by AlertLabelController, so AC5 reads the real endpoint; mocking them would be a duplication and a flip that changes nothing. |
| frontend | getWarnings (raw-materials/api/raw-material-warnings.ts) | reuse | The warning rows already carry the GREEN/RED/QUESTION/NA pictogram, the notes and the update dates AC6 needs, per raw material, from a live endpoint. |
| frontend | use-formula-screening hook (description/hooks/use-formula-screening.ts) | create | Searched the catalog for fe.hook under raw-materials and read the closest candidate: useWarnings is a single-raw-material CRUD hook with pagination and toasts, and useFormulaComposition owns the editable grid; neither loads one aggregated list plus the warnings of many materials read-only. Extending either would mix two resources in one contract. |
| frontend | Screening cell pictogram (description/components/screening/screening-pictogram.tsx) | create | RAG on pictogram and status rendering found the raw-material page's helper and the warning dialog's options: both print a word beside an emoji and neither has a 'no status entered' state, which AC6 requires as a fifth, visibly distinct marker. Changing the raw-material page's table instead would alter a live screen this ticket does not touch. |
| frontend | Screening aggregation and verdict libs (lib/screening-aggregation.ts, lib/screening-verdict.ts) | create | composition-math.ts is the feature's pure derivation module but covers only the live grid's QSP total, phase groups and cost; it has no pre-mix expansion, no 'Not included' removal and no renormalisation, because the base model has no such lines at all. The new rules go beside it in lib/, in the same pure style. |
| frontend | Screening types and mock api (types/screening.ts, lib/screening-mock-data.ts, api/formula-screening.ts) | create | No fe.api-client or be.route serves an aggregated per-formula raw-material list, and the line model at the indexed base has neither a PREMIX kind nor a 'Not included' marker; the feature's own doc confirms both belong to MP-691, whose PR is not on main. The mock therefore represents that model in its own types, leaving the live FormulaLine untouched. |
| backend | GET /v1/formulas/{id}/screening/raw-materials (aggregated raw-material list) | unknown | Decided at the Backend stage of this ticket; no route serves this aggregation today, and the mock api function fixes the response shape it must return. |
| backend | Formula screening aggregation in the composition service | unknown | Decided at the Backend stage; the pure TypeScript rules written here are the specification it ports, and it needs MP-691's pre-mix lines and exclusion level server-side. |
| backend | Alert label and raw-material warning endpoints | reuse | Both are already served and are read directly by the Screening view, so the Backend stage adds no second home for them; folding the cells into the screening endpoint later is an optimisation, not a new owner. |
Bound to
- RAG revision
- 6ff9aee4994f
- Catalog
- ead4cae607ae
- RAG indexed base
- 3830b223cba4
- Feature base
- 3830b223cba4
- Main
- 3830b223cba4
- Ticket snapshot
- 5db900fc8d74
Contract sections
architecture· 14213 B ·be9cebd4d93c· shownfrontend-plan· 10410 B ·4f96b6cce7af· shownbackend-plan· 1893 B ·7a1417cba24a· shownevidence· 8546 B ·2b811d7a673a· shownreview-plan· 8033 B ·52bf15c15e14· shown
Raw logsRaw
Logs are not loaded on this page. Open the Planner run to read them.