Building an admin platform from scratch: an ops console and a RAG Document Center

How I built Revotas's new admin platform alone: CRM, support and billing on legacy APIs, plus a Turkish RAG assistant that cites sources or abstains.

Admin console demo with synthetic data: sidebar, KPI cards with sparklines, recent invoices and tickets

You can read a product's quality faster from its admin panel than from anything customers ever see. The old one at Revotas was a stack of server-rendered pages, and it's where the people running sales, support and billing do their work.

So in April 2026 I started a new one. I was the only engineer on it. Between April 17 and October 1 that came to 78 commits across two repos, roughly 28k lines of TypeScript and TSX, and about 30 screens.

Screen recording of the demo-safe branch: mock data, no real API.

It ended up as two halves. One is an operations console: CRM, support tickets, billing and a bridge that drops you into a customer's workspace. The other is a Document Center, a RAG assistant that answers questions from our Turkish product docs and shows where each answer came from.

What made it hard

The backend already existed and I wasn't rewriting it. Every screen had to sit on endpoints built years ago for a different UI, with their own session rules and response shapes.

The data was real customer data, so I couldn't show my work to anyone outside the company. And the panel stays open for hours, so a slow leak or a render loop shows up by lunch.

The stack: React 19, TypeScript, Vite 6, Tailwind 4, Radix primitives and TanStack Table for the dense lists.

Decision 1: read the old contract before writing the new screen

My first notes were questions. Where does a user enter the panel? What's the first decision screen? Which legacy flow maps to which new page?

The answers lived in the old system's behavior, and it lied a little. One endpoint had a name that suggested it managed login users. It actually wrote the contacts table. Months later, that one name turned out to be the source of a confusing bug report.

Another endpoint returned customer names as UTF-8 bytes decoded as Latin1, so Turkish letters came back as garbage. A different endpoint returned the same rows correctly. I wrote a small repair function as a safety net and kept the clean endpoint as the source when I could.

Same story with list performance. A "last comment" column on the customer list would have meant about 1,460 requests, one per customer. I asked the backend team for a single bulk field on the list response instead, then wired it in one mapper. The lists also sit behind a 60-second cache, and two components asking for the same list share one in-flight request.

Search had to fold Turkish properly. "İ" and "ı" lowercase differently in Turkish than in English, so sorting and matching use the tr locale everywhere.

Decision 2: Cmd+K and the motion toggle in week one

These two look like polish. I made them first.

A power user wants to drive the panel from the keyboard, so a command palette (Cmd+K) handles global navigation. A motion-sensitive user wants animation turned down, so Panel Settings has an Animation Intensity toggle: Reduced or Full Motion.

Deciding this early changed real code. The login screen has a canvas background with five time-of-day atmospheres, and it throttles itself when the OS asks for reduced motion or the tab is hidden. Animated components had to take the motion setting as an input from day one. Bolting that on later would have meant touching every screen.

Decision 3: prove the migration didn't do it

We moved about 1,450 legacy customer records into the new CRM in a single run. A few weeks later a complaint came in: users from different brands were showing up under the wrong tenant, and the migration was the first suspect.

So I ran read-only SQL. The migration had written to four CRM tables and nothing else. No trigger on those tables creates a panel login. And the wrong-tenant pattern in the user table went back years before the migration date.

The fix ended up on another team's side, and the new users after it landed all went to the right place. I like this one because the answer was a query anyone could rerun.

Decision 4: a demo-safe branch

I wanted to share the work publicly without leaking a single customer row. So I cut a dedicated branch with a mock data layer, sanitized names, and sensitive flows behind feature flags.

It turned out to be its own small engineering job. Every screen had to render believable data with zero network calls. The recording above is that branch.

Decision 5: a RAG assistant that's allowed to say "I don't know"

The Document Center goes after a simple question: can someone get an answer from our Turkish product docs without reading them first?

The ingest path parses PDF, DOCX, Markdown and plain text, capped at 25 MB per file. The chunker splits by paragraph, then by sentence if a paragraph is too long, then by word as a last resort. Chunks target 800 tokens with 150 tokens of overlap.

Each chunk gets an embedding. A question gets embedded too, and cosine similarity picks the top 5. If the best match scores under 0.6, the answer isn't grounded, and the assistant says so.

In "docs only" mode it refuses outright and suggests uploading a relevant document. Small talk ("merhaba", "teşekkürler") skips that check so a greeting doesn't get a refusal.

The grounded prompt is written in Turkish and tells the model to answer only from the provided sections, and to say plainly when they don't cover the question. Sources go to the client as their own event before the first token streams, so the UI can show citation chips with the file name and a snippet. The server streams status stages too (searching, reading, generating), so the UI shows progress before the answer starts.

Model providers are pluggable. Gemini, Ollama, Hugging Face and DeepSeek each register behind one interface and turn on through config. Switching embedding models taught me one quiet bug: vectors from different models have different lengths, and comparing them gives nonsense scores. Search now drops any chunk whose embedding dimension doesn't match the query's.

What I'd do differently

The Document Center store is in memory. Restart the service and every document and embedding is gone. Fine for a prototype, wrong for anything else. Persistent vector storage is the next step.

The token count is an estimate: words times 1.3. Turkish is agglutinative, so I think that number runs low for long Turkish words. I'd swap in a real tokenizer.

And the deploy pipeline was too clever. I mirrored code between a working copy, a build machine and a server with a mirror-sync script. Run in the wrong direction, it deletes newer files. A review caught that before it bit, but a broken restore script did write 3 source files out as JSON-encoded strings. Today I'd use one repo and one build step.

Close

Two halves, one engineer, and most of the hard parts were reading what the old system actually did.

More Revotas work is at /work/revotas/.

Try the interactive demo

Have a problem worth solving?Let's make it reliable

Start a conversation

Open to full-stack and applied AI roles, remote or hybrid.

Tell me about the role, the team and the problem.