I've been building Melororium ( https://melororium.com ) — a flat-fee workspace for agencies — for about eight months. The Team section turned out to be one of the densest features in the product. Five tabs, eight distinct data domains, two add-on modules, and one of the trickier design problems I've hit: anonymous feedback that's actually useful.
Here's the full breakdown of what I shipped.
Architecture: five tabs, one shared data layer
The Team page (route: /employees) has five tabs:
Team — the member table and sub-team management
Requests — pending join requests and sent invitations
Time Off — leave management
Onboarding — role-specific checklists (module-gated)
Sentiment — weekly mood tracking (module-gated)
All tabs read from a shared CRM store (Zustand) that loads employee data, project data, and time logs on mount. This means switching tabs doesn't trigger new fetches — the productivity metrics on a member profile come from client-side aggregation of the already-loaded store.
// Productivity metrics derived in the client — no extra API call
const myTimeLogs = storeTimeLogs.filter(log => log.user === memberFullName);
const totalTimeSeconds = myTimeLogs.reduce((s, log) =>
s + Math.round(log.durationHours * 3600), 0
);
const totalMoney = myTimeLogs.reduce((s, log) => s + (log.cost || 0), 0);
const completionRate = myTasks.length > 0
? Math.round((closedTasks.length / myTasks.length) * 100) : 0;
The header widgets: real-time team state
Four stat cards at the top answer "what's happening with the team right now":
// Active today: signed in within 24 hours
const active = members.filter(m =>
m.lastSeenRaw &&
(Date.now() - new Date(m.lastSeenRaw).getTime() < 24 * 3_600_000)
).length;
// On leave right now: approved leave covering today
function activeLeaveCount(reqs) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const t = today.getTime();
return reqs.filter(r => {
if (r.status !== "approved") return false;
const s = parseDayMs(r.startDate), e = parseDayMs(r.endDate);
return s !== null && e !== null && t >= s && t <= e;
}).length;
}
The "on leave" count was deceptively tricky. Date parsing from stored strings needs to handle both ISO format (2026-07-20) and localized strings (Jul 20, 2026), and needs to treat the end date as inclusive. I ended up with a parseDayMs function that detects ISO format and parses it as local date (avoiding UTC offset drift) before falling back to new Date(str).
Sub-teams: tabs, leads, icons, drag-to-reorder
Sub-teams are stored in a teams table with members as a relation. The UI renders them as tabs above the member table. Clicking a tab filters the list to that team's members.
Key behaviors:
Team lead is set per team and gets hoisted to the top of the filtered list via the sort function
Members move between teams via moveMember(userId, teamId) — or moveMember(userId, null) to remove from all teams
Teams reorder via drag-and-drop: the dragged team's tab gets a hover target on other tabs, and reorderTeams(newOrder) persists the result
16 icon options render from a TEAM_ICONS array with Lucide components
const handleTeamDrop = (targetId: string) => {
if (!dragTeam || dragTeam === targetId) { setDragTeam(null); return; }
const order = teams.map(t => t.id);
const from = order.indexOf(dragTeam);
const to = order.indexOf(targetId);
order.splice(to, 0, order.splice(from, 1)[0]);
setTeams(order.map(id => teams.find(t => t.id === id)!));
setDragTeam(null);
reorderTeams(order).catch(() => {});
};
The member table: sorting, bulk actions, inline editing
The table has these columns: name, position, department, local time now, role, hourly rate, leave status, active projects, last seen, joined.
Sorting cycles through neutral > asc > desc > neutral:
const toggleSort = (key: string) => {
if (sortKey !== key) { setSortKey(key); setSortDir("asc"); }
else if (sortDir === "asc") setSortDir("desc");
else setSortKey(null);
};
The localTime sort key sorts by timezone string — alphabetical by TZ identifier, which isn't perfect but keeps UTC-offset-adjacent timezones grouped.
Bulk select state lives in a Set of IDs. Three bulk actions: export selected members as CSV, open mailto with all their emails, move to team. The CSV export runs client-side from the in-memory member data.
Inline rate editing uses a controlled that appears on click with a ref-driven focus:
const saveRate = async (m: Member, raw: string) => {
setEditingRate(null);
const val = Math.max(0, Math.round(Number(raw)));
if (!Number.isFinite(val) || val === m.hourlyRate) return;
updateEmployee(m.id, { hourlyRate: val, rateUpdatedAt: new Date().toISOString() }); // optimistic
await dbUpdateEmployee(m.id, { hourlyRate: val }).catch(() => {
toast.error("Couldn't save rate");
});
};
The sentiment module: anonymity vs. usefulness
The anonymity tradeoff is where I spent the most design time.
The problem: if sentiment scores are fully anonymous, I can show trend data but the admin can't act on specific problems. If they're attributable, members self-censor and the data is useless.
My solution: scores and comments are stored with a userId but the admin UI never exposes which user submitted which comment. The AI report generator also operates on aggregated data only. The only thing the admin sees per-user is whether they responded (count: "7 of 8 responded"), not what they said.
The survey rotation system lets you sequence templates across weeks so members don't see the same questions back-to-back:
// scheduled auto-send: runs when the sentiment tab loads, once per session
checkScheduleAndSend().then(res => {
if (res.sent) {
toast.success("Survey auto-sent per schedule");
setSentimentLoaded(false); // reload dashboard
}
});
The schedule check runs server-side and compares the configured day/time/timezone against the last sent timestamp. If the current time is past the scheduled window and no survey went out this week, it sends automatically.
The invite flow: everything upfront
The invite modal lets you:
Add multiple emails at once (tab/comma/semicolon separates them)
Set role (Admin or Member)
Assign to a team
Assign to projects
Attach an onboarding template
Add a welcome note
The onboarding template assignment happens in the same server action as the invite creation, so the new member's checklist is ready before they click the link. The alternative — assigning onboarding after they join — creates a window where someone's in the workspace but nobody has thought through what they're supposed to do first.
What I'd do differently
The per-member productivity tab does client-side aggregation from the full time log store. This works fine at small scale (the store caps out around 500 log entries before paginating) but it's not the right architecture if you're expecting hundreds of members or thousands of time logs. A dedicated GET /api/employees/:id/metrics?period=all endpoint would serve the aggregated numbers directly and make the productivity tab faster on load.
The sentiment scheduling also deserves its own service. Right now the auto-send check runs in the browser when someone navigates to the sentiment tab. If nobody opens the page on Friday, the survey doesn't go out. A proper cron job or a serverless scheduled function is the right fix — it's on the roadmap.
Shipping the feature with the browser-triggered approach let me validate the scheduling concept without infrastructure overhead. Now that the concept is validated, the infrastructure upgrade is worth doing.
Top comments (0)