Marcom: A Marketing Communications Material Dashboard
An internal dashboard for tracking marketing communications material by brand, cluster, feature, and type — with expiry reminders and realtime updates. Next.js 15 + React 19 on the front, Bun + Elysia behind it.
Marcom is an internal dashboard for managing marketing communications materi (material): each one carries a brand, a cluster, a feature, a type, a start date and an end date. The problem it solves is simple but miserable by hand — material that is about to expire has to surface before it expires, not after.
This repo is the front end (Next.js). The back end lives in marcom_services — Bun + Elysia + MySQL — and both are brought up together by an orchestrator repo using git submodules and Docker Compose.
Four axes, one table
Everything filterable in the dashboard comes off the same four master-data dimensions:
// constants/filter-options.ts
export type FilterKey = "brand" | "cluster" | "fitur" | "status" | "jenis";
That shows up in the code as master-data.store.ts holding the four lists and filter-materi.store.ts holding the current selection. The material table never filters anything itself — it reads whatever useFilteredMateri returns. When a new dimension was requested, only those two places changed.
Date ranges get their own presets, because 90% of the usage only ever needs four of them:
export enum PresetDate {
ALL_TIME = "All time",
THIS_MONTH = "Bulan ini",
LAST_MONTH = "Bulan lalu",
THIS_YEAR = "Tahun ini",
CUSTOM = "Pilih tanggal tertentu",
}
Expiry reminders
This is the part that got rethought the most. The backend has an expiryService that pulls material approaching its end date, and the condition is written straight into SQL:
WHERE COALESCE(m.notification_count, 0) < 1
AND m.end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
notification_count is what stops people being mailed the same reminder every day until the material actually expires. The cron isn’t a separate process either — it’s an HTTP endpoint in cronController guarded by cronAuth, so scheduling can be handed to whatever scheduler already exists and the service stays one binary.
Realtime, but only where it earns it
Socket.IO runs on its own port (SOCKET_IO, default 5002), separate from the HTTP API (PORT, default 5001). The front end has exactly one useSocket, used so an open material list updates when someone else saves a change. Everything else stays ordinary requests through TanStack Query — not everything needs to be a stream.
Front-end layout
| Area | Contents |
|---|---|
app/dashboard | material table, stats, chart, filters (material + date), material form [id] |
app/master-data | CRUD for brand / cluster / feature / type |
app/users | user management |
stores/ | Zustand: auth, materi, filter, master data, activity log |
hooks/ | useFilteredMateri, useStatsData, useDateRange, useDebounce, useAutoLogout, useSocket |
useAutoLogout exists because this dashboard tends to be left open on a shared screen. An idle session drops itself rather than waiting for the token to expire.
What it taught me
Splitting front end and back end into two repos and rejoining them with submodules gives each its own release cycle, but the price is that every newcomer has to remember --recurse-submodules. Forget it and the folder is empty and Compose fails with a message that explains nothing. It is still the one step written in bold in the README.