Files
perauset-e2e-mrfzot2p/ai/memory-bank/ux-architecture.md
2026-07-11 08:34:30 +02:00

36 KiB

Perauset Frontend UX Architecture

Next.js (App Router) + Mantine v8. Backend: Pikku RPC over HTTP (port 6002).


1. Layout System

Shell Structure

AppShell (Mantine AppShell)
+--------------------------------------------------+
| Header                                            |
| [Logo/Name]  [Search?]  [Bell] [ThemeToggle] [Avatar] |
+--------+-----------------------------------------+
| Sidebar| Main Content Area                        |
| (nav)  |                                          |
|        |                                          |
|        |                                          |
|        |                                          |
|        |                                          |
+--------+-----------------------------------------+

Use Mantine's AppShell with AppShell.Header, AppShell.Navbar, AppShell.Main.

Header (60px): Logo left, notification bell + user avatar right. Bell shows unread count badge.

Sidebar (260px desktop, collapsed on mobile): Icon + label nav items. Collapsible groups per domain. Active item highlighted. On mobile, the sidebar becomes a hamburger-triggered drawer.

Component Hierarchy

app/layout.tsx
  AuthProvider (session context)
    MantineProvider (theme)
      AppShell
        AppShell.Header
          Logo
          HeaderActions (NotificationBell, ThemeToggle, UserMenu)
        AppShell.Navbar
          SidebarNav (role-filtered)
        AppShell.Main
          children (page content)

Sidebar Navigation — Role-Based Visibility

Each nav item declares the permission it requires. The sidebar component filters items against the session's resolved permissions.

type NavItem = {
  label: string
  icon: TablerIcon
  href: string
  permission?: string        // Required permission, omit = visible to all authenticated
  children?: NavItem[]       // Nested items (collapsible group)
  badge?: () => ReactNode    // Dynamic badge (e.g., pending count)
}

const NAV_ITEMS: NavItem[] = [
  {
    label: 'Dashboard',
    icon: IconDashboard,
    href: '/dashboard',
    // No permission — visible to everyone
  },
  {
    label: 'My Requests',
    icon: IconSend,
    href: '/my-requests',
    // Visible to all authenticated users
  },
  {
    label: 'Stays',
    icon: IconBed,
    href: '/stays',
    permission: 'stays.view_all',
    children: [
      { label: 'Requests', href: '/stays/requests', permission: 'stays.review' },
      { label: 'Active Stays', href: '/stays/active', permission: 'stays.view_all' },
      { label: 'Check-in/out', href: '/stays/checkin', permission: 'stays.manage' },
    ],
  },
  {
    label: 'Rooms',
    icon: IconDoor,
    href: '/rooms',
    permission: 'rooms.view_all',
    children: [
      { label: 'Occupancy', href: '/rooms/occupancy', permission: 'rooms.view_all' },
      { label: 'Manage', href: '/rooms/manage', permission: 'rooms.manage' },
    ],
  },
  {
    label: 'Boats',
    icon: IconSailboat,
    href: '/boats',
    permission: 'boats.view_all',
    children: [
      { label: 'Schedule', href: '/boats/schedule', permission: 'boats.view_all' },
      { label: 'Requests', href: '/boats/requests', permission: 'boats.manage' },
    ],
  },
  {
    label: 'Tasks',
    icon: IconChecklist,
    href: '/tasks',
    permission: 'tasks.view_all',
    children: [
      { label: 'Board', href: '/tasks/board', permission: 'tasks.view_all' },
      { label: 'Templates', href: '/tasks/templates', permission: 'tasks.manage' },
    ],
  },
  {
    label: 'Retreats',
    icon: IconMountain,
    href: '/retreats',
    permission: 'retreats.view',
  },
  {
    label: 'Kitchen',
    icon: IconToolsKitchen2,
    href: '/kitchen',
    permission: 'kitchen.view',
    children: [
      { label: 'Meal Services', href: '/kitchen/meals', permission: 'kitchen.view' },
      { label: 'Dietary Profiles', href: '/kitchen/dietary', permission: 'kitchen.manage' },
      { label: 'My Dietary Info', href: '/kitchen/my-dietary', permission: 'kitchen.update_dietary' },
    ],
  },
  {
    label: 'Inventory',
    icon: IconPackage,
    href: '/inventory',
    permission: 'inventory.request',
    children: [
      { label: 'Items', href: '/inventory/items', permission: 'inventory.manage' },
      { label: 'Requests', href: '/inventory/requests', permission: 'inventory.manage' },
    ],
  },
  {
    label: 'People',
    icon: IconUsers,
    href: '/people',
    permission: 'users.view',
    children: [
      { label: 'Users', href: '/people/users', permission: 'users.view' },
      { label: 'Roles', href: '/people/roles', permission: 'roles.assign' },
    ],
  },
  {
    label: 'Audit Log',
    icon: IconHistory,
    href: '/audit',
    permission: 'audit_log.view',
  },
]

Filtering logic:

function filterNavItems(items: NavItem[], userPermissions: Set<string>): NavItem[] {
  return items
    .filter(item => !item.permission || userPermissions.has(item.permission) || userPermissions.has('*'))
    .map(item => ({
      ...item,
      children: item.children ? filterNavItems(item.children, userPermissions) : undefined,
    }))
    .filter(item => !item.children || item.children.length > 0)
}

Mobile Navigation

On viewports below 768px:

  • Sidebar collapses entirely. A hamburger icon in the header toggles a Mantine Drawer from the left.
  • The drawer contains the same nav items, rendered as a flat accordion.
  • Header shrinks: logo becomes icon-only, actions collapse into a single menu.
  • Bottom tab bar for the 4-5 most-used items: Dashboard, My Requests, Tasks (if permitted), Boats (if permitted), More (opens drawer).
Mobile layout:
+--------------------------------------------+
| [=] Logo        [Bell] [Avatar]            |
+--------------------------------------------+
| Main Content (full width, scrollable)      |
|                                            |
+--------------------------------------------+
| [Dashboard] [Requests] [Tasks] [More]      |  <-- BottomNav
+--------------------------------------------+

2. Component Patterns

2.1 Request/Approval Cards

The platform has several request-then-review flows: stay requests, boat seat requests, inventory requests. All follow the same structural pattern.

RequestCard component:

+-------------------------------------------------------+
| [StatusBadge]                           [TimeAgo]      |
|                                                        |
| Requester Name                                         |
| Request type or summary line                           |
|                                                        |
| Key details (dates, notes, etc.) in a compact grid     |
|   Start: Mar 20   End: Mar 27   Type: Volunteer        |
|                                                        |
| --- coordinator-only section (if has review perm) ---  |
| [Approve]  [Reject]  [Review Notes input]              |
+-------------------------------------------------------+

Props interface:

type RequestCardProps = {
  id: string
  status: string
  entityType: 'stay_request' | 'boat_reservation_request' | 'inventory_request'
  requester: { name: string; avatarUrl?: string }
  title: string
  subtitle?: string
  details: { label: string; value: string }[]
  notes?: string
  createdAt: Date
  reviewActions?: {
    onApprove: () => void
    onReject: () => void
    onReviewNotes?: (notes: string) => void
  }
}

Use Mantine Card with Card.Section dividers. The review actions section renders only when reviewActions is provided (which the page component controls based on permission).

Request lifecycle view (community user):

My Requests page (list of own requests across all types)
  - Filterable by type (stay, boat, inventory)
  - Filterable by status
  - Each card shows status + basic info
  - Clicking opens detail drawer/modal

Request review view (coordinator):

Stays > Requests page (all pending stay requests)
  - Default filter: status = submitted | under_review
  - Cards show review action buttons inline
  - Approve opens a confirmation dialog
  - Reject opens a dialog with required rejection reason
  - Optimistic update: card moves to approved/rejected state immediately

2.2 Status Badges with Transitions

Statuses are central to this platform. Every entity has a state machine. Badges must be visually distinct per status family.

Color mapping (Mantine color names):

const STATUS_COLORS: Record<string, string> = {
  // Pending / needs attention
  submitted: 'yellow',
  under_review: 'orange',
  planned: 'gray',
  draft: 'gray',
  waitlisted: 'orange',

  // Active / in progress
  open: 'blue',
  active: 'blue',
  confirmed: 'teal',
  assigned: 'indigo',
  in_progress: 'violet',
  checked_in: 'teal',
  published: 'blue',
  approved: 'teal',

  // Completed / success
  completed: 'green',
  checked_out: 'green',
  fulfilled: 'green',

  // Negative / terminal
  cancelled: 'red',
  rejected: 'red',
  declined: 'red',
  blocked: 'red',
}

StatusBadge component:

function StatusBadge({ status, entityType }: { status: string; entityType?: string }) {
  const color = STATUS_COLORS[status] ?? 'gray'
  const label = status.replace(/_/g, ' ')
  return <Badge color={color} variant="light" size="sm" tt="capitalize">{label}</Badge>
}

Transition indicators: When a coordinator takes an action (approve, reject, check-in), briefly show the old status with a strikethrough fading into the new status. Accomplish this with Mantine's Transition component and a simple CSS animation, not a library.

// StatusTransition: shows old -> new with animation
function StatusTransition({ from, to }: { from: string; to: string }) {
  return (
    <Group gap="xs">
      <StatusBadge status={from} />
      <IconArrowRight size={14} />
      <StatusBadge status={to} />
    </Group>
  )
}

2.3 Operational Boards

Task Kanban Board

For coordinators/staff with tasks.view_all. Uses columns based on task status.

+----------+----------+----------+----------+----------+
|  Open    | Assigned |In Progress| Blocked | Completed|
+----------+----------+----------+----------+----------+
| [Card]   | [Card]   | [Card]    | [Card]  | [Card]   |
| [Card]   | [Card]   |           |         |          |
| [Card]   |          |           |         |          |
+----------+----------+----------+----------+----------+

Implementation approach:

  • Use CSS Grid with horizontal scroll on mobile (snap scrolling).
  • Each column is a vertical stack of TaskCard components.
  • Drag-and-drop is a nice-to-have, NOT a requirement for v1. Use explicit action buttons on each card instead (Assign, Start, Complete, Block).
  • Filter bar above: category (housekeeping, kitchen, maintenance...), assigned user, date range.
  • On mobile: switch to a list view grouped by status, with collapsible status sections.
type TaskCardProps = {
  taskId: string
  title: string
  category: TaskCategory
  status: TaskStatus
  assignee?: { name: string }
  scheduledAt?: Date
  location?: string
  actions: TaskAction[]  // Derived from state machine transitions
}

Boat Trip Timeline

For boat coordinators. A day-view timeline showing trips along a time axis.

Today: March 17, 2026           [< Prev Day] [Next Day >]
+-----+------+------+------+------+------+------+
| 6am | 8am  | 10am | 12pm | 2pm  | 4pm  | 6pm  |
+-----+------+------+------+------+------+------+
| Route A: Island -> Mainland                      |
|    [08:00 =========> 09:30]  MV Seabird (12/15) |
|    [14:00 =========> 15:30]  MV Seabird (8/15)  |
+--------------------------------------------------+
| Route B: Mainland -> Island                      |
|    [10:00 =========> 11:30]  MV Coral (10/20)   |
+--------------------------------------------------+

Implementation:

  • Custom component using CSS Grid where columns represent time slots.
  • Each trip is a positioned bar spanning its departure-to-arrival time.
  • Bar color indicates status (planned=gray, open=blue, full=orange, completed=green, cancelled=red with strikethrough).
  • Clicking a trip bar opens a detail panel/drawer showing manifest, seat requests, actions.
  • On mobile: switch to a simple chronological list sorted by departure time.

2.4 Date Range Pickers for Stays/Rooms

Use Mantine DatePickerInput with type="range" for selecting stay date ranges.

Stay request form:

<DatePickerInput
  type="range"
  label="Stay dates"
  placeholder="Select arrival and departure"
  minDate={new Date()}          // Cannot request past dates
  maxDate={addMonths(new Date(), 6)}  // Max 6 months ahead
  value={dateRange}
  onChange={setDateRange}
/>

Room allocation form (coordinator):

Same date range picker, but also displays a mini occupancy preview below showing room availability for the selected range. This is a custom component that queries listRoomAllocations for the date range and overlays occupied/available indicators.

2.5 Occupancy Grid for Rooms

A calendar-style grid showing room occupancy over time. This is the core scheduling view for coordinators.

                  Mar 17  Mar 18  Mar 19  Mar 20  Mar 21  Mar 22
+------------+  +-------+-------+-------+-------+-------+-------+
| Room A (2) |  |[=====John Doe=====]   |       |[===Jane===]   |
| Room B (4) |  |[==============Retreat Group==============]    |
| Room C (1) |  | MAINT |       |       |[===Bob Smith===]      |
| Room D (2) |  |       |       |[====Alice====]|       |       |
+------------+  +-------+-------+-------+-------+-------+-------+

Implementation approach:

  • Rows = rooms (sorted by type, then name). Columns = days.
  • Each cell can be: empty, occupied (colored bar spanning multiple days), blocked (hatched pattern for maintenance/admin holds).
  • Occupied bars show guest name, colored by stay type (volunteer=blue, guest=green, staff=purple, facilitator=teal).
  • Clicking an occupied bar opens the stay detail.
  • Clicking an empty cell opens the room allocation form pre-filled with that room and date.
  • Horizontal scroll with sticky room name column on the left.
  • Time range selector above: week view (default), 2-week, month.
  • Room type filter: show all, private only, dorm only, etc.
type OccupancyGridProps = {
  rooms: Room[]
  allocations: RoomAllocation[]
  blocks: RoomBlock[]
  dateRange: [Date, Date]
  onCellClick: (roomId: string, date: Date) => void
  onAllocationClick: (allocationId: string) => void
}

On mobile: the grid is too wide. Switch to a single-room view (select room from dropdown, see its calendar). Or a day-view showing all rooms for one day as a vertical list.

2.6 List/Detail Pattern for CRUD Entities

Every domain entity (users, rooms, boats, retreats, inventory items, task templates) follows the same list/detail pattern.

List page structure:

+-----------------------------------------------------+
| Page Title                          [+ Create] btn   |
+-----------------------------------------------------+
| Filters bar:                                         |
| [Status v] [Type v] [Search________] [Date range]   |
+-----------------------------------------------------+
| Table or Card list                                   |
| +--------------------------------------------------+|
| | Name/Title | Status | Key Field | Actions        ||
| +--------------------------------------------------+|
| | Row 1      | Active | ...       | [View] [Edit]  ||
| | Row 2      | Draft  | ...       | [View] [Edit]  ||
| +--------------------------------------------------+|
| Pagination: [< 1 2 3 ... 10 >]                      |
+-----------------------------------------------------+
  • On desktop: use Mantine Table with sortable columns.
  • On mobile: switch to card-based layout (each row becomes a card).
  • The [View] action navigates to a detail page or opens a detail drawer (depending on entity complexity).

Detail page structure:

+-----------------------------------------------------+
| [< Back to list]                                     |
| Entity Title                          [StatusBadge]  |
| Created by X on Date                                 |
+-----------------------------------------------------+
| Tab bar: [Overview] [History] [Related]              |
+-----------------------------------------------------+
| Tab content (scrollable)                             |
|                                                      |
| Overview: Key-value detail grid                      |
| History: Timeline of status changes (from audit log) |
| Related: Linked entities (e.g., stay -> room alloc)  |
+-----------------------------------------------------+
| Action bar (sticky bottom on mobile):                |
| [Primary Action] [Secondary Action] [Cancel/Delete]  |
+-----------------------------------------------------+

Reusable components for this pattern:

// Generic list page wrapper
type EntityListPageProps<T> = {
  title: string
  createHref?: string
  createPermission?: string
  filters: FilterConfig[]
  columns: ColumnConfig<T>[]     // For table view
  cardRenderer?: (item: T) => ReactNode  // For card view
  fetchFn: (params: FilterParams) => Promise<{ items: T[]; total: number }>
}

// Generic detail page wrapper
type EntityDetailPageProps = {
  title: string
  backHref: string
  status?: string
  entityType?: string
  tabs: TabConfig[]
  actions?: ActionConfig[]
}

3. State Management

Auth Session

// contexts/auth-context.tsx
type AuthState = {
  user: {
    userId: string
    email: string
    displayName: string
    roles: string[]
  } | null
  permissions: Set<string>   // Resolved from roles via ROLE_PERMISSIONS
  isLoading: boolean
}

const AuthContext = createContext<AuthState>(...)

Session is loaded on app mount via a getSession Pikku call. Stored in React context (not a global store — Mantine and Next.js App Router work well with context).

Permission checking hook:

function usePermission(permission: string): boolean {
  const { permissions } = useAuth()
  return permissions.has(permission) || permissions.has('*')
}

function useAnyPermission(...perms: string[]): boolean {
  const { permissions } = useAuth()
  return permissions.has('*') || perms.some(p => permissions.has(p))
}

Role-based rendering:

// Wrapper component for permission gating
function RequirePermission({ permission, children, fallback }: {
  permission: string
  children: ReactNode
  fallback?: ReactNode
}) {
  const allowed = usePermission(permission)
  if (!allowed) return fallback ?? null
  return <>{children}</>
}

Usage in pages:

// In a page component
<RequirePermission permission="stays.review">
  <ReviewActionsPanel requests={pendingRequests} />
</RequirePermission>

Server-side: use Pikku session from cookies in server components to pre-check permissions and avoid rendering pages the user cannot access. Redirect at the layout/page level, not just hide UI.

Optimistic Updates

For state transitions (approve, reject, check-in, complete, etc.), use optimistic updates:

function useOptimisticTransition<T extends { id: string; status: string }>(
  items: T[],
  mutationFn: (id: string) => Promise<void>
) {
  const [optimisticItems, setOptimisticItems] = useState(items)

  async function transition(id: string, newStatus: string) {
    // Immediately update local state
    setOptimisticItems(prev =>
      prev.map(item => item.id === id ? { ...item, status: newStatus } : item)
    )
    try {
      await mutationFn(id)
      // On success, refetch or keep optimistic state
    } catch {
      // Rollback on failure
      setOptimisticItems(items)
      // Show error notification
    }
  }

  return { items: optimisticItems, transition }
}

Alternatively, use @tanstack/react-query (TanStack Query) for cache management and optimistic updates. This is the recommended approach since it handles cache invalidation, background refetching, and stale-while-revalidate automatically.

// Recommended: TanStack Query pattern
const queryClient = useQueryClient()

const approveStay = useMutation({
  mutationFn: (requestId: string) => api.approveStayRequest({ requestId }),
  onMutate: async (requestId) => {
    await queryClient.cancelQueries({ queryKey: ['stayRequests'] })
    const previous = queryClient.getQueryData(['stayRequests'])
    queryClient.setQueryData(['stayRequests'], (old) =>
      old.map(r => r.requestId === requestId ? { ...r, status: 'approved' } : r)
    )
    return { previous }
  },
  onError: (_err, _id, context) => {
    queryClient.setQueryData(['stayRequests'], context.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['stayRequests'] })
  },
})

4. Data Fetching Pattern

When to Use Server vs Client

Server-side (RSC / SSR) — use for:

  • Initial page loads: list pages, detail pages, dashboard summaries
  • Any data that does not change in response to user interaction on the same page
  • SEO-irrelevant here (internal app), but SSR still helps with perceived performance
  • Auth session resolution (read cookie, call Pikku getSession server-side)

Client-side (SWR/TanStack Query) — use for:

  • Data that changes after user actions (approve a request, the list updates)
  • Polling / real-time data (notification count, task board)
  • Infinite scroll / pagination interactions
  • Form submissions and their responses
  • Any data dependent on client-side filter/search state

Pikku RPC Integration

Pikku functions are called via HTTP. Create a thin client wrapper:

// lib/api.ts
import { createPikkuClient } from '@pikku/client'  // Or a thin fetch wrapper

const api = createPikkuClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:6002/api',
})

// Server-side variant (includes cookie forwarding)
export function createServerApi(cookies: ReadonlyHeaders) {
  return createPikkuClient({
    baseUrl: process.env.API_URL ?? 'http://localhost:6002/api',
    headers: { cookie: cookies.get('cookie')?.value ?? '' },
  })
}

Server component example:

// app/stays/requests/page.tsx (Server Component)
import { createServerApi } from '@/lib/api'
import { cookies } from 'next/headers'

export default async function StayRequestsPage() {
  const api = createServerApi(await cookies())
  const { items } = await api.listStayRequests({ limit: 50, offset: 0 })
  return <StayRequestsList initialData={items} />
}

Client component with TanStack Query:

// components/stays/StayRequestsList.tsx
'use client'

function StayRequestsList({ initialData }: { initialData: StayRequest[] }) {
  const { data } = useQuery({
    queryKey: ['stayRequests', filters],
    queryFn: () => api.listStayRequests(filters),
    initialData: { items: initialData },
    staleTime: 30_000,  // 30s before refetch
  })

  return /* render list with data.items */
}

Query Key Convention

Consistent query keys for cache management:

['stayRequests', { status, limit, offset }]
['stays', { status, limit, offset }]
['stay', stayId]
['rooms']
['roomAllocations', { startDate, endDate }]
['boatTrips', { date }]
['boatRequests', { tripId }]
['tasks', { status, category }]
['retreats', { status }]
['retreat', retreatId]
['notifications', { limit, offset }]
['notificationCount']     // Unread count, polled frequently
['inventory']
['inventoryRequests', { status }]
['users', { search }]
['mealServices', { date }]

5. Mobile and Offline Considerations

The island has limited and intermittent connectivity. This shapes the architecture significantly.

Connectivity Tiers

  1. Online: Full functionality, real-time updates.
  2. Slow/intermittent: App works, but requests may be slow. Show loading states, avoid blocking the UI.
  3. Offline: Read cached data, queue write actions for sync when back online.

What Should Work Offline / Cached

Must cache (service worker + TanStack Query persistence):

  • Current user session and permissions (avoid auth failures offline)
  • Today's boat schedule (critical for island operations)
  • Active task list for the current user
  • Room occupancy for the current week
  • Own stay details and dates
  • Notification history (already fetched)
  • Dietary profiles (kitchen needs this regardless of connectivity)

Queue for sync when online:

  • Task status updates (claim, start, complete) — most common offline action
  • Meal attendance marking (kitchen staff marking who showed up)
  • Check-in/check-out (coordinator at the dock with no signal)

Implementation

// Offline queue using IndexedDB (via idb-keyval or Dexie)
type QueuedAction = {
  id: string
  endpoint: string
  method: string
  payload: unknown
  createdAt: Date
  retries: number
}

// On mutation when offline:
if (!navigator.onLine) {
  await offlineQueue.add({
    id: crypto.randomUUID(),
    endpoint: '/stays/check-in',
    method: 'POST',
    payload: { stayId },
    createdAt: new Date(),
    retries: 0,
  })
  // Update local cache optimistically
  // Show "queued" indicator on the item
  return
}

Sync indicator in the header:

[Online]         — green dot, hidden after 3s
[Syncing (3)]    — orange dot + count of queued actions
[Offline]        — red dot, persistent

TanStack Query Persistence

Use @tanstack/query-persist-client with an IndexedDB persister to survive page reloads and brief offline periods:

const persister = createIDBPersister('perauset-cache')

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24h garbage collection
      staleTime: 1000 * 30,          // 30s stale time
      retry: 3,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
    },
  },
})

Mobile-Specific Adaptations

Desktop pattern Mobile adaptation
Sidebar nav Bottom tab bar + hamburger drawer
Occupancy grid Single-room calendar or day-view list
Task kanban board Status-grouped list with collapsible sections
Boat timeline Chronological trip list
Table views Card-based layouts
Detail in page Full-screen detail with back gesture
Multi-column forms Single-column stacked form
Hover tooltips Long-press or inline text

6. Notification UX

Bell Icon with Unread Count

Header:
  ... [Bell(3)] [Avatar] ...

The bell icon lives in the header, always visible. The unread count badge uses Mantine Indicator.

function NotificationBell() {
  const { data: count } = useQuery({
    queryKey: ['notificationCount'],
    queryFn: () => api.listNotifications({ limit: 1, offset: 0 })
      .then(res => res.items.filter(n => !n.readAt).length),
    refetchInterval: 30_000,  // Poll every 30s when online
    refetchIntervalInBackground: false,
  })

  // Better: dedicated unread-count endpoint to avoid fetching full list
  // For now, derive from list query cached data

  return (
    <Indicator
      label={count && count > 0 ? count : undefined}
      size={16}
      disabled={!count || count === 0}
      color="red"
    >
      <ActionIcon variant="subtle" onClick={toggle}>
        <IconBell size={20} />
      </ActionIcon>
    </Indicator>
  )
}

Notification Panel

Clicking the bell opens a dropdown panel (Mantine Popover on desktop, full-screen drawer on mobile).

+------------------------------------------+
| Notifications              [Mark all read]|
+------------------------------------------+
| * Stay request approved                   |  <- unread (bold, blue dot)
|   Your stay Mar 20-27 was approved        |
|   2 hours ago                             |
+------------------------------------------+
|   Boat trip tomorrow                      |  <- read (normal weight)
|   MV Seabird departs 08:00               |
|   1 day ago                               |
+------------------------------------------+
|   Task assigned to you                    |
|   Kitchen cleanup - Building A            |
|   2 days ago                              |
+------------------------------------------+
| [View all notifications]                  |
+------------------------------------------+

Behavior:

  • Clicking a notification marks it as read (call markNotificationRead) and navigates to the related entity if entityType and entityId are present.
  • Navigation mapping from notification:
function getNotificationHref(entityType: string | null, entityId: string | null): string | null {
  if (!entityType || !entityId) return null
  const routes: Record<string, string> = {
    stay_request: `/stays/requests/${entityId}`,
    stay: `/stays/${entityId}`,
    boat_trip: `/boats/schedule/${entityId}`,
    boat_reservation_request: `/boats/requests/${entityId}`,
    task_instance: `/tasks/${entityId}`,
    retreat: `/retreats/${entityId}`,
    inventory_request: `/inventory/requests/${entityId}`,
    room_allocation: `/rooms/occupancy`,
  }
  return routes[entityType] ?? null
}
  • "Mark all read" calls markNotificationRead for each unread notification (batch endpoint would be a good backend addition).
  • The panel shows the 10 most recent. "View all" navigates to /notifications which is a full paginated list.

Notification Types and Display

Map notification type field to icons and colors:

const NOTIFICATION_CONFIG: Record<string, { icon: TablerIcon; color: string }> = {
  stay_request_submitted: { icon: IconBed, color: 'blue' },
  stay_request_approved: { icon: IconCheck, color: 'green' },
  stay_request_rejected: { icon: IconX, color: 'red' },
  stay_checked_in: { icon: IconLogin, color: 'teal' },
  boat_trip_reminder: { icon: IconSailboat, color: 'blue' },
  boat_request_confirmed: { icon: IconCheck, color: 'green' },
  boat_request_declined: { icon: IconX, color: 'red' },
  task_assigned: { icon: IconChecklist, color: 'violet' },
  task_completed: { icon: IconCheck, color: 'green' },
  retreat_published: { icon: IconMountain, color: 'blue' },
  inventory_request_approved: { icon: IconPackage, color: 'green' },
  inventory_request_fulfilled: { icon: IconCheck, color: 'green' },
  generic: { icon: IconBell, color: 'gray' },
}

7. Page Route Map

/                          -> Redirect to /dashboard
/login                     -> Login page (public)

/dashboard                 -> Role-adaptive dashboard
/my-requests               -> User's own requests (stays, boats, inventory)
/notifications             -> Full notification list

/stays
  /stays/requests          -> Stay request list (coordinator: review queue)
  /stays/requests/new      -> New stay request form
  /stays/requests/[id]     -> Stay request detail
  /stays/active            -> Active stays list
  /stays/[id]              -> Stay detail (check-in/out actions)
  /stays/checkin           -> Quick check-in/out interface

/rooms
  /rooms/occupancy         -> Occupancy grid (calendar view)
  /rooms/manage            -> Room list (CRUD)
  /rooms/[id]              -> Room detail

/boats
  /boats/schedule          -> Boat trip timeline (day view)
  /boats/schedule/[id]     -> Trip detail (manifest, requests)
  /boats/requests          -> Seat request review queue
  /boats/request/new       -> Request a seat form
  /boats/manage            -> Boats + routes CRUD

/tasks
  /tasks/board             -> Kanban board
  /tasks/[id]              -> Task detail
  /tasks/templates         -> Task template management
  /tasks/claim             -> Claimable tasks (community view)

/retreats
  /retreats                -> Retreat list
  /retreats/new            -> Create retreat
  /retreats/[id]           -> Retreat detail (people, schedule tabs)

/kitchen
  /kitchen/meals           -> Meal service calendar
  /kitchen/meals/[id]      -> Meal detail (attendance, dietary summary)
  /kitchen/dietary         -> All dietary profiles (kitchen lead view)
  /kitchen/my-dietary      -> Own dietary profile (self-service)

/inventory
  /inventory/items         -> Item list with quantities
  /inventory/requests      -> Request review queue
  /inventory/request/new   -> Submit inventory request

/people
  /people/users            -> User directory
  /people/users/[id]       -> User detail + role management
  /people/roles            -> Role assignment interface

/audit                     -> Audit log viewer

8. Dashboard Design

The dashboard is role-adaptive. Each widget checks permissions before rendering.

Coordinator/Admin dashboard:

+---------------------------+---------------------------+
| Today's Overview          | Pending Actions           |
| - 12 active stays         | - 3 stay requests         |
| - 8 rooms occupied (60%)  | - 5 boat seat requests    |
| - 2 boat trips scheduled  | - 2 inventory requests    |
| - 4 tasks in progress     |                           |
+---------------------------+---------------------------+
| Today's Boat Schedule     | Task Board Summary        |
| 08:00 -> Mainland (12/15) | Open: 3  Assigned: 5      |
| 14:00 <- Mainland (8/20)  | In Progress: 4  Blocked: 1|
+---------------------------+---------------------------+
| This Week's Arrivals/Departures                       |
| Mar 17: 2 arrivals, 1 departure                      |
| Mar 18: 0 arrivals, 3 departures                     |
| ...                                                   |
+-------------------------------------------------------+

Community member dashboard:

+---------------------------+---------------------------+
| My Stay                   | Upcoming Boats            |
| Mar 20-27 (Volunteer)     | Mar 20 08:00 -> Island    |
| Status: Confirmed         | Seat: Confirmed           |
| Room: Building A, Room 3  |                           |
+---------------------------+---------------------------+
| My Tasks                  | Notifications             |
| Kitchen cleanup (today)   | Stay approved (2h ago)    |
| Garden maintenance (tmrw) | Task assigned (1d ago)    |
+---------------------------+---------------------------+
| Upcoming Retreats                                     |
| Spring Meditation - Mar 25-30 (Published)             |
+-------------------------------------------------------+

9. Mantine v8 Component Mapping

Key Mantine components used per pattern:

Pattern Mantine Components
Shell layout AppShell, AppShell.Header, AppShell.Navbar, AppShell.Main, Burger
Navigation NavLink, ScrollArea, Drawer (mobile)
Request cards Card, Card.Section, Badge, Group, Stack, Text, Button
Status badges Badge with variant="light"
Data tables Table, Table.Thead, Table.Tr, etc. (native Mantine, or mantine-datatable)
Filters Select, MultiSelect, TextInput, DatePickerInput
Forms TextInput, Textarea, Select, NumberInput, DatePickerInput, Checkbox
Task board SimpleGrid, Card, ScrollArea
Detail pages Tabs, Stack, Group, Paper, Timeline
Modals/drawers Modal, Drawer
Notifications Popover, Indicator, ActionIcon, ScrollArea
Occupancy grid Custom CSS Grid + Tooltip, Paper
Theme toggle SegmentedControl or ActionIcon with system/light/dark
Loading Skeleton, LoadingOverlay, Progress
Empty states Center, Stack, Text, ThemeIcon

10. Theme System

Use Mantine's built-in theme system with useComputedColorScheme and useMantineColorScheme.

// components/ThemeToggle.tsx
function ThemeToggle() {
  const { setColorScheme, colorScheme } = useMantineColorScheme()

  return (
    <SegmentedControl
      size="xs"
      value={colorScheme}
      onChange={(value) => setColorScheme(value as 'light' | 'dark' | 'auto')}
      data={[
        { label: 'Light', value: 'light' },
        { label: 'Dark', value: 'dark' },
        { label: 'Auto', value: 'auto' },
      ]}
    />
  )
}

Mantine v8 handles light/dark natively via CSS variables. No custom CSS variable system needed beyond Mantine's theme configuration in MantineProvider.

Custom theme tokens go in the Mantine createTheme call:

const theme = createTheme({
  primaryColor: 'teal',
  fontFamily: 'Inter, system-ui, sans-serif',
  headings: { fontFamily: 'Inter, system-ui, sans-serif' },
  defaultRadius: 'md',
  other: {
    // App-specific tokens
    statusColors: STATUS_COLORS,
  },
})