No off-the-shelf product fit. So I designed and shipped one from scratch: eight integrated modules spanning architecture, permission systems, an AI analysis layer, UI/UX, infrastructure, and production deployment. Then the company built the rest of itself on top of it.
Plus an AI analysis layer that sits across these modules, consuming their data rather than forming a ninth (see 04 — AI Layer).
I designed and shipped a fully integrated internal operations platform covering eight functional domains — built independently, from system architecture and data modeling through UI/UX, infrastructure, and deployment.
The guiding philosophy was Operational Truth + Immutable Logs: business data can be updated, but work records and activity logs are written once and never edited. Nothing is deleted, only archived. This single principle shaped every architectural decision downstream.
The organization ran eight operational domains across disconnected tools — spreadsheets, chat threads, standalone apps — with no single source of truth, no audit trail, and no way to correlate data across functions like project hours, leave requests, and resource bookings.
The specific workflows were the problem: attendance records that feed back into project work logs, a rental system covering rooms, vehicles, and equipment in one interface, and a permission model where two managers with the same role see completely different data depending on their assignments.
So I built it from scratch.
Everything resolves to a single source of truth. Identity flows down from Employee Management; operational data flows up into Project Management; events and records flow out to the calendar, logs, and AI analysis. Hover or tap any module to trace its links; toggle the legend to see which modules push notifications out.
Meeting Record is not a ninth module — it extends the Rental system's Meeting Room subtype, linking meeting decisions into task assignment and writing them back into Project Management.
Project management is where the platform's data density pays off. I built a configurable LLM layer that reads live project and work-log data straight from Firestore and turns it into structured, executive-ready analysis, grounded in the organization's real context rather than generic model output.
The layer connects to multiple major LLM providers through one interface, with model selection exposed per run. Switching models for cost, capability, or availability is a configuration choice, not a rewrite.
Output is driven by the organization's own knowledge: client brand profiles, the project database, and reusable capability templates feed a retrieval layer, so analysis reflects real entities and prior work rather than invented specifics. Context is assembled deliberately: the right records, in the right order, within the model's window.
Source material is summarized and compressed before it reaches the model, preserving signal while cutting token consumption across high-volume calls.
Prompts are calibrated for a consistent output style and format, so analysis lands in a shape the team can act on immediately.
Each run is saved as its own tabbed version; re-analyzing produces a new version instead of replacing the last: the same write-once principle that governs the rest of the platform.
Sample run · Week 26 (Jun 22–28, 2026) Analyzed 2026-06-27 00:50 · live from Firestore Tokens 7,783 (in 6,961 / out 822) Model GPT-4o (OpenAI) Est. cost ~NT$0.82 (US$0.0256 @ NT$32)
Per-run token usage and cost are surfaced directly in the output, a designed outcome of the compression and context strategy above. Management sees exactly what each analysis consumes and what it returns, instead of an opaque AI bill.
Frontend Layer → React PWA (TypeScript, Tailwind CSS) · Vercel Application Logic → Permission Engine · Audit Logger · Snapshot Builder Firebase Platform → Firebase Auth + Firestore + Functions Data Layer → Operational · Immutable Logs · Snapshots · Archives Intelligence & Output → LLM Analysis Layer · PDF / Excel Export
The first four layers govern truth: what's recorded and how it's protected. The fifth layer consumes that truth and produces derived artifacts on top of it: AI analysis, reports, and document exports. Those artifacts follow the same write-once discipline (an analysis run is versioned, not overwritten), so the intelligence layer extends the system's philosophy rather than bypassing it.
The data access patterns were document-centric and user-scoped. Firestore's real-time listeners reduced round-trips for dashboard views, and its security rules let me enforce access control at the database layer as a second line of defense. I configured composite indexes manually to handle complex multi-field queries across modules without performance degradation.
Multi-step operations run through Firestore transactions: a leave approval that updates both the request and the linked work log, a rental booking that checks availability and writes the calendar event atomically. Either everything succeeds, or nothing does.
I designed a hybrid model combining Role-Based and Attribute-Based Access Control.
CHAIRMAN → CEO → DIRECTOR → MANAGER → GENERAL → HR → FIN → ADMIN / OPS_ADMIN + Project Manager Permissions
On top of roles, permissions are constrained by three attributes:
Two users with the same role can have entirely different data visibility. A PROJECT MANAGER sees only projects they're assigned to; a GENERAL user sees only their own records. The permission engine is validated independently of Firestore security rules — a defense-in-depth design where even a misconfigured query can't leak data.
Every entity carries a structured ID that encodes its context directly — no joins required to read a log.
| Entity | Format | Example |
|---|---|---|
| Project | YYYYMMDD-PNN | 20260307-P01 |
| Item | YYYYMMDD-PNN-INN | 20260307-P01-I01 |
| Task | …-PNN-INN-TNN | …-P01-I01-T01 |
| Work Log | YYMMDD-WLNNNN | 260328-WL0001 |
| Leave Request | YYYYMMDD-TYPE-NNN | 20260310-OFF-001 |
The embedded date refers to the parent entity's first actual start date, not the record's creation date. Once written, IDs never change.
Meeting notes are usually where decisions go to die. Here, a meeting record links straight into the work pipeline: captured decisions become assigned tasks and push real updates to the associated tickets, so what was agreed and what's tracked never drift apart, and every decision stays connected to the work it produced in one trail.
Meetings are recorded directly from the meeting-record page in one tap, uploaded to Firebase Storage, and transcribed by Gemini 2.5 Flash on Vertex AI. The transcript is prompt-tuned for Traditional Chinese, multi-speaker prefixes, and explicit unclear-audio markers, then feeds the same AI analysis layer that surfaces decisions and action items, turning a spoken conversation into structured, assignable tasks with no minutes typed by hand.
Why Vertex AI, specifically: meeting content is confidential business data, so I deliberately routed transcription through Vertex AI rather than a public API endpoint — enterprise data governance guarantees the audio and transcripts are never used to train models. Access to transcription follows the same permission model as the rest of the system (record owner, attendees, or senior roles only). Recordings over 30 minutes are truncated on capture, a deliberate boundary around serverless execution limits.
A follow-up release chained the whole flow behind one button: transcript, then AI summary, then suggested dispatch. The dispatch step pulls decisions and to-dos out of the transcript, matches them against existing project tasks, proposes an owner for each, and skips resolutions it already generated; items worth keeping but not assignable land under "other discussions". A pre-meeting keyword field (names, products, companies) cuts recognition errors, long recordings are transcribed in segments with visible progress, and every record shows its accumulated AI cost at the bottom. Original audio is always preserved, and a failed transcription says exactly why.
Attendance records and room bookings are managed via the Calendar API across the full event lifecycle: not just creation, but update and deletion with dynamic attendee management as assignments change. Keeping external calendar state and internal system state consistent, handling partial failures and avoiding drift, was the real engineering work beyond simply calling the API.
Real-time notifications through two channels: Google Chat for office staff, LINE for field staff. They cover approvals, task assignments, and announcements. The system comes to the user, not the other way around.
The approval engine reads live data from the Attendance module: when any approver, from a department head up to the CEO, is on leave or traveling, requests escalate to the next level automatically, and pending-approval reminders to that person pause. Someone back from a trip isn't greeted by a pile of expired notifications. The modules feed each other, and the workflow adapts to organizational reality on its own, one more persona-aware behavior falling out of the single-source-of-truth design.
External attendance data is reconciled into the immutable work log via webhooks. Key modules export to PDF and Excel, the output half of the Intelligence & Output layer. A server-side layer sends email notifications as a reliable fallback to the bot channels.
The UI/UX was designed independently, informed by a background in graphic design. Visual consistency across eight modules — spacing, hierarchy, color, interaction — was treated as a first-class requirement, not an afterthought.
Six decisions govern how the platform loads, renders, and reads data. Each one started with a number from the actual build, and the two tagged as core design are the ones I'd walk through first in a design review.
Instead of trusting automatic vendor splitting, manualChunks sorts libraries by how often pages use them. Code every page touches lands in shared vendor chunks (vendor-react, vendor-firebase, vendor-lucide): downloaded once, cached, reused on every navigation. Heavy libraries that serve a single page, like recharts and d3, stay inside the dashboard's lazy chunk and load only when someone opens it.
Two packages needed force. A build test showed that leaving xlsx unsplit put ~880 KB into the index bundle, a cost every page paid on every visit. Split out, it loads only on the export page; the whole ~1.1 MB package refuses to tree-shake, and that's an accepted trade. mock-data, at ~2.3 MB the heaviest single item in the index, got its own chunk as well.
Nearly every page mounts through React.lazy(() => import(...)): 75 call sites across the app. Named exports pass through .then(m => ({ default: m.Xxx })) because React.lazy only accepts default exports, and a single <Suspense> boundary with a spinner covers every loading state. Landing on the home page downloads the index, the shared vendors, and that one page's chunk. Everything else waits for a click.
No react-query, no SWR. fsCache exists for one reason: Firestore bills per read, and most reads here would return unchanged data. It caches on two levels, in memory and in localStorage, so a page refresh keeps the cache. getAll(keys) pulls several collections in one call and re-fetches only the stale ones, while an inflight Map dedupes concurrent requests so a race can't hit the same collection twice.
The TTL is 180 days. That number only makes sense as a fallback for when the onSnapshot connection drops; the real expiry logic comes next. A separate getEmployeeCache() keeps a 4-hour snapshot of employee data and serves stale data on a miss instead of blocking the page.
The conflict: a 180-day TTL is what actually saves money, but every client still has to notice a change within seconds. Long TTL and fresh data pull against each other, and cache invalidation is famously one of the two hard problems in computer science. The resolution is a version broadcast:
cacheVersionSync, which bumps that collection's version number in a single doc: system_meta/cache_versions.onSnapshot listener, on that doc. The version change reaches all of them at once.getAll() sees the expired fetchedAt and re-reads just that collection.cn-cache-versions-v1 in localStorage records versions already seen, so a reload doesn't re-invalidate data that's still fresh.A count of what the codebase actually contains:
useMemo does the real guarding: filtering, sorting, assembling project chains, computing statistics. Those are recalculations worth avoiding. React.memo and list virtualization guard re-renders themselves, and at a 30-person scale there's nothing there worth guarding yet; wrapping every child component would be optimizing for a size the system doesn't have. useCallback follows the same logic: with no memoized children, a stable callback reference buys little, which is why it appears 69 times against useMemo's 587.
The next bottleneck already has a name. When work_logs passes ten thousand entries and filtering or print views start to drag, list virtualization goes in first. What each tool solves, and whether today's data volume has that problem, matters more than how many tools show up in a grep.
Data access splits into two paths, both built on native fetch and the cache layer above.
updateDoc, writeBatch, runTransactionfirestore.rules stands as the second permission gate behind the app's own enginegetAll() across up to five collections, in parallel, deduped in flightfetch('/api/...') appears 29 times across the app?action=The usual answer to a function ceiling is a paid plan. Keeping bulk data off the API entirely, and multiplexing what remains behind ?action=, decouples how many features ship from how many functions the platform allows.
Delivering this to a live organization meant owning the Google infrastructure end to end:
Version control uses structured atomic commits — each message documents intent and scope, serving as the sole maintainer's PR description and changelog. A version changelog is maintained inside the system itself.
The technical architecture was the straightforward part. The real challenge was continuous requirement discovery across departments, each with different workflows, mental models, and sometimes conflicting priorities, and translating all of it into one system that serves everyone without compromising data integrity.
A general staffer needs frictionless work-log entry. A manager needs a cross-project workload view. A director needs clean KPIs. Finance needs an audit trail. HR needs attendance tied to project records. These aren't UI differences — they demand different access patterns, permission boundaries, and definitions of "done" for the same record.
I ran UAT cycles with staff before each module launched, and onboarded both general users and managers, adapting the explanation to each person's technical comfort.
After the full demo, leadership greenlit continued development on the spot. The leave module rolled out company-wide within a week, and a scheduling tool another team had been prototyping in parallel was consolidated into this platform's roadmap, a merge the prototyping team themselves had been hoping for as their design kept converging on what the platform already did. It passed the only validation that matters: the people closest to the alternative wanted to build on this one.