Seller Portal: Listings, Tiers & Quotas
Business view
Every supplier gets its own login to a dedicated app — supplier-web, branded "Supplier Portal" — separate from the clinic/pharmacy product. A supplier's staff log in with a company login name, username, and password (same two-step flow as the pharmacy app), and land on a dashboard showing at a glance: how many products they've listed, how many are currently active, how many orders are open vs. delivered, and how many RFQ invitations they've received.
From there, the two things a supplier does day-to-day are:
- Manage listings — what they sell, at what price, with what minimum order quantity and lead time.
- Watch the order inbox — see what buyers have ordered and move each order through fulfilment.
Both RFQs (quote requests from buyers) and the standalone accounting view are paid-tier features: a supplier on the free/starter plan sees them in the navigation as locked, with an upsell message, until they upgrade. This is the plan/tier system: suppliers sign up on a plan (tiers 0–3), and each capability in the portal is unlocked at a specific tier. A plan also caps how many listings a supplier can publish — trying to add one more past the limit is blocked with a clear "upgrade your plan" message.
Technical view
The app
packages/supplier-web is a new Vite + React + Apollo SPA, copied from the existing pharmacy-web app's architecture (same session provider, same UI kit, same permissions pattern). Dev server runs on port 3020 (packages/supplier-web/vite.config.ts), proxying /graphql to the API server. Branding is deliberately placeholder: SUPPLIER_BRAND in packages/supplier-web/src/brand.ts sets displayName: 'Supplier Portal' and an empty hostnames: [], with a comment that the real trade name is pending registrar approval — no production subdomain is configured yet, which is why it isn't reachable in the sandbox (see Walkthrough).
Routes (packages/supplier-web/src/App.tsx): /login, then behind session auth, / (Dashboard), /listings, /orders, /rfqs, /accounting, all wrapped by a shared AppShell (icon rail + content area).
Two independent gating systems
This module layers two separate checks, and both must pass:
- Permissions (
lib/permissions.ts) — per-user role flags computed fromuser.group.permissions, e.g.viewListings,addListings,editListings,deleteListings,viewOrders,manageOrders,viewRfqs,submitOffers. This answers "can this staff member do this," same as any other Dentolize permission group. - Entitlements (
SupplierEntitlements, fetched via thesupplierEntitlementsquery) — per-company subscription-tier flags:accounting,bulkImport,rfqRespond,analyticsBasic,featuredListings,multiUser, plusmaxListings/listingCount. This answers "does the vendor's plan include this feature at all."
Both the RFQs and Accounting nav items independently re-fetch entitlements and render a locked empty state even if reached directly by URL — not just hidden from the nav (packages/supplier-web/src/modules/rfqs/RfqsPage.tsx, .../accounting/AccountingPage.tsx). The Accounting page itself is currently a stub — a single "provisioned" confirmation card, no real ledger UI yet.
Entitlement tiers (packages/server/src/services/trade/entitlements.js)
A vendor's effective tier (0–3) comes from its VendorSubscription.plan.tier, downgraded to tier 0 automatically if the subscription isn't ACTIVE/PAST_DUE/live-TRIAL — "so in-flight orders can still close" rather than a hard lockout (entitlements.js:62-63). Feature → minimum tier (VENDOR_FEATURE_DEFAULTS, entitlements.js:15-38):
| Tier | Features this PR uses |
|---|---|
| 0 (free) | INVENTORY, ORDERS, LISTINGS — the core listing/order functionality is not gated |
| 1 | ACCOUNTING, BULK_IMPORT, RFQ_RESPOND, ANALYTICS_BASIC, MULTI_USER |
| 2 | FEATURED_LISTINGS (add-on) |
hasVendorEntitlement(prisma, companyId, key) checks, in order: a per-vendor hard revoke override → hard grant override → a purchased à-la-carte add-on → else tier >= minTier. checkVendorQuota(prisma, companyId, quotaKey, currentCount) resolves maxListings/maxImagesPerListing the same way (override wins over the plan field; null = unlimited).
Every gated resolver re-checks server-side — the UI checks are for user experience, not enforcement:
submitSupplierOffermutation (packages/server/src/resolvers/mutations/supplierMutations.js:30-36) throws"Responding to RFQs requires the Growth plan or higher."ifRFQ_RESPONDisn't entitled — even if the user's role permission (SUBMIT_VENDOR_OFFERS) would otherwise allow it. This is deliberate: "a tier downgrade locks bidding even for a permitted user" (file header comment).supplierRfqs/supplierRfqqueries (resolvers/queries/supplierQueries.js:33-42) silently return[]/nullif not entitled, rather than erroring.
A gap worth flagging: the entitlement registry also defines RFQ_BROADCAST_CITY (tier 1) and RFQ_BROADCAST_REGION_ALL (tier 2) — presumably meant to gate how widely an RFQ can reach a vendor by geo-broadcast. But offerService.js's reachableRfqIds (which decides which broadcast RFQs a vendor can see) never checks either key — any vendor whose service area geo-matches an open broadcast RFQ can see and bid on it, gated only by the flat RFQ_RESPOND check, regardless of broadcast tier.
Listing CRUD (packages/server/src/supplier/listingService.js)
Every read and write is scoped by vendorCompanyId — a vendor can never touch another vendor's listings, enforced by findFirst({ where: { id, vendorCompanyId: companyId } }) before every update/delete (listingService.js:50-53, 111-125, 127-140, 142-154).
- Create (
createSupplierListing,listingService.js:89-109): counts existing listings, callscheckVendorQuota(..., 'maxListings', count); if not allowed, throws `Listing limit reached (${quota.limit}). Upgrade your plan to add more listings.`. This is a hard block — contrast with images below. - Images:
sanitizeImagescallscheckVendorQuota(..., 'maxImagesPerListing', 0)and, if over the limit, silently truncates the list to the limit (Array.slice) rather than erroring — an intentional asymmetry: the listing count is hard-blocked, the image count is soft-clamped. - Delete (
deleteSupplierListing,listingService.js:142-154): if the listing is referenced by any historicalTradeOrderLine(i.e., it was ever ordered), it's soft-retired (active: false) instead of hard-deleted, to preserve order history. Otherwise it's hard-deleted. Both paths returntrue— the caller can't tell which happened from the return value alone. - Defaults applied on create/update:
type: 'PRODUCT',price: 0,currency: 'SAR',moq: 1,packSize: 1,availability: 'IN_STOCK',active: true(listingService.js:71-87).
The Listings page (ListingsPage.tsx)
Table of the vendor's own listings — Name, SKU, Type, Price, Availability (a toggle button flipping IN_STOCK ⇄ OUT_OF_STOCK), Status badge, Edit/Delete actions. The "+ Add listing" button is disabled with a tooltip once listingCount >= maxListings, and an inline red banner explains why. The add/edit modal covers every field in SupplierListingInput: name (+ Arabic name), type, SKU, price, currency, MOQ, pack size, availability, lead time, active flag.
Order inbox
See Order Lifecycle & Accounting for the full seller state machine and how fulfilment posts to the books. On the Orders page, filter tabs mirror the state machine (All / Placed / Accepted / Preparing / Shipped / Delivered); clicking a row opens a detail drawer with line items, totals, delivery address, and notes, plus one button per currently-valid next status.
RFQs and offer submission
See RFQs & Offers.