DEF Beauty Supply ERP shown across a central dashboard, an inventory panel, and a finance panel, with beauty products and shipping objects connecting the business workflow.

ERP Platform for DEF Beauty Supply

Replaced a legacy desktop inventory tool and a disconnected ordering system with one platform, designed as a small set of rules that hold across 40+ screens instead of screen-by-screen decisions.

B2B SaaSEnterprise UXSystems DesignFull stack

About DEF Beauty Supply

DEF Beauty Supply is a B2B beauty wholesaler serving professional salons across Italy. The business ran on a legacy desktop inventory tool plus a disconnected WeChat ordering system: fifteen people, no shared source of truth. I designed and built its replacement, an ERP that treats sales, warehouse, finance, and procurement as one system instead of four.

Role

Research, Product Design & Full-stack Engineering

Solo, including interviews, system design, and implementation.

Team

Solo, with an AI coding agent (Claude Code) as implementation partner.

Scope

Platform-wide: dashboards, sales, procurement, inventory, customers, finance

Company

DEF Beauty Supply

Year

2026

01 / Context

Fifteen people, eight real jobs, one spreadsheet-shaped tool.

DEF Beauty Supply sells professional nail, hair, and beauty products wholesale to salons across Italy, through a legacy desktop inventory tool and a WeChat mini-program that never talked to each other. Neither system knew what the other believed was true.

15people running the business daily
8real jobs, from counter sales to driver
5permission roles the system actually has
2disconnected systems, one truth each

The gap between 8 real jobs and 5 permission roles is real; I kept it visible instead of inventing roles nobody asked for.

02 / Before

What “replace the old system” actually meant.

Storefront, sales, inventory, procurement, receiving, finance: six parts feeding each other. None of that was one system. Every arrow below was, in practice, a person re-typing what another screen already knew.

DEF Beauty Supply business ecosystemArchitecture diagram of the DEF Beauty Supply B2B wholesale platform. Customer demand arrives through a WeChat storefront and becomes sales orders that draw down inventory; an inventory gap drives procurement, which places the order with an external supplier at a human gate; received goods and their variance flow back into inventory while finance reconciles the invoice.DEMAND SIDECORE PLATFORMSYNCDEMANDSTOCK GAPHUMAN GATEDDT · GOODSSTOCK-IN ±VARINVOICEUSERCustomerpro salonsCHANNELWeChat StorestorefrontCORESales OrderdemandSTOREInventorystock · incomingCOREFinanceinvoice · cashCOREProcurementlifecycle · statesCOREReceivingddt · varianceEXTERNALSuppliermoq · lead timeLEGENDPlatform moduleExternal partyInternal flowExternal exchange

The business this had to serve, drawn as one system. It never was one system: see the actual purchasing screen, dragged against its replacement, in chapter 05.

03 / Research System

Five interviews became one file the design has to answer to.

I interviewed sales, warehouse, finance, procurement, and the owner, one person per role. Instead of writing a research summary and moving on, I built the findings into a single living file (UX.md) that every design decision, mine or the AI agent's, has to cite. Every line carries a source tag:

[Interview]

Traced to a specific person and date. Highest authority. Overrides everything else when it conflicts.

[Code]

Verifiable directly in the repository right now.

[Decision]

Already settled and written down, not open for silent reinterpretation.

[Assumption]

Not yet validated. The agent is told not to treat it as fact.

Why this matters

A tag isn't bureaucracy for its own sake. It is what lets a rule get overturned cleanly. One salesperson told me the business deliberately hides real stock from customers, to avoid looking weak to competitors. That became a design constraint. Later, the owner said the opposite: stock visibility should open up gradually, based on whether a customer is logged in and has ordered before. That interview won. The file records both, marks the first one superseded, and explains why, instead of quietly picking a side.

04 / One Skeleton, Five Roles

Four dashboards. One shared structure, reweighted.

Every dashboard keeps the same shell: KPI tiles, a trend chart, a “tasks to handle” panel. What changes is which numbers earn a tile, sourced from what each role told me they actually check. The legacy backend already had a page called “Dashboard” — same numbers for everyone, owner or warehouse hand. Drag to compare it against the Admin view it became.

New Admin dashboard: sales, orders, receivables, customer mix, and tasks to handle, laid out for the admin role specifically.
Legacy WeChat mini-program backend Dashboard page: one undifferentiated view mixing sales KPIs, customer approvals, and withdrawal requests for every user.
BeforeAfter
Drag the handle, or use the arrow keys, to compare.

Before: one Dashboard page for every login, mixing task types that belonged to different jobs. After: the same shell, reweighted per role — this is the Admin view, one of four.

AdminFull operating snapshot: revenue, orders, receivables, customer mix, in one view.

1 of 4

Two pieces of that shell, running here instead of as screenshots: the trend chart and the customer-type mix, on the same component code and demo data as the Admin view.

LiveSwitch the time range, hover the curve or a slice. Same components and demo data as the Admin dashboard.

Sales performance

Last 30 days net sales, refunds deducted

NetSalesRefunds
€12k€9k€6k€3k€0
Aug 7Aug 14Aug 22Aug 29Sep 5

Customer type

The rule, not the screen

The thing I designed is not four dashboards. It is one rule: same skeleton, reweighted content, sourced from what each role actually does daily. A fifth role (ops) reuses the Admin view. New roles get a cheap starting point instead of a blank page, and the shell itself stays a single component to maintain.

05 / Rules Over Screens

Collapsing what the database knows into what a person needs to reason about.

A purchase order carries seven statuses in the database. The interface shows exactly four. I asked the procurement lead whether a middle state like “sent to supplier, awaiting confirmation” was worth adding. The answer: no, just upload the confirmation when it comes back.

draftpendingorderedconfirmedpartially_receivedreceivedcancelled
DraftOrderedReceivedCancelled

Seven database statuses, two of them legacy. Four buckets a person actually reasons in.

Negotiating confirmed price against list price and document discount didn't exist as its own screen before — the legacy system had one moment for a purchase: receiving.

Purchase order detail view, product rows collapsed into SPU groups with estimated quantity, stock, and pricing columns.

Products group by SPU, collapsed by default. Pricing panel on the right tracks document-level discount and shipping threshold, both of which change the per-line math.

Expanded SKU rows inside a purchase order, showing editable confirmed quantity and confirmed price fields next to system estimates.

Expanding a group reveals SKU-level rows. Estimated quantity and price sit next to editable confirmed fields, the pattern used everywhere OCR fills a value: the system proposes, the person on the counter decides. Column widths are fixed so expanding a row never reflows the ones a person already checked.

A product can't get a selling price until its real cost is known. Italian suppliers stack discounts like “50%+50%+7,69”, and each tier applies to what the previous one left, not to the list price. This calculator gives the real unit cost before ordering, and the same number once the supplier confirms.

LiveTry a supplier’s discount terms before ordering, or a line exactly as printed on its confirmation.
IVA

Real purchase cost per unit

Incl. IVA 22% · estimate before ordering, confirmed after upload

€5.4620

If the tiers were added

50% + 50% + 7.69% = 107.69% off

−€1.5380

Negative cost

Chained, as suppliers mean it

0.5 × 0.5 × 0.9231 = 0.230775

€4.6155

  1. 01

    End on the number pricing needs

    The chain resolves to one figure, the tax-inclusive cost per unit. That is what gets saved as the product’s purchase cost and what the selling price is marked up from.

  2. 02

    Same math before and after the order

    Before ordering, a buyer enters a supplier’s discount terms to estimate what one item will really cost. After the confirmation is uploaded, the confirmed price is computed the same way, so the estimate and the final price never disagree on method.

  3. 03

    Accept the text exactly as printed

    “7,69” is 7.69 and “1.250,00” is 1250. The parser reads whichever separator comes last as the decimal and ignores prefixes like “sc.”, so nothing has to be retyped.

Receiving is where the two systems overlap: both record what physically came in. Drag to compare the same task, years apart.

Receiving workspace comparing ordered vs received quantities per SKU, with a short-shipment note and linked supplier confirmation, DDT, and invoice documents.
Legacy desktop inventory software: a flat purchasing table with a generic supplier dropdown, a single receiving date and warehouse, and no distinction between ordered and received quantities.
BeforeAfter
Drag the handle, or use the arrow keys, to compare.

Before: one flat table, a generic supplier label, a receiving date and warehouse, no distinction between what was ordered and what arrived. After: ordered and received quantities sit side by side per SKU, with the actual discrepancy (“short on Classic bonder 8ml”) recorded in place, not on a separate exceptions screen.

06 / One Rule, One Exception

The system has no undo button. So it has an opinion about when to ask twice.

Every action that touches stock or money is irreversible once confirmed. The default rule: state the consequence, then ask — not “Are you sure?”

Confirm stocktake — dialog copy

About to:
  · Adjust stock for 12 SKUs
  · Record €340.50 in losses (posts to finance)
  · This cannot be undone

Then one interview broke the default on purpose: a customer finishes checking out, remembers one more item, while the next customer waits. A confirmation dialog here is not caution, it is a tax on the line. So hold/resume uses an instant toast instead:

Held: Zhang San, 5 items

Toast shown after holding an in-progress order, no confirmation step

Hold and resume aren't new — the legacy system had the same buttons in the same corner. What changed is context per row: packaging tier, live stock, oversell warning, gift flag, all inline at the moment of typing a quantity.

New product sales screen: the same Hold and Resume buttons, with a packaging-unit popover open on one line (piece or box of 3, with prices), live stock, an Oversold 2 pcs warning on a gift line, and a pre-tax and VAT breakdown.
Legacy desktop POS screen: product sales table with price, discount rate, discounted price, quantity, and unit, plus Hold and Resume buttons.
BeforeAfter
Drag the handle, or use the arrow keys, to compare.

Same page, same hold/resume buttons. Packaging tier, live stock, and tax breakdown are the new additions per row.

Logged, not hidden

21 native browser confirm dialogs still violate this policy, tracked by file in the same document that defines the rule. No new ones; migrate opportunistically when a file is already open. That's what keeps it a policy instead of an inconsistency nobody owns.

07 / Two Cadences

Sales and finance disagreed about invoicing. I didn't pick a side.

The disagreement sits at the last step of an order's life: when does money become an invoice?

Sales wanted

Invoice the moment an order closes, then strip out anything that turns out not to be invoiceable during review.

Finance needed

B2B customers settle in batches over time. An invoice only makes sense once payment has actually come in, sometimes covering several orders at once.

Neither side was wrong — two real cadences in the same business. The system holds both timelines instead of forcing one workflow to be “correct” and the other a workaround: a sales order tracks shipment and payment as two independent tracks, not one combined status.

Before“Is this order paid?” had no field to check — it meant checking MPSOFT, WeChat chat history, invoice records, and bank transfers separately, by hand.
AfterTwo fields on the order itself, tracked independently:
Shipment
Not shipped
Partially shipped
Shipped
Payment
Unpaid
Partially paid
Paid

A shipped order can still be unpaid. A paid order can still be waiting on a partial shipment. Invoicing reads the payment track, not the shipment track — which is why it can't just fire when an order closes.

08 / One Order, End to End

Payment, shipping, and invoicing stopped being notes.

Two of the legacy steps weren't features, they were notes fields standing in for features — and one didn't exist at all.

BeforeSeven steps, two of them a note field standing in for a status, one a feature that didn't exist
01Select customer
02Add product
03Search product
04Add product
05Paymentnoted in free-text remarks
06Shippingnoted in free-text remarks
07Invoiceno feature — recorded by hand, outside the system
AfterFive steps; payment, shipping, and invoice are each a real status, not a note
01Select customer
02Search + add product
03Payment
04Shippingpartial shipment, tracked per batch
05Invoice

Before: adding products took two separate mechanisms (old-item picker, new-item picker), and payment, shipping, and invoicing were all the same move — write it in a notes field, or nowhere. After: one search adds anything, and the three money-and-goods steps each have a real status the rest of the system can read.

Four screens along that flow, from the sales counter to finance — captured from the production code, with English demo data.

Product sales screen with five catalog products for Luce Beauty Lab, each with its product photo: L'Oréal Vitamino Color and Absolut Repair shampoos sold by the piece, Olaplex Nº7 by the box and a DEF anti-dandruff shampoo by the carton (chosen from packaging dropdowns), one line at a 0.95 discount, and an OPI cuticle oil toggled to a gift with its price struck through. The cuticle oil shows an Oversold 2 pcs warning. The footer splits the €823 goods total into pre-tax and VAT.

Building the order: one search box adds anything, but each line keeps its own packaging unit — plain text when a SKU has one unit, a dropdown when it sells by box or carton. There's no separate “add gift” flow: any line becomes a gift by toggling its own row, and drops out of the total.

Save sales order dialog: Collect payment now is ticked with a card payment of €823, and Picked up / partially picked up is ticked with a per-line table where 2 of 4 Absolut Repair bottles and 0 of 3 cartons of anti-dandruff shampoo are picked up.

Saving the order asks two separate questions. Was it paid, and how? Did the customer take goods with them? A partial hand-off isn't a status someone picks — it's simply picking up fewer than were ordered, line by line.

Sales order OUT-2026-001248 with Partially shipped and Paid badges, an Ordered, Shipped, Completed tracker with Shipped half-filled, and shipping progress at 34 of 39 pieces. The Deliveries tab lists a shipped pickup, a draft courier delivery for the shampoo cartons, and two unshipped Absolut Repair bottles. The sidebar shows Invoice status Not invoiced and Payment status Paid in separate boxes.

The same order afterwards: shipment is partial — one pickup done, a courier delivery still in draft, two bottles not yet allocated — while payment is already complete. Invoice status and payment status sit in separate boxes, so neither is inferred from shipping.

New invoice dialog for Luce Beauty Lab: the paid order's lines with remaining quantity, quantity on this invoice and editable invoice amounts, the gift line unselected, and a shipping line included. Invoice settings on the right show an €831 total, card payment, operator and notes.

Where the payment track turns into an invoice: finance picks lines out of paid orders and sets the amounts, instead of editing a document generated when the order closed. The gift line is listed but carries no amount; shipping becomes its own line.

09 / How I Worked

Solo, with an AI coding agent as the implementation partner.

I made the calls this case study describes — the status model, the confirmation policy, the dashboard skeleton, the invoicing compromise — and wrote them down before any code existed. Claude Code built against that written record, not against a conversation.

01

Spec

What the screen must do, which states it passes through, which existing rule it has to match.

02

Agent builds

Claude Code implements the React/TypeScript frontend and Supabase backend against the spec.

03

I review

Against the same spec and UX.md, not against memory of what I asked for.

10 / Honest Status

This shipped as an MVP and is in daily use. The evidence here is design reasoning, shipped code, and a test suite, not usage analytics.

ModuleStatusNote
Phase 0 · FoundationShippedSupabase, Vite, and Workers scaffolding
Phase 1 · AuthShippedLogin, JWT roles, route guards, dashboard
Phase 2 · ProductsShippedCategories, products, SKUs, multi-tier packaging
Phase 3 · ProcurementShippedSuppliers, requests, orders, receiving
Phase 4 · SalesShippedOrder creation, shipping, returns, exchanges
Phase 5 · CustomersShippedAccounts, fund ledger, tiers
Phase 6 · FinanceShippedInvoicing, payment ledger, reconciliation; Fatture in Cloud sync not yet connected

How correctness is checked

A Playwright suite tests the money invariants chapter 06 argues can't be undone — multi-unit packaging math, bundle stock deduction, gift-line zero-receivable, partial-shipment reservations, and one real invoice-rounding regression (57.38 × 1.22 × 5) kept permanently as a test. Most runs against a mock backend; a gated subset writes to the real database. Not yet wired into the deploy pipeline, so a bad merge can still ship without failing a check.

Accessibility, audited and fixed

17screens and dialogs audited against WCAG 2.2 A/AA
282 → 7violations found by axe-core, before and after
11 → 1failing rules, the last one a documented exception
4.03 → 5.39contrast of white text on the primary blue

I ran axe-core across the live code — dashboards, sales, finance, procurement, inventory, customers, settings, and the two money dialogs. Most failures came from a few shared pieces, so the fixes were systemic rather than page by page: the brand blue moved from #197afa to #1366d6, status text got its own darker tokens, every select and number input now has an accessible name, the open-pages strip became a keyboard-operable tab list, and order details use valid description lists. The 7 remaining are 16px stepper arrows beside a full-size number input, covered by WCAG 2.5.8's equivalent-control exception.

What's next

WeChat storefront cutover

Still runs on a third-party SaaS backend, not this platform's own data. The plan connects it through a thin API layer, paired with a visual agent already generating product cover and detail shots.

Fatture in Cloud connection

Invoices generated here don't reach Italian e-invoicing yet. Wiring up that API is the remaining piece of Finance.

Known limits

Interview coverage

One person per role. Enough to expose mechanics, not enough to prove it generalizes across all fifteen staff.

Confirm-dialog migration

Tracked in chapter 06, not finished.

A separate, deeper case study covers the AI procurement agent built on top of this same platform: Procurement Agent for DEF Beauty Supply. This page is about the platform underneath it.

11 / Recap

Four rules, not forty screens.

01

One skeleton, reweighted

Four dashboards share a shell; what changes is which numbers earn a tile, sourced from what each role told me they check.

02

Collapse to what people reason in

Seven backend statuses become four buckets, because an interview confirmed the extra states have no owner.

03

Confirm by default, except where a queue is real

One consistent policy, one logged exception, and the remaining debt tracked instead of hidden.

04

Two tracks, not one status

Shipment and payment move independently, so sales can hand goods over and finance can invoice on its own cadence.