As case study
BDC Professionals dashboard
Full frontend build for a dealership BDC operations platform — 41 pages, role-based permission UX, design system, delivered solo on an agency engagement.
Role
Frontend developer · Agency engagement
Timeline
2025
Status
Internal
At a glance
The brief
BDC Professionals runs Business Development Center operations for car dealerships — the team that handles inbound leads, books test drives, coordinates with sales floors, and keeps the relationship between dealer and customer warm between visits. Their internal platform had grown into a tangle of spreadsheets, shared inboxes, and tribal knowledge.
The engagement scope was specific: deliver the full frontend of a replacement platform — every page, every form, the full permission UX, the dashboards — as if the backend already existed. The backend integration phase would follow with a separate team. My job was to lock the UX with realistic data flows before a database schema got committed to.
Approach
I picked a stack that let one person move fast without owning infrastructure:
- React + TypeScript for the UI, Vite for the build.
- Wouter for routing — small enough that 40+ routes don’t bloat the bundle, expressive enough to handle nested admin patterns.
- TanStack Query wired up against a localStorage shim. The cache, mutation, and invalidation patterns are real — when the API lands, only the fetch function inside each query changes.
- shadcn/ui + Tailwind for the component layer. Accessible primitives, ownable code (no library upgrades to manage), restyled to the dealership’s brand tokens.
- Drizzle ORM + PostgreSQL schema defined and committed alongside the frontend, even though the storage was still localStorage. The backend team would inherit a real schema, not a sketch.
The store architecture used a typed IStorage interface with a localStorage implementation. When the backend phase begins, swapping in a FetchStorage implementation is a one-file change, not a refactor.
The interface
Four representative screens from the platform. Click any image to see it full-size.
The permission system
The trickiest part wasn’t routing or forms — it was the role and permission system. BDC operations have overlapping accountability: agents handle their own dealers, supervisors see across agents, dealership admins see only their own location. The UX had to communicate exactly what each user could and couldn’t do without surfacing controls that would just throw errors.
I designed it as:
- 11 functional modules (Dealers, Appointments, SMS, Roles, Dealerships, Settings, Reports, etc.).
- ~60 individual permissions across those modules —
view,create,edit,delete, plus module-specific actions likesend_sms,assign_dealer,reassign_appointment. - A localStorage-backed role store with a
usePermission()hook wrapping every action. - Conditional rendering at three levels: sidebar items, page-level guards (designed for the auth phase), and per-control visibility (e.g., the “Delete” button on a dealer row only renders if the role has
dealers.delete).
Even though the backend will ultimately enforce these, having the UX check them early meant the support team could see each role’s view, give feedback, and lock the permission matrix before a single API endpoint got built.
Outcomes
What shipped at the end of the engagement window:
- 41 functional pages with realistic data flows, validated forms, and full keyboard navigation.
- The full sidebar + responsive header layout, with collapsible sections matching the dealership’s mental model.
- Client-side validated forms for every CRUD entity (dealers, appointments, SMS, roles, dealerships).
- A documented role / permission spec that the backend team could implement against directly — no follow-up clarifications needed.
- A migration path from localStorage to PostgreSQL with the Drizzle schema already in place.
- Component-level documentation so the next person picking it up could find what they needed without reading the whole codebase.
The agency moved the platform into its backend-integration phase on schedule.
What I’d do differently
Two things, in hindsight.
Build the auth wrapper before the pages. I built the permission system as a hook layer, but I left the actual page guards as “designed, to be enforced when auth lands.” That worked, but every page had to be retroactively wrapped. Doing it once at the layout level on day one would have saved a couple of days at handoff.
Commit to TanStack Query’s optimistic updates earlier. I built the forms with synchronous localStorage writes, then realized halfway through that the UI was going to feel sluggish once a real network round-trip was involved. Going back to add optimistic update patterns to forms that were already “done” was the part of the engagement I’d most like to redo.
Next case study
Royal Subz — building solo, end-to-end