1118 lines
36 KiB
Markdown
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,
|
|
},
|
|
})
|
|
```
|