Files
perauset-e2e-mrg0et1k/ai/memory-bank/ux-architecture.md
2026-07-11 08:54:43 +02:00

1118 lines
36 KiB
Markdown

# 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.
```typescript
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:**
```typescript
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:
```typescript
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):**
```typescript
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:**
```typescript
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.
```typescript
// 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.
```typescript
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:**
```typescript
<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.
```typescript
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:**
```typescript
// 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
```typescript
// 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:**
```typescript
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:**
```typescript
// 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:
```typescript
// 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:
```typescript
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.
```typescript
// 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:
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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
```typescript
// 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:
```typescript
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`.
```typescript
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:
```typescript
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:
```typescript
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`.
```typescript
// 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:
```typescript
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,
},
})
```