OfferGrade
A working demonstration of supplier-offer intelligence — reads whatever price list arrives, normalises it, and refuses to score the lines it isn't sure about.
- Role
- Architect & sole engineer
The application
Screens, captured from the running app
Booted against its seeded database and walked route by route — these are the real pages, not mockups.






A demo, built to show the architecture behind a confidential client platform without exposing it. Every supplier, product and price here is invented. The normalisation and scoring behaviour is the real thing.
Why it exists
The production version of this runs daily for a European distributor and can't be shown — no source, no screenshots, no data. That leaves a gap: the interesting part of that system isn't the dashboard, it's a set of decisions about when not to answer, and those are impossible to describe convincingly in prose.
So this is the same architecture over invented data: a hundred and forty products, eight suppliers with different formatting habits, forty-six inbound files across a fortnight, and three hundred and seventy-two scored lines.
The problem being modelled
Suppliers email price lists in whatever shape suits them. In this seed that means four formats — XLSX, CSV, PDF, and prices typed into the body of the mail — across seven languages, in four currencies. Eight of the forty-six sheets have no header row at all, and most of the rest carry it somewhere other than row one.
Assuming row one is a header is the single most common way a supplier importer silently loses a product.
The decision the whole thing turns on
A confident wrong answer is worse than an admitted unknown.
Three gates run before any scoring, and they win outright:
- The price column can't be identified. Several numeric columns, none resolvable to the buyable price. Guessing here is how a case price or a weight ends up scored as a unit price.
- The figure is a retail price. Classified as RRP rather than trade. An RRP that slips through reads as the best deal on the board.
- Extraction confidence is below 70%. Usually pack size or the price itself came out of the file too uncertain to act on.
A gated line has no score at all. It is not a low score — it is an abstention, and it goes to a human queue. In this seed that is 42% of all lines, concentrated almost entirely in the two suppliers with the worst formatting: one sits at 89% review, another at 59%, while the tidiest supplier sits at 30%.
That number is not flattering and it is not tuned. It is the honest output, and expressing it per supplier is what turns "their files are messy" into a cost someone can negotiate about.
Scoring, once a line is clear
Four weights that sum to one, visible on their own page because a buyer who can't see why a line scored 74 won't trust the 74:
| Weight | Share | Why | |---|---|---| | Price vs last paid | 34% | The most reliable benchmark we own — it already reflects our volumes and terms | | Supplier rank | 24% | Cheapest of six means something; cheapest of one is scored neutral, not good | | Price vs market reference | 22% | Catches the case where our own last price was a bad one | | Supplier reliability | 20% | Weighted lowest because late delivery is the one problem a buyer can chase |
Scores band into BUY / NEGOTIATE / REVIEW / IGNORE, with one documented override: a line that is the confirmed cheapest of three or more offers and sits 8% or more under last paid is promoted from NEGOTIATE to BUY even when a weak reliability score drags the total down. A buyer can chase a late delivery; they cannot re-find a price that has gone.
Every line carries its reasons in plain language — "cheapest of five live offers", "11.2% under last paid", "supplier delivers on time 74% of the time" — because that is what someone acts on, not the number.
What's on screen
Six routes: the dashboard, the inbound file log with per-file shape and parse rate, the full scored-offer table, per-product cross-supplier comparison, the review queue grouped by withholding reason, and the scoring rules themselves.
Next.js 16 App Router with React Server Components, Prisma over SQLite, Recharts. The seed is deterministic, so re-running it produces the same figures rather than reshuffling every number on the page.