A Bangkok beauty clinic group with 10 branches — Siam Paragon, EmQuartier, ICONSIAM, CentralWorld, Thonglor, Phromphong, Asoke, Ari, Mega Bangna, Sathorn — was running 140+ appointments per day on a stack that looked like every Thai SME you've ever met:

  • One shared Google Sheet per branch, color-coded by status
  • A LINE group per branch for staff comms
  • A second LINE group for management
  • An Excel file at HQ trying to reconcile all 10 sheets daily
  • Doctor schedules sent as JPEG screenshots in LINE
  • Customer history kept in physical paper files

The owner could not answer simple questions in real time. Which branches were under-booked today? Which VVIP clients hadn't come back in 60+ days? Which services were trending? Was GoWabi (the third-party booking platform) actually profitable, or were the commissions eating the margin? Every answer required a half-day of spreadsheet archaeology.

So we built them a real operating system. One URL. Five role-specific views. Live across all 10 branches. Bilingual EN + ไทย. No login flow yet — just open it and click around.

Live system · no signup

Open the actual working build

Everything described below is live in your browser right now. Click through every tab, switch the role, change the branch — it's all real seeded data running on a CDN edge near you.

Open Beauty Clinic Console →

The five role views

The top of every screen carries a role switcher: Staff · Doctor · Manager · Top Mgt · Marketing. Each role sees a totally different cockpit — same underlying data, different angles. Anyone in the clinic can flip to "Top Mgt" view to see what the owner sees. (In production we lock this behind permissions; on the live demo it's wide open so you can poke around.)

Staff view

Today's arrivals

Single-branch focus. Today's appointment table, status pills, follow-up actions for late or cancelled clients. The Front-of-house cockpit.

Doctor view

My schedule + treatment notes

What's on your chair today, the customer's previous treatments, aftercare templates, photo library. The clinical cockpit.

Manager view

Branch ops

Today + week ahead, staff schedule, stock + supply alerts, low-margin service warnings, branch P&L preview.

Top Mgt view

10-branch network

Network revenue, branch ranking, churn cohort by month, top-spender list, action items (close GoWabi? launch VIP win-back? raise a service price?).

Marketing view

Campaigns + audiences

Segments by lifetime value, package promotions, AI-suggested win-back queue with copy already drafted for LINE OA.

The booking status flow — 8 states, two cancel branches

Most clinic systems we benchmarked had 3 states: booked / arrived / done. Useless. A real clinic spends most of its operational energy on the weird states — the client called to cancel, the client is late but the staff doesn't know if they're still coming, the client showed up 45 min late and now the chair is overbooked.

So the booking state machine has eight nodes, and the cancellation path forks based on a single SOP question: are you willing to wait in the cancel queue?

// Booking state machine waiting → staff calls client to confirm confirmed → client confirmed for today arrived → ลูกค้ามาถึงแล้ว in-progress → on the chair done → treatment complete, post-care sent // The late path — staff decides which branch to take arrived-late → "client arrived 30 minutes late" ├── late-waiting → willing to wait in cancel queue └── late-left → client left, no slot today // The cancel path cancelled → SOP: ask when & which branch they'll return

That bottom SOP — ถามลูกค้าสะดวกเข้ามาอีกทีเมื่อไหร่ สาขาไหน — is hardcoded as the cancel-action label. Staff doesn't have to remember; the action button literally tells them what to say. The follow-up gets logged against the customer record, the customer auto-enters the "follow up" queue, and the next day's first-thing task list surfaces them.

VIP protection — a rule the spreadsheets couldn't enforce

The clinic's tier rule is simple: a client who has spent ≥ 150,000 THB lifetime is VIP or VVIP. Their previous spreadsheet had no way to enforce a tier-based rule, so high-spenders occasionally got marked as late-left when they were 30 minutes late — and the owner would find out three days later that a 600,000-baht customer had quietly churned.

In the new system, the status transition simply refuses to land on late-left for anyone above the VIP threshold:

// VIP protection — enforced at state-transition time const isVIPOrAbove = c.totalSpend >= 150000; if (nextStatus === 'late-left' && isVIPOrAbove) { nextStatus = 'late-waiting'; // route to cancel queue, never "left" surfaceAction(c, 'VVIP — call to retain'); // raise alert }

The principle: "VIP can churn — they spend less. VIPs and above cannot leave the system as 'left'. They must surface as 'follow up' so the owner sees them." That's the whole rule. Three lines of code, but only possible because the data was unified in one place.

Multi-branch ops — what the owner finally has

"Top Mgt" view is the one the owner opens first thing every morning. It folds 10 branches' worth of state into a single screen:

// Top Mgt · today's network snapshot Total today ........ 142 bookings · ฿487,200 gross Confirmed ........ 38 (27%) Calls outstanding ........ 11 (within 90 min) Cancel rate ........ 8% // Branch ranking — net revenue today 1. ICONSIAM ฿64,200 margin +18% 2. EmQuartier ฿58,400 margin +22% 3. Siam Paragon ฿57,100 margin +15% 4. CentralWorld ฿52,300 margin +12% 5. Thonglor ฿49,800 margin +19% ... // Action today — what the system says to handle ! 5 VVIP clients haven't visited in 60+ days · AI-suggested offer ready ! Mega Bangna — 3 cancel-queue clients unhandled past 24h ! Stock alert · Ari branch · Stylage XXL down to 4 vials

The "Action today" block isn't a passive dashboard. Each row is a tappable command that opens the relevant queue with the right action pre-loaded. The owner doesn't have to navigate; the system pushes work at them.

AI-suggested upsells — drafted, not just suggested

For every customer, the upsell column doesn't just say "try Pico." It gives staff a specific, dated, priced offer to read off — already keyed off the customer's history:

  • First-time client: "First-time bonus: ฿500 off next visit + welcome kit"
  • Glow / Brightening regular: "Pico package · 10 sessions ฿55K (39% off retail)"
  • VVIP: "Offer concierge package, free upgrade — never let them leave"
  • 3+ month silence: "Win-back — AI-drafted LINE OA copy queued in Marketing tab"

The Marketing role then sees all the AI-drafted messages in one queue and either approves, edits, or sends to LINE OA in batches. No copy is ever "AI sends." Approval is always human — the AI is the copywriter, not the broadcaster.

The closure decision the owner couldn't make before

One of the most interesting outputs the new system surfaced in week one was about the clinic's GoWabi (online) revenue line. GoWabi takes a ~20% platform commission plus a daily marketing levy. The clinic had no way of seeing per-channel margin before; on the spreadsheets, GoWabi's gross looked great.

Plug the real numbers into a per-branch P&L (COGS 30%, allocated overhead, platform commission, marketing spend) and the picture flips:

// GoWabi vs. physical branches · daily P&L Gross revenue ฿34,200 COGS (30%) -฿10,260 Daily ops overhead -฿5,000 Platform commission (20%) -฿6,840 Daily marketing levy -฿1,667 Daily margin +฿10,433 (vs. ฿15K+ at any physical branch) // 6 months in the red on net contribution after platform fees: Cumulative platform cost over 12 months: ~฿830,000 GoWabi-only buyers who never re-booked direct: 22 of 31

The system surfaced the action: "Close GoWabi · 6 months in the red". With one click, the owner could schedule the channel closure, generate a closure-notice draft, and queue a win-back offer to the 22 platform-locked customers — "come direct, same price, double loyalty points." That decision was invisible to them before the dashboard existed.

How it's built — the boring (but honest) tech

  • One HTML file. React 18 + Tailwind CSS, both via CDN. No build step. The entire app is a single index.html.
  • State management: plain React useState + useMemo with an in-memory store. No Redux, no Zustand, no overhead.
  • Bilingual: a single L(en, th) helper + dictionary lookups. Language toggle at the top right.
  • Hosting: Cloudflare Pages on a custom domain. Global edge cache. ~50ms load time anywhere in the world.
  • Seed data: realistic Thai customer names, doctor schedules, services with Thai/English category labels, and a status timeline that produces believable analytics.
  • The whole thing is ~25K lines of JSX with all the seed data inlined. Could be deployed by uploading one file.

Could the same thing be done with Salesforce + Mendix + a 6-month onboarding? Yes. Would it cost ฿2.5M+ year-one and require the clinic to hire a Salesforce admin? Also yes. Custom built, this clinic's system shipped in under 3 weeks at a fraction of that, with no recurring license fees and full IP ownership of the code.

What we'd build for you

The same architecture works for any multi-location operation that's stuck in spreadsheets: dental clinics, fitness studios, salons, restaurant groups, retail chains, real-estate brokerages, dental groups, F&B (with Grab + LINE MAN Wongnai channel integration). The pattern is always the same — multi-role console, network-level dashboard, action queues that push work at the operator instead of hiding it in tabs.

Open the live system, then book a call.

Click through every role. Switch branches. Try the cancel-queue flow. If the way it handles your category isn't obvious from the demo, book a call — we'll scope a system for your operation. IP-clear contracts, Git repo handed over on final invoice, no platform lock-in.