chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:54:43 +02:00
commit 49d05dfa0b
293 changed files with 28421 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
DB_HOST=0.0.0.0
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=perauset
BETTER_AUTH_SECRET=change-me
PORT=6002

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage
coverage
*.lcov
.nyc_output
# Dependency directories
node_modules/
# TypeScript cache
*.tsbuildinfo
# dotenv environment variable files
.env
.env.*
!.env.example
# Build output
dist
.next/
# Bun
.bun/
# Pikku generated files
.pikku/
.deploy/
.pikku-runtime/
# All codegen output — regenerated by the build (pikku all / prebuild).
# Never track *.gen.* (e.g. db/annotations.gen.json, *.gen.ts, *.gen.d.ts).
*.gen.*
# tsconfig build info
apps/app/tsconfig.tsbuildinfo
# Claude
.claude/skills/
.playwright-mcp/

42
CLAUDE.md Normal file
View File

@@ -0,0 +1,42 @@
# Perauset
Village operations platform.
## Project Structure
- `backend/` — Backend server (uWebSockets + Pikku)
- `packages/functions/` — Shared business logic / Pikku functions
- `sql/` — Database migrations (postgres-migrations)
## Running
### Backend
```sh
cd backend && yarn dev
```
Runs `tsx watch` with env files (`../.env` and `.env.local`). Watches for changes and auto-restarts.
### Port
| Service | Port |
|---------|------|
| Backend | 6002 |
## Commands
- `yarn pikku` — Run Pikku CLI codegen (`pikku all`)
- `yarn pikku:watch` — Run Pikku CLI codegen in watch mode
- `yarn prebuild` — Bootstrap + codegen
- `yarn tsc` — Type-check all workspaces
- `cd backend && yarn dbmigrate` — Run database migrations
- `cd packages/functions && yarn db:types` — Regenerate Kysely DB types
## Package Manager
Uses **Yarn 4.9.2** (with workspaces). Node >= 22 required.
## Database
PostgreSQL, database name `perauset`, default schema `app`.

260
PLATFORM-REVIEW.md Normal file
View File

@@ -0,0 +1,260 @@
# PerAuset Platform Review -- Daily Operations Gaps
**Reviewer**: Alex (Product Manager)
**Date**: 2026-03-19
**Scope**: Full platform review from the perspective of a daily coordinator, kitchen lead, boat coordinator, and facilitator
**Method**: Reviewed all 66 RPC functions, 15+ frontend pages, 13 database migrations, and the role/permission system
---
## Critical (Blocks Daily Operations)
### 1. [Dashboard] All stats are hardcoded "--" except notifications
The dashboard shows "Active Stays: --", "Upcoming Boats: --", "Open Tasks: --". There is no backend function to fetch dashboard summary stats. A coordinator opening the app every morning gets zero situational awareness.
**What to build**: A `getDashboardStats` RPC that queries active stays count, upcoming boat trips count, and open task count. Update the dashboard page to call it.
### 2. [Kitchen] Cannot create a meal service from the UI
The "Create Meal" button on the kitchen page is explicitly `disabled`. The backend `createMealService` RPC exists, but the frontend form to call it is missing. A kitchen lead cannot plan any meals.
**What to build**: A meal creation drawer/page that calls `createMealService` with service_type, service_date, planned_headcount, and menu_notes.
### 3. [Kitchen] No way to see who is eating (meal headcount from stays/retreats)
The `meal_attendance` table exists and links to stays and retreats, but there is no RPC to list meal attendance, no RPC to auto-populate attendance from active stays, and the frontend shows zero attendance data. A kitchen lead cannot know how many mouths to feed or what dietary needs exist for a given meal.
**What to build**: (a) `listMealAttendance` RPC filtered by meal_service_id. (b) `generateMealAttendance` RPC that auto-creates attendance records from active stays and retreat participants for a date range. (c) Frontend attendance list on each meal card showing headcount and dietary summary.
### 4. [Kitchen] No dietary summary per meal
Dietary profiles exist per user, but there is no function that aggregates dietary requirements for a specific meal (e.g., "3 vegan, 2 gluten-free, 1 nut allergy"). Without this, the kitchen lead has to manually cross-reference every guest's profile.
**What to build**: `getMealDietarySummary` RPC that joins meal_attendance with dietary_profile and returns counts. Show this on each meal card.
### 5. [Retreats] People tab is always empty -- not wired to backend
The retreat detail page hardcodes `const people: any[] = []` and `const schedule: any[] = []`. Despite `addRetreatPerson` and `addRetreatScheduleItem` RPCs existing, there is no `listRetreatPersons` or `listRetreatScheduleItems` RPC, and the "Add Person" button is `disabled`. A facilitator cannot manage retreat participants or view the schedule.
**What to build**: (a) `listRetreatPersons` and `listRetreatScheduleItems` RPCs. (b) Wire the retreat detail page to call them. (c) Enable the "Add Person" and "Add Schedule Item" forms calling the existing RPCs.
### 6. [Retreats] No `getRetreat` RPC -- detail page re-fetches all retreats
The retreat detail page calls `listRetreats` and filters client-side by ID. This means no additional detail (persons, schedule) is loaded. There is no single-retreat fetch endpoint.
**What to build**: `getRetreat` RPC that returns the retreat with its persons and schedule items in one call.
### 7. [Stays] No "who is here right now" view
There is no way to see currently checked-in guests. The stays list shows all stays but does not filter by "checked_in" status or date overlap with today. A coordinator doing a morning roll call has no tool for this.
**What to build**: Add a "Currently Checked In" filter/tab to the stays page that queries stays with status='checked_in' or where today falls within start_at/end_at. Ideally also surface this count on the dashboard.
### 8. [Rooms] No room availability / availability calendar
You can see rooms and their allocations, but there is no way to check "which rooms are available from date X to date Y?" before making an allocation. This means room assignment is guesswork.
**What to build**: `checkRoomAvailability` RPC that accepts a date range and returns rooms with their open capacity. Frontend calendar or date-picker view on the rooms page.
---
## Important (Significantly Impacts Efficiency)
### 9. [Kitchen] No weekly menu planning
Meals are individual records with no concept of a menu or weekly plan. A kitchen lead has to create each meal one at a time with no template or copy-week functionality.
**What to build**: `generateWeeklyMeals` RPC that bulk-creates meal_service records for breakfast/lunch/dinner across a date range. Frontend weekly calendar view for kitchen.
### 10. [Kitchen] No shopping list generation from menu
There is no `meal_ingredient` or `recipe` table. The menu_notes field is free text. There is no connection between meals and inventory items. A kitchen lead cannot generate a shopping list from the planned menu.
**What to build**: New `meal_ingredient` table linking meal_service to inventory_item with quantity. `generateShoppingList` RPC that diffs meal ingredients against inventory.
### 11. [Boats] No passenger manifest view
The trip detail page shows seat requests, but there is no consolidated manifest showing confirmed passengers with their names, cargo notes, and special requirements. The boat coordinator needs a printable list.
**What to build**: `getBoatManifest` RPC that returns confirmed seat requests joined with user profiles. Frontend manifest card on the trip detail page.
### 12. [Boats] No weather cancellation / rebooking workflow
Cancelling a trip (`cancelBoatTrip`) does not notify affected passengers or offer rebooking. Seat requests just sit in their current state.
**What to build**: When a trip is cancelled, auto-create notifications for all confirmed seat request holders. Add a `rebookSeatRequest` RPC that moves a request to another trip.
### 13. [Tasks] No recurring task generation from templates
Task templates have a `recurrence_cron` field, but there is no scheduler or function that creates task instances from templates based on the cron expression. Recurring tasks (daily cleaning, weekly maintenance) must be created manually every time.
**What to build**: A scheduled function (cron job) `generateRecurringTasks` that reads active templates with recurrence_cron and creates task instances for upcoming periods.
### 14. [Tasks] No task history / completion stats
There is no way to see completed tasks over time, completion rates, or who completed the most tasks. No analytics view exists.
**What to build**: `getTaskStats` RPC returning completion counts by category, by user, and by time period. Frontend stats view on the tasks page.
### 15. [Inventory] No stocktake functionality
You can edit item quantities one at a time, but there is no bulk stocktake flow where someone walks through the storage room updating all quantities at once.
**What to build**: `submitStocktake` RPC that accepts an array of {itemId, actualQuantity} and updates all in one transaction with audit trail. Frontend stocktake page with a checklist.
### 16. [Inventory] No consumption history
When quantities are manually edited, the old value is lost (only the audit log captures it). There is no `inventory_transaction` table tracking additions/removals with reasons.
**What to build**: New `inventory_transaction` table (item_id, quantity_change, reason, user_id, created_at). `adjustInventory` RPC that creates a transaction record. Frontend history tab per item.
### 17. [Inventory] No automatic reorder alerts
Items have `minimum_quantity`, and the UI shows a yellow warning icon, but there is no notification sent when stock drops below minimum. The kitchen lead has to manually check the inventory page.
**What to build**: Trigger (database or application-level) that creates a notification when `current_quantity` drops below `minimum_quantity`.
### 18. [Finance] No totals, summaries, or reports
The finance page lists individual records but shows no totals by category, no monthly summaries, no income-vs-expense breakdown. You cannot generate a monthly report.
**What to build**: `getFinanceSummary` RPC returning totals by category, by record_type, and by month. Frontend summary cards at the top of the finance page.
### 19. [Finance] No link to retreat or project for cost tracking
The `finance_link` table exists and the `linkFinanceRecord` RPC exists, but the frontend has no UI to link a finance record to a retreat, boat trip, stay, or inventory request. You cannot track per-retreat costs.
**What to build**: Add a "Link to Entity" button on finance records that opens a selector for retreat/trip/stay/item. Show linked entity badges on the finance table.
### 20. [Finance] No approval workflow
`finance_record` has no `approved_by` or `approval_status` field. Anyone with `finance.manage` permission (which is missing from the roles entirely -- only admin has it via wildcard) can create records. There is no review step.
**What to build**: Add `approval_status` and `approved_by_user_id` to `finance_record`. Add `finance.manage` and `finance.view` permissions to relevant roles. Add approval workflow RPCs.
### 21. [Notifications] No way to send bulk or event-triggered notifications
The `notification` table is write-only from the application side. There is no `createNotification` or `sendBulkNotification` RPC. Notifications are only created as side effects (if even implemented). A coordinator cannot send a message like "boat departing in 2 hours" to all passengers.
**What to build**: `createNotification` RPC (admin/coordinator only) that creates notifications for one or more users. `sendBulkNotification` for a group (retreat participants, today's boat passengers, all active stays).
### 22. [Users] No way to invite new users
The users page shows existing users and lets admins edit roles, but there is no invite flow. New users can only join by signing in through auth.js. There is no way to pre-register someone with a role.
**What to build**: `inviteUser` RPC that creates a user record with email and role in 'invited' status. Send an email (or at minimum create the record so they can sign in).
---
## Nice to Have (Would Improve the Experience)
### 23. [Dashboard] No today's arrivals/departures widget
The dashboard does not show who is arriving or departing today, which is critical for room turnover planning.
### 24. [Dashboard] No upcoming tasks widget
The dashboard does not show the user's assigned or claimable tasks for today.
### 25. [Stays] No direct link from stay to room allocation
A stay exists independently of room allocation. To see which room a guest is in, you have to go to room allocations and search by stay ID. No inline room info on the stays page.
### 26. [Rooms] No cleaning/turnover tracking
There is no concept of room status (clean, dirty, in-progress) or housekeeping tasks auto-generated on checkout.
### 27. [Boats] Route names not human-readable
Boat trips display "Route abc123..." (UUID truncation). Routes need a human-readable name (e.g., "Aswan to Village"). No `listBoatRoutes` function exists for the frontend.
### 28. [Boats] Boat names not shown
Similar to routes -- boats are shown as UUIDs. No `listBoats` function for the frontend. Should show boat name/capacity.
### 29. [Retreats] No retreat-specific meal planning
Meals can be linked to a retreat via `retreat_id` on `meal_service`, but there is no UI to plan meals for a specific retreat or view retreat meals in the retreat detail page.
### 30. [Retreats] No notifications to retreat participants
Publishing a retreat does not notify registered participants. There is no mechanism to send updates to a retreat group.
### 31. [Tasks] Cannot assign tasks to specific roles (only users)
`task_assignment` links to a specific user. There is no way to say "this task is for any kitchen_lead" -- you must know the person.
### 32. [Profile] No user preferences beyond dietary
No notification preferences, no timezone setting, no language preference. The dietary profile is the only user preference.
### 33. [Kitchen] No meal feedback mechanism
There is no way for guests to rate or comment on meals.
### 34. [Stays] No stay extension or modification workflow
Once a stay is created, there is no way to extend dates or modify the stay type without cancelling and recreating.
### 35. [Audit Log] No filtering
The audit log shows all entries with no ability to filter by table, user, action, or date range.
---
## Data Model Issues
### finance_record
- Missing `approved_by_user_id` and `approval_status` fields
- No `finance.manage` or `finance.view` permissions in the roles system (only admin via wildcard can access)
### inventory_item
- No transaction/history table to track quantity changes over time
- No `last_restocked_at` or `supplier` field
### meal_service
- No `meal_ingredient` table linking meals to inventory items with quantities
- `menu_notes` is free text with no structured recipe/ingredient support
### task_template
- `recurrence_cron` exists but nothing reads it -- no scheduler generates instances
### room
- No `cleaning_status` or `last_cleaned_at` field
- No `floor` or `building` field for larger villages
### stay
- No `room_preference` field (only `rooms.request_preference` permission exists but no schema support)
### boat_trip
- No `weather_status` or `cancellation_reason` field
- No `max_passengers` or `max_cargo_weight` field on the trip (only on boat, which is a UUID reference)
### notification
- No `channel` field (in-app vs email vs SMS)
- No `scheduled_at` field for future notifications
### retreat
- No `budget` or `pricing` fields
- No `retreat_tag` or `category` for filtering retreat types
### user
- No `invited_by_user_id` or `invited_at` for tracking invitations
- No `last_active_at` for activity monitoring
- No `preferences` JSONB field for user settings
---
## Specific Recommendations (Priority Order)
### Tier 1 -- Do This Week (unblocks daily operations)
1. **Wire the dashboard stats**: Create `getDashboardStats` function, update dashboard page. Half-day effort.
2. **Enable meal creation**: Remove `disabled` from the Create Meal button, add a creation form calling `createMealService`. Half-day effort.
3. **Wire retreat people and schedule**: Add `listRetreatPersons` and `listRetreatScheduleItems` RPCs. Wire the retreat detail page to call them. Enable the Add Person and Add Schedule Item buttons. 1-day effort.
4. **Add "Currently Checked In" view**: Add a filtered tab to stays page. 2-hour effort.
### Tier 2 -- Do This Sprint (high-impact workflows)
5. **Meal attendance auto-generation**: Build the function that creates attendance records from active stays/retreats for a date. Show headcount and dietary summary per meal. 2-day effort.
6. **Boat manifest view**: Add a confirmed-passenger manifest to trip detail. 1-day effort.
7. **Room availability check**: Build the availability query and date-range picker. 1-day effort.
8. **Finance summary/totals**: Add summary cards to the finance page. 1-day effort.
9. **Recurring task generation**: Implement the cron-based task generator from templates. 1-day effort.
### Tier 3 -- Do Next Sprint (important but not blocking)
10. **Inventory transaction history and stocktake**. 2-day effort.
11. **Low-stock notifications**. Half-day effort.
12. **Bulk notification sending**. 1-day effort.
13. **Finance entity linking UI**. 1-day effort.
14. **Shopping list from menu** (requires meal_ingredient table). 3-day effort.
15. **User invitation flow**. 1-day effort.
16. **Boat route/boat name display** (add list functions + display). Half-day effort.
---
## Summary
The platform has a solid data model and a comprehensive set of CRUD operations. The core tables, enum types, and permission system are well-designed. However, the gap between "data exists" and "a human can use this daily" is significant in several areas:
- **Kitchen module** is essentially non-functional from the UI (cannot create meals, no attendance, no dietary aggregation)
- **Retreat detail page** is a shell (people and schedule are hardcoded empty arrays)
- **Dashboard** provides no operational value (all stats are "--")
- **Cross-entity workflows** are missing (menu to shopping list, stay to meal attendance, cancellation to rebooking, checkout to room cleaning)
- **Finance** has no summaries, no approval flow, and no entity linking UI
The backend functions are about 70% complete. The frontend is about 50% complete relative to what the backend supports. The biggest wins come from wiring existing backend capabilities to the frontend (retreat persons, meal creation) and adding the handful of missing aggregation/summary functions that make the data actionable.

1
README.md Normal file
View File

@@ -0,0 +1 @@
# perauset

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

27
apps/app/messages/de.json Normal file
View File

@@ -0,0 +1,27 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"brand": "PerAuset",
"common_email": "E-Mail",
"common_password": "Passwort",
"login_subtitle": "Melden Sie sich bei Ihrem Konto an",
"login_cta": "Anmelden",
"login_invalid_credentials": "Ungültige E-Mail oder Passwort",
"login_error": "Anmeldung fehlgeschlagen",
"nav_dashboard": "Übersicht",
"nav_stays": "Meine Aufenthalte",
"nav_tasks": "Meine Aufgaben",
"nav_kitchen": "Küche",
"nav_inventory": "Inventar",
"nav_retreats": "Retreats",
"nav_finance": "Finanzen",
"nav_rooms": "Zimmer",
"nav_boats": "Boote",
"nav_users": "Benutzer",
"nav_audit_log": "Audit-Protokoll",
"nav_sign_out": "Abmelden",
"dashboard_title": "Übersicht",
"dashboard_stays": "Aufenthalte",
"dashboard_tasks": "Aufgaben",
"dashboard_boats": "Boote",
"dashboard_retreats": "Retreats"
}

612
apps/app/messages/en.json Normal file
View File

@@ -0,0 +1,612 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"ai_chat_greeting": "How can I help you today?",
"ai_chat_input_placeholder": "Ask me anything about the village...",
"ai_chat_subtitle": "Ask me anything about the village -- stays, tasks, boats, retreats, and more.",
"ai_chat_title": "PerAuset Assistant",
"audit_action": "Action",
"audit_changed_fields": "Changed Fields",
"audit_description": "View a record of all changes made in the system.",
"audit_record_id": "Record ID",
"audit_table": "Table",
"audit_timestamp": "Timestamp",
"audit_title": "Audit Log",
"audit_user": "User",
"boats_accessibility_needs": "Any accessibility or special needs...",
"boats_actions": "Actions",
"boats_active": "Active",
"boats_add_boat": "Add Boat",
"boats_add_route": "Add Route",
"boats_additional_information": "Any additional information...",
"boats_arrival": "Arrival",
"boats_assigned": "Assigned",
"boats_boat": "Boat",
"boats_boat_name": "Boat Name",
"boats_boat_name_placeholder": "e.g. Nile Star",
"boats_boat_notes_placeholder": "Additional details about the boat...",
"boats_boats": "Boats",
"boats_cancel_trip": "Cancel Trip",
"boats_cargo": "Cargo",
"boats_cargo_capacity": "Cargo Capacity (kg)",
"boats_cargo_capacity_placeholder": "e.g. 500",
"boats_cargo_kg": "Cargo (kg)",
"boats_cargo_notes": "Cargo Notes",
"boats_complete_trip": "Complete Trip",
"boats_confirm": "Confirm",
"boats_create_trip": "Create Trip",
"boats_decline": "Decline",
"boats_departure": "Departure",
"boats_describe_cargo": "Describe any cargo you need to bring...",
"boats_destination": "Destination",
"boats_destination_placeholder": "e.g. Aswan",
"boats_edit_boat": "Edit Boat",
"boats_edit_route": "Edit Route",
"boats_fleet_description": "Manage boats and routes.",
"boats_fleet_management": "Fleet Management",
"boats_inactive": "Inactive",
"boats_manifest": "Manifest",
"boats_name": "Name",
"boats_no_boats": "No boats found. Add one to get started.",
"boats_no_boats_available": "No boats available",
"boats_no_confirmed_passengers": "No confirmed passengers yet.",
"boats_no_matching_trips": "No matching trips",
"boats_no_open_trips": "No open trips available",
"boats_no_routes": "No routes found. Add one to get started.",
"boats_no_routes_available": "No routes available",
"boats_no_trips": "No boat trips found.",
"boats_notes": "Notes",
"boats_number_of_seats": "Number of Seats",
"boats_open_for_requests": "Open for Requests",
"boats_origin": "Origin",
"boats_origin_placeholder": "e.g. Luxor",
"boats_passenger_capacity": "Passenger Capacity",
"boats_passengers": "Passengers",
"boats_print_manifest": "Print Manifest",
"boats_request_seat": "Request Seat",
"boats_request_seat_description": "Request a seat on an upcoming boat trip.",
"boats_request_seat_title": "Request Boat Seat",
"boats_requested": "Requested",
"boats_route": "Route",
"boats_route_name": "Route Name",
"boats_route_name_placeholder": "e.g. Luxor Express",
"boats_routes": "Routes",
"boats_save_boat": "Save Boat",
"boats_save_route": "Save Route",
"boats_scheduled_arrival": "Scheduled Arrival",
"boats_scheduled_departure": "Scheduled Departure",
"boats_seat_requests": "Seat Requests",
"boats_seats": "Seats",
"boats_select_boat_optional": "Select a boat (optional)",
"boats_select_route": "Select a route",
"boats_select_trip": "Select a trip",
"boats_special_requirements": "Special Requirements",
"boats_status": "Status",
"boats_trip": "Trip",
"boats_trip_details": "Trip Details",
"boats_trip_not_found": "Trip Not Found",
"boats_trip_not_found_message": "This boat trip could not be found.",
"boats_trips_description": "View upcoming and past boat trips.",
"boats_trips_title": "Boat Trips",
"boats_unknown_boat": "Unknown Boat",
"boats_user": "User",
"brand": "PerAuset",
"common_cancel": "Cancel",
"common_close": "Close",
"common_email": "Email",
"common_error": "Error",
"common_password": "Password",
"common_save_changes": "Save Changes",
"common_success": "Success",
"common_validation": "Validation",
"dashboard_boats": "Boats",
"dashboard_retreats": "Retreats",
"dashboard_stays": "Stays",
"dashboard_tasks": "Tasks",
"dashboard_title": "Dashboard",
"finance_actions": "Actions",
"finance_add_link": "Add New Link",
"finance_add_record": "Add Record",
"finance_amount": "Amount",
"finance_base": "Base",
"finance_by_category": "By Category",
"finance_category": "Category",
"finance_create_record": "Create Record",
"finance_currency": "Currency",
"finance_date": "Date",
"finance_description": "Description",
"finance_edit_record": "Edit Finance Record",
"finance_empty": "No finance records found.",
"finance_entity_type": "Entity Type",
"finance_exchange_rates": "Exchange Rates",
"finance_existing_links": "Existing Links",
"finance_last_updated": "Last updated",
"finance_link_entity": "Link Entity",
"finance_name": "Name",
"finance_net": "Net",
"finance_no_links": "No linked entities yet.",
"finance_notes": "Notes",
"finance_reference": "Reference",
"finance_reference_id": "Reference ID",
"finance_refresh_rates": "Refresh Rates",
"finance_save_changes": "Save Changes",
"finance_subtitle": "Track expenses, income, and reimbursements.",
"finance_title": "Finance",
"finance_total_expenses": "Total Expenses",
"finance_total_income": "Total Income",
"finance_type": "Type",
"inventory_add_item": "Add Item",
"inventory_add_item_title": "Add Inventory Item",
"inventory_alert_threshold": "Alert threshold",
"inventory_cancel": "Cancel",
"inventory_cat_equipment": "Equipment",
"inventory_cat_garden": "Garden",
"inventory_cat_kitchen": "Kitchen",
"inventory_cat_other": "Other",
"inventory_cat_rooms": "Rooms",
"inventory_category": "Category",
"inventory_col_actions": "Actions",
"inventory_col_change": "Change",
"inventory_col_date": "Date",
"inventory_col_item": "Item",
"inventory_col_notes": "Notes",
"inventory_col_quantity": "Quantity",
"inventory_col_reason": "Reason",
"inventory_col_status": "Status",
"inventory_col_unit": "Unit",
"inventory_create_item": "Create Item",
"inventory_current_quantity": "Current Quantity",
"inventory_description": "Track supplies and request items.",
"inventory_edit_item": "Edit Item",
"inventory_edit_item_title": "Edit Inventory Item",
"inventory_error_create": "Failed to create item.",
"inventory_error_request": "Failed to submit request.",
"inventory_error_stocktake": "Failed to submit stocktake.",
"inventory_error_title": "Error",
"inventory_error_update": "Failed to update item.",
"inventory_field_name": "Item Name",
"inventory_field_unit": "Unit",
"inventory_history_title": "History: {name}",
"inventory_in_stock": "In Stock",
"inventory_item_created_message": "Inventory item has been added.",
"inventory_item_created_title": "Item created",
"inventory_item_updated_message": "Inventory item has been updated.",
"inventory_item_updated_title": "Item updated",
"inventory_low_stock": "Low Stock",
"inventory_minimum_quantity": "Minimum Quantity",
"inventory_missing_fields_message": "Name and unit are required.",
"inventory_missing_fields_title": "Missing fields",
"inventory_name_placeholder": "e.g. Rice, Soap, Candles",
"inventory_no_history": "No transaction history for this item.",
"inventory_no_items": "No inventory items found.",
"inventory_request_item": "Request Item",
"inventory_request_item_title": "Request Inventory Item",
"inventory_request_name_placeholder": "e.g., Olive oil, Cleaning supplies",
"inventory_request_purpose": "Purpose",
"inventory_request_purpose_placeholder": "What is this for?",
"inventory_request_quantity": "Quantity",
"inventory_request_quantity_placeholder": "Amount needed",
"inventory_request_submitted_message": "Your inventory request has been submitted.",
"inventory_request_submitted_title": "Request submitted",
"inventory_request_unit_placeholder": "e.g., liters, kg, boxes",
"inventory_save_changes": "Save Changes",
"inventory_stocktake": "Stocktake",
"inventory_stocktake_current": "Current: {quantity} {unit}",
"inventory_stocktake_intro": "Enter the actual counted quantity for each item. Only changed items will be updated.",
"inventory_stocktake_submitted_message": "{count} item(s) adjusted.",
"inventory_stocktake_submitted_title": "Stocktake submitted",
"inventory_stocktake_title": "Stocktake",
"inventory_submit_request": "Submit Request",
"inventory_submit_stocktake": "Submit Stocktake",
"inventory_tab_all": "All ({count})",
"inventory_tab_equipment": "Equipment ({count})",
"inventory_tab_garden": "Garden ({count})",
"inventory_tab_kitchen": "Kitchen ({count})",
"inventory_tab_other": "Other ({count})",
"inventory_tab_rooms": "Rooms ({count})",
"inventory_title": "Inventory",
"inventory_transaction_history": "Transaction History",
"inventory_unit_placeholder": "e.g. kg, liters, pieces",
"kitchen_cancel": "Cancel",
"kitchen_create_meal": "Create Meal",
"kitchen_create_meal_service": "Create Meal Service",
"kitchen_description": "Meal services and dietary management.",
"kitchen_dietary_allergy_notes": "Allergy Notes",
"kitchen_dietary_allergy_notes_placeholder": "Describe any allergies in detail...",
"kitchen_dietary_cancel": "Cancel",
"kitchen_dietary_dairy_free": "Dairy-free",
"kitchen_dietary_description": "Update your dietary preferences and allergies so the kitchen can accommodate your needs.",
"kitchen_dietary_dislikes": "Dislikes",
"kitchen_dietary_dislikes_placeholder": "Foods you prefer to avoid...",
"kitchen_dietary_error": "Failed to save dietary profile",
"kitchen_dietary_gluten_free": "Gluten-free",
"kitchen_dietary_nut_allergy": "Nut allergy",
"kitchen_dietary_other_requirements": "Other Requirements",
"kitchen_dietary_other_requirements_placeholder": "Any other dietary requirements...",
"kitchen_dietary_preferences": "Dietary Preferences",
"kitchen_dietary_save": "Save Profile",
"kitchen_dietary_success": "Dietary profile saved successfully.",
"kitchen_dietary_title": "Dietary Profile",
"kitchen_dietary_vegan": "Vegan",
"kitchen_dietary_vegetarian": "Vegetarian",
"kitchen_error_create_meal": "Failed to create meal service.",
"kitchen_error_title": "Error",
"kitchen_expected_headcount": "Expected headcount: {count}",
"kitchen_meal_created_message": "The meal service has been created.",
"kitchen_meal_created_title": "Meal created",
"kitchen_meal_type_breakfast": "Breakfast",
"kitchen_meal_type_dinner": "Dinner",
"kitchen_meal_type_lunch": "Lunch",
"kitchen_meal_type_snack": "Snack",
"kitchen_menu_notes": "Menu Notes",
"kitchen_menu_notes_placeholder": "Menu description or notes",
"kitchen_no_meals": "No meal services scheduled.",
"kitchen_planned_headcount": "Planned Headcount",
"kitchen_planned_headcount_placeholder": "Expected number of diners",
"kitchen_select_meal_type": "Select meal type",
"kitchen_service_date": "Service Date",
"kitchen_service_type": "Service Type",
"kitchen_status_cancelled": "Cancelled",
"kitchen_status_planned": "Planned",
"kitchen_status_preparing": "Preparing",
"kitchen_status_served": "Served",
"kitchen_title": "Kitchen",
"login_cta": "Sign in",
"login_error": "Sign-in failed",
"login_invalid_credentials": "Invalid email or password",
"login_subtitle": "Sign in to your account",
"my_active_stays": "Active Stays",
"my_dietary_profile": "Dietary Profile",
"my_dietary_profile_description": "Manage your dietary preferences, allergies, and meal requirements.",
"my_display_name": "Display Name",
"my_display_name_placeholder": "How should we call you?",
"my_edit_profile": "Edit Profile",
"my_field_email": "Email",
"my_field_name": "Name",
"my_field_roles": "Roles",
"my_field_status": "Status",
"my_first_name": "First Name",
"my_first_name_placeholder": "First name",
"my_last_name": "Last Name",
"my_last_name_placeholder": "Last name",
"my_mobile_number": "Mobile Number",
"my_no_active_stays": "You don't have any active stays at the moment.",
"my_no_active_tasks": "No active tasks right now. Enjoy the calm!",
"my_no_requests": "You haven't submitted any stay requests yet.",
"my_phone": "Phone",
"my_phone_placeholder": "+1 (555) 000-0000",
"my_profile_description": "View and update your personal information.",
"my_profile_title": "My Profile",
"my_requests": "My Requests",
"my_save_profile": "Save Profile",
"my_sign_in_to_view": "Please sign in to view your profile.",
"my_stays_description": "Your stay requests and active stays.",
"my_stays_title": "My Stays",
"my_tasks_assigned": "Assigned to Me",
"my_tasks_blocked": "Blocked",
"my_tasks_description": "Tasks assigned to you or available to claim.",
"my_tasks_in_progress": "In Progress",
"my_tasks_open": "Open (Claimable)",
"my_tasks_title": "My Tasks",
"my_update_failed": "Failed to update profile",
"my_update_success": "Profile updated successfully.",
"my_whatsapp_number": "WhatsApp Number",
"nav_audit_log": "Audit Log",
"nav_boat_management": "Boat Management",
"nav_boats": "Boats",
"nav_dashboard": "Dashboard",
"nav_finance": "Finance",
"nav_inventory": "Inventory",
"nav_kitchen": "Kitchen",
"nav_management": "Management",
"nav_more": "More",
"nav_my_profile": "My Profile",
"nav_my_stays": "My Stays",
"nav_my_tasks": "My Tasks",
"nav_operations": "Operations",
"nav_personal": "Personal",
"nav_profile": "Profile",
"nav_retreats": "Retreats",
"nav_rooms": "Rooms",
"nav_sign_out": "Sign out",
"nav_stays": "My Stays",
"nav_tasks": "My Tasks",
"nav_users": "Users",
"notifications_all_caught_up": "You are all caught up.",
"notifications_empty": "No notifications",
"notifications_title": "Notifications",
"notifications_unread_count": "{count} unread",
"notifications_view_all": "View all notifications",
"profile_description": "View and update your personal information.",
"profile_dietary_profile": "Dietary Profile",
"profile_dietary_profile_description": "Manage your dietary preferences and allergies.",
"profile_display_name": "Display Name",
"profile_display_name_placeholder": "How should we call you?",
"profile_edit_dietary_profile": "Edit Dietary Profile",
"profile_edit_profile": "Edit Profile",
"profile_field_email": "Email",
"profile_field_roles": "Roles",
"profile_field_status": "Status",
"profile_first_name": "First Name",
"profile_first_name_placeholder": "First name",
"profile_last_name": "Last Name",
"profile_last_name_placeholder": "Last name",
"profile_phone": "Phone",
"profile_phone_placeholder": "+1 (555) 000-0000",
"profile_save_profile": "Save Profile",
"profile_sign_in_to_view": "Please sign in to view your profile.",
"profile_title": "My Profile",
"profile_update_failed": "Failed to update profile",
"profile_update_success": "Profile updated successfully.",
"retreats_add_meal": "Add Meal",
"retreats_add_meal_service": "Add Meal Service",
"retreats_add_person": "Add Person",
"retreats_add_person_title": "Add Person to Retreat",
"retreats_add_schedule_item": "Add Schedule Item",
"retreats_back": "Back to Retreats",
"retreats_capacity": "Capacity",
"retreats_create": "Create Retreat",
"retreats_description": "Description",
"retreats_empty": "No retreats found. Create one to get started.",
"retreats_end_date": "End Date",
"retreats_ends_at": "Ends At",
"retreats_headcount": "Headcount",
"retreats_lead_facilitator": "Lead Facilitator",
"retreats_location": "Location",
"retreats_menu_notes": "Menu Notes",
"retreats_name": "Retreat Name",
"retreats_no_meals": "No meal services planned for this retreat yet.",
"retreats_no_people": "No people added to this retreat yet.",
"retreats_no_schedule": "No schedule items yet.",
"retreats_not_found_body": "The retreat you are looking for does not exist or you do not have access.",
"retreats_not_found_title": "Retreat Not Found",
"retreats_notify_body": "Body",
"retreats_notify_participants": "Notify Participants",
"retreats_notify_subject": "Title",
"retreats_notify_title": "Notify Retreat Participants",
"retreats_person_name": "Name",
"retreats_planned_headcount": "Planned Headcount",
"retreats_role": "Role",
"retreats_schedule_title": "Title",
"retreats_send_notification": "Send Notification",
"retreats_service_date": "Service Date",
"retreats_service_type": "Service Type",
"retreats_slug": "Slug",
"retreats_start_date": "Start Date",
"retreats_starts_at": "Starts At",
"retreats_status": "Status",
"retreats_subtitle": "Manage and view scheduled retreats.",
"retreats_tab_meals": "Meals",
"retreats_tab_overview": "Overview",
"retreats_tab_people": "People",
"retreats_tab_schedule": "Schedule",
"retreats_title": "Retreats",
"rooms_active": "Active",
"rooms_allocations_all_description": "All room allocations across the village.",
"rooms_allocations_room_description": "Allocations for this room.",
"rooms_allocations_title": "Room Allocations",
"rooms_availability": "Availability",
"rooms_availability_end": "Available To",
"rooms_availability_start": "Available From",
"rooms_available": "Available",
"rooms_capacity": "Capacity",
"rooms_create_room": "Create Room",
"rooms_dates": "Dates",
"rooms_description": "View and manage room inventory.",
"rooms_edit_room": "Edit Room",
"rooms_full": "Full",
"rooms_inactive": "Inactive",
"rooms_no_rooms": "No rooms found.",
"rooms_notes": "Notes",
"rooms_notes_placeholder": "Additional details...",
"rooms_price_night": "Price / Night",
"rooms_price_per_night": "Price per Night (EUR)",
"rooms_price_placeholder": "e.g. 25.00",
"rooms_reason": "Reason",
"rooms_room": "Room",
"rooms_room_code": "Room Code",
"rooms_room_code_placeholder": "e.g. R101",
"rooms_room_name": "Room Name",
"rooms_room_name_placeholder": "e.g. Sunrise Room",
"rooms_room_type": "Room Type",
"rooms_save_room": "Save Room",
"rooms_select_type": "Select type",
"rooms_showing_availability": "Showing availability",
"rooms_status": "Status",
"rooms_title": "Rooms",
"rooms_type": "Type",
"rooms_view_allocations": "View Allocations",
"stays_actions": "Actions",
"stays_active_stays": "Active Stays",
"stays_approve": "Approve",
"stays_approve_failed": "Failed to approve request.",
"stays_cancel_failed": "Failed to cancel stay.",
"stays_cancel_stay": "Cancel Stay",
"stays_check_in": "Check In",
"stays_check_in_date": "Check-in Date",
"stays_check_in_failed": "Failed to check in.",
"stays_check_out": "Check Out",
"stays_check_out_failed": "Failed to check out.",
"stays_checked_in": "Checked In",
"stays_checked_in_message": "Guest has been checked in.",
"stays_checked_out": "Checked out",
"stays_checked_out_message": "Guest has been checked out.",
"stays_create_failed": "Something went wrong creating the stay.",
"stays_create_stay": "Create Stay",
"stays_create_stay_hint": "This will create a stay request, auto-approve it, and generate the stay record in one step. The stay will be ready for check-in immediately.",
"stays_dates": "Dates",
"stays_description": "Manage stays, requests, and check-ins.",
"stays_end_date": "End Date",
"stays_expected_checkout": "Expected Checkout",
"stays_guest": "Guest",
"stays_missing_fields": "Missing fields",
"stays_missing_fields_message": "Please fill in all required fields.",
"stays_no_active_stays": "No active stays found.",
"stays_no_checked_in": "No guests currently checked in.",
"stays_no_requests": "No stay requests found.",
"stays_notes": "Notes",
"stays_notes_placeholder": "Any additional information...",
"stays_reject": "Reject",
"stays_reject_failed": "Failed to reject request.",
"stays_request_approved": "Request approved",
"stays_request_approved_message": "The stay request has been approved.",
"stays_request_rejected": "Request rejected",
"stays_request_rejected_message": "The stay request has been rejected.",
"stays_request_stay": "Request a Stay",
"stays_request_submitted": "Request submitted",
"stays_request_submitted_message": "Your stay request has been submitted for review.",
"stays_request_type": "Request Type",
"stays_requested_dates": "Requested Dates",
"stays_requests": "Requests",
"stays_select_type": "Select type",
"stays_something_went_wrong": "Something went wrong.",
"stays_start_date": "Start Date",
"stays_status": "Status",
"stays_stay_cancelled": "Stay cancelled",
"stays_stay_cancelled_message": "The stay has been cancelled.",
"stays_stay_created": "Stay created",
"stays_stay_created_message": "The stay has been created and is ready for check-in.",
"stays_stay_type": "Stay Type",
"stays_submit_request": "Submit Request",
"stays_title": "Stays",
"stays_type": "Type",
"stays_type_day_visit": "Day Visit",
"stays_type_facilitator": "Facilitator",
"stays_type_guest": "Guest",
"stays_type_staff": "Staff",
"stays_type_volunteer": "Volunteer",
"stays_user": "User",
"tasks_assign_role": "Assign Role",
"tasks_assign_to_role": "Assign Task to Role",
"tasks_assign_to_role_optional": "Assign to Role (optional)",
"tasks_block": "Block",
"tasks_block_title": "Mark Task Blocked",
"tasks_blocked": "Task marked as blocked",
"tasks_cancel": "Cancel",
"tasks_category": "Category",
"tasks_category_housekeeping": "Housekeeping",
"tasks_category_kitchen": "Kitchen",
"tasks_category_maintenance": "Maintenance",
"tasks_category_operations": "Operations",
"tasks_category_retreat": "Retreat",
"tasks_category_transport": "Transport",
"tasks_claim": "Claim",
"tasks_claimed": "Task claimed",
"tasks_complete": "Complete",
"tasks_complete_title": "Complete Task",
"tasks_completed": "Task completed",
"tasks_create_new_task": "Create New Task",
"tasks_create_task": "Create Task",
"tasks_created_message": "\"{title}\" has been created successfully.",
"tasks_created_title": "Task created",
"tasks_description": "View and manage village tasks across all categories.",
"tasks_description_placeholder": "Optional details about the task",
"tasks_error_assign_role": "Failed to assign role.",
"tasks_error_generate": "Failed to generate recurring tasks.",
"tasks_error_generic": "Something went wrong",
"tasks_error_title": "Error",
"tasks_field_description": "Description",
"tasks_field_title": "Title",
"tasks_generate_recurring": "Generate Recurring Tasks",
"tasks_generated_message": "Generated {count} recurring task(s).",
"tasks_generated_title": "Tasks generated",
"tasks_location": "Location",
"tasks_location_placeholder": "e.g. Main building, Kitchen",
"tasks_mark_blocked": "Mark Blocked",
"tasks_new_task": "New Task",
"tasks_no_status_tasks": "No {status} tasks",
"tasks_no_tasks": "No tasks",
"tasks_notes_optional": "Notes (optional)",
"tasks_notes_placeholder": "Any completion notes?",
"tasks_reason": "Reason",
"tasks_reason_placeholder": "Why is this task blocked?",
"tasks_role": "Role",
"tasks_role_admin": "Admin",
"tasks_role_assigned_message": "Task has been assigned to the selected role.",
"tasks_role_assigned_title": "Role assigned",
"tasks_role_boat_coordinator": "Boat Coordinator",
"tasks_role_community_member": "Community Member",
"tasks_role_coordinator": "Coordinator",
"tasks_role_facilitator": "Facilitator",
"tasks_role_kitchen_lead": "Kitchen Lead",
"tasks_role_staff": "Staff",
"tasks_role_volunteer": "Volunteer",
"tasks_scheduled_end": "Scheduled End",
"tasks_scheduled_start": "Scheduled Start",
"tasks_select_category": "Select category",
"tasks_select_role": "Select a role",
"tasks_start": "Start",
"tasks_started": "Task started",
"tasks_status_assigned": "Assigned",
"tasks_status_blocked": "Blocked",
"tasks_status_cancelled": "Cancelled",
"tasks_status_completed": "Completed",
"tasks_status_in_progress": "In Progress",
"tasks_status_open": "Open",
"tasks_status_planned": "Planned",
"tasks_templates": "Templates",
"tasks_templates_back": "Back to Tasks",
"tasks_templates_claimable": "Claimable",
"tasks_templates_col_category": "Category",
"tasks_templates_col_claimable": "Claimable",
"tasks_templates_col_duration": "Est. Duration",
"tasks_templates_col_recurrence": "Recurrence",
"tasks_templates_col_title": "Title",
"tasks_templates_description": "Reusable task definitions for recurring village operations.",
"tasks_templates_empty": "No templates yet. Create one above to get started.",
"tasks_templates_error": "Failed to load templates",
"tasks_templates_minutes": "{minutes} min",
"tasks_templates_no": "No",
"tasks_templates_not_claimable": "Not claimable",
"tasks_templates_one_time": "One-time",
"tasks_templates_recurring": "Recurring",
"tasks_templates_title": "Task Templates",
"tasks_templates_yes": "Yes",
"tasks_title": "Tasks",
"tasks_title_placeholder": "e.g. Clean guest rooms",
"tasks_validation_message": "Title and category are required.",
"tasks_validation_title": "Validation",
"templates_create": "Create Template",
"templates_created": "Template created",
"templates_field_category": "Category",
"templates_field_category_placeholder": "Select category",
"templates_field_claimable": "Claimable by volunteers",
"templates_field_default_location": "Default Location",
"templates_field_default_location_placeholder": "e.g. Main kitchen",
"templates_field_description": "Description",
"templates_field_description_placeholder": "What does this task involve?",
"templates_field_estimated_minutes": "Estimated Duration (minutes)",
"templates_field_estimated_minutes_placeholder": "e.g. 30",
"templates_field_recurrence": "Recurrence (cron)",
"templates_field_recurrence_placeholder": "e.g. 0 8 * * 1,3,5",
"templates_field_title": "Title",
"templates_field_title_placeholder": "e.g. Daily kitchen clean-up",
"templates_hide_form": "Hide Form",
"templates_new": "New Template",
"templates_recurrence_hint": "Leave blank for one-time tasks",
"templates_title_category_required": "Title and category are required.",
"user_select_label": "User",
"users_back": "Back to Users",
"users_display_name": "Display Name",
"users_edit_roles": "Edit Roles",
"users_first_name": "First Name",
"users_invite": "Invite User",
"users_last_name": "Last Name",
"users_name": "Name",
"users_no_roles": "No roles",
"users_not_found_body": "The user you are looking for does not exist or you do not have access.",
"users_not_found_title": "User Not Found",
"users_registered_count": "{count} users registered.",
"users_role_management": "Role Management",
"users_roles": "Roles",
"users_roles_updated": "Roles updated successfully.",
"users_save_roles": "Save Roles",
"users_send_invitation": "Send Invitation",
"users_status": "Status",
"users_title": "Users & Roles",
"profile_name": "Name",
"profile_name_placeholder": "Full name",
"profile_mobile_number": "Mobile number",
"profile_mobile_number_placeholder": "Mobile number",
"profile_whatsapp_number": "WhatsApp number",
"profile_whatsapp_number_placeholder": "WhatsApp number"
}

40
apps/app/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "@perauset/app",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --port 6001",
"build": "tsc -b && vite build",
"preview": "vite preview --port 6001",
"tsc": "tsc --noEmit"
},
"dependencies": {
"@mantine/core": "^9.2.1",
"@mantine/hooks": "^9.2.1",
"@mantine/notifications": "^9.2.1",
"@perauset/components": "workspace:*",
"@perauset/functions": "workspace:*",
"@perauset/functions-sdk": "workspace:*",
"@perauset/mantine-theme": "workspace:*",
"@pikku/fetch": "^0.12.6",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-router": "^1.100.0",
"@tanstack/react-start": "^1.100.0",
"better-auth": "^1.6.19",
"lucide-react": "^0.554.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.20.0",
"@tanstack/router-generator": "^1.100.0",
"@tanstack/router-plugin": "^1.100.0",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"typescript": "^5.9"
}
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -0,0 +1,305 @@
import { useState, useRef, useEffect } from 'react'
import {
Drawer,
Stack,
Group,
Text,
TextInput,
ActionIcon,
ScrollArea,
Paper,
Avatar,
Loader,
Box,
} from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { Send, Bot } from 'lucide-react'
import { useSessionUser } from '@/lib/auth-context'
import { usePikkuRPC } from '@pikku/react'
import type { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen'
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
interface AIChatDrawerProps {
opened: boolean
onClose: () => void
}
export function AIChatDrawer({ opened, onClose }: AIChatDrawerProps) {
const rpc = usePikkuRPC<PikkuRPC>()
const userName = useSessionUser()?.name ?? ''
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [threadId, setThreadId] = useState<string | null>(null)
const viewportRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const userInitial = userName?.[0]?.toUpperCase() ?? 'U'
// Auto-scroll to bottom when messages change
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: 'smooth',
})
}
}, [messages, loading])
// Auto-focus input when drawer opens
useEffect(() => {
if (opened) {
setTimeout(() => {
inputRef.current?.focus()
}, 200)
}
}, [opened])
async function sendMessage(message: string) {
if (!message.trim() || loading) return
const userMessage = message.trim()
setInput('')
setLoading(true)
setMessages((prev) => [...prev, { role: 'user', content: userMessage }])
try {
const currentThreadId = threadId || crypto.randomUUID()
if (!threadId) setThreadId(currentThreadId)
// TODO: confirm agent RPC — runs the `perauset-router` agent to completion.
const res = await rpc.agent.run('perauset-router' as any, {
message: userMessage,
threadId: currentThreadId,
resourceId: currentThreadId,
})
const result = res?.result as
| string
| { text?: string; message?: string }
| undefined
const assistantMessage =
typeof result === 'string'
? result
: result?.text || result?.message || JSON.stringify(result)
setMessages((prev) => [
...prev,
{ role: 'assistant', content: assistantMessage },
])
} catch (err) {
const msg = err instanceof Error ? err.message : 'Request failed'
setMessages((prev) => [
...prev,
{ role: 'assistant', content: `Error: ${msg}` },
])
} finally {
setLoading(false)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage(input)
}
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size={480}
title={
<Group gap="xs">
<ActionIcon
variant="light"
color="teal"
size="md"
radius="xl"
style={{ pointerEvents: 'none' }}
>
<Bot size={18} />
</ActionIcon>
<Text
fw={700}
size="lg"
style={{ fontFamily: '"Cormorant", serif', color: '#2A7B88' }}
>
{m.ai_chat_title()}
</Text>
</Group>
}
styles={{
content: {
display: 'flex',
flexDirection: 'column',
},
body: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
padding: 0,
},
}}
>
{/* Messages area */}
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} px="md" pt="md">
{messages.length === 0 && (
<Stack align="center" justify="center" gap="sm" py="xl" mt="xl">
<ActionIcon
variant="light"
color="teal"
size={64}
radius="xl"
style={{ pointerEvents: 'none' }}
>
<Bot size={36} />
</ActionIcon>
<Text
size="lg"
fw={600}
c="dimmed"
ta="center"
style={{ fontFamily: '"Cormorant", serif' }}
>
{m.ai_chat_greeting()}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={300}>
{m.ai_chat_subtitle()}
</Text>
</Stack>
)}
<Stack gap="md" pb="md">
{messages.map((msg, i) => (
<Group
key={i}
justify={msg.role === 'user' ? 'flex-end' : 'flex-start'}
align="flex-end"
gap="xs"
wrap="nowrap"
>
{msg.role === 'assistant' && (
<Avatar
size="sm"
color="teal"
radius="xl"
style={{ flexShrink: 0 }}
>
<Bot size={16} />
</Avatar>
)}
<Paper
px="sm"
py="xs"
radius="lg"
style={{
maxWidth: '80%',
backgroundColor: msg.role === 'user' ? '#2A7B88' : '#F5F0E8',
color: msg.role === 'user' ? '#ffffff' : '#3D2B1F',
borderBottomRightRadius: msg.role === 'user' ? 4 : undefined,
borderBottomLeftRadius:
msg.role === 'assistant' ? 4 : undefined,
wordBreak: 'break-word',
}}
>
<Text
size="sm"
style={{
whiteSpace: 'pre-wrap',
lineHeight: 1.5,
}}
>
{asI18n(msg.content)}
</Text>
</Paper>
{msg.role === 'user' && (
<Avatar
size="sm"
color="teal"
radius="xl"
style={{ flexShrink: 0 }}
>
{userInitial}
</Avatar>
)}
</Group>
))}
{/* Typing indicator */}
{loading && (
<Group justify="flex-start" align="flex-end" gap="xs" wrap="nowrap">
<Avatar size="sm" color="teal" radius="xl" style={{ flexShrink: 0 }}>
<Bot size={16} />
</Avatar>
<Paper
px="sm"
py="xs"
radius="lg"
style={{
backgroundColor: '#F5F0E8',
borderBottomLeftRadius: 4,
}}
>
<Group gap={4}>
<Loader size="xs" type="dots" color="teal" />
</Group>
</Paper>
</Group>
)}
</Stack>
</ScrollArea>
{/* Input bar */}
<Box
px="md"
py="sm"
style={{
borderTop: '1px solid rgba(61, 43, 31, 0.08)',
backgroundColor: '#ffffff',
}}
>
<Group gap="xs" wrap="nowrap">
<TextInput
ref={inputRef}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={m.ai_chat_input_placeholder()}
disabled={loading}
radius="xl"
size="md"
style={{ flex: 1 }}
styles={{
input: {
border: '1px solid rgba(61, 43, 31, 0.15)',
'&:focus': {
borderColor: '#2A7B88',
},
},
}}
/>
<ActionIcon
variant="filled"
color="teal"
size="lg"
radius="xl"
onClick={() => sendMessage(input)}
disabled={!input.trim() || loading}
aria-label={asI18n('Send message')}
>
<Send size={18} />
</ActionIcon>
</Group>
</Box>
</Drawer>
)
}

View File

@@ -0,0 +1,205 @@
import { useState } from 'react'
import {
Card,
Stack,
TextInput,
Textarea,
Select,
Group,
Button,
Switch,
NumberInput,
Collapse,
Title,
} from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { m } from '@/i18n/messages'
import { useQueryClient } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
type Category =
| 'housekeeping'
| 'kitchen'
| 'maintenance'
| 'operations'
| 'retreat'
| 'transport'
const CATEGORIES: { value: Category; label: string }[] = [
{ value: 'housekeeping', label: 'Housekeeping' },
{ value: 'kitchen', label: 'Kitchen' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'operations', label: 'Operations' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'transport', label: 'Transport' },
]
export function CreateTemplateForm() {
const queryClient = useQueryClient()
const [opened, { toggle }] = useDisclosure(false)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<Category | null>(null)
const [isClaimable, setIsClaimable] = useState(false)
const [defaultLocation, setDefaultLocation] = useState('')
const [estimatedMinutes, setEstimatedMinutes] = useState<number | string>('')
const [recurrenceCron, setRecurrenceCron] = useState('')
const resetForm = () => {
setTitle('')
setDescription('')
setCategory(null)
setIsClaimable(false)
setDefaultLocation('')
setEstimatedMinutes('')
setRecurrenceCron('')
}
const mutation = usePikkuMutation('createTaskTemplate', {
onSuccess: () => {
notifications.show({
title: m.templates_created(),
message: `"${title}" template has been created.`,
color: 'teal',
})
resetForm()
toggle()
queryClient.invalidateQueries({
predicate: (q) =>
typeof q.queryKey[0] === 'string' &&
(q.queryKey[0] as string).toLowerCase().includes('template'),
})
},
onError: (err: Error) => {
notifications.show({
title: m.common_error(),
message: err.message ?? 'Something went wrong',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !category) {
notifications.show({
title: m.common_validation(),
message: m.templates_title_category_required(),
color: 'orange',
})
return
}
const body = {
title: title.trim(),
category,
...(description.trim() ? { description: description.trim() } : {}),
...(isClaimable ? { isClaimable: true } : {}),
...(defaultLocation.trim() ? { defaultLocation: defaultLocation.trim() } : {}),
...(typeof estimatedMinutes === 'number' && estimatedMinutes > 0
? { estimatedMinutes }
: {}),
...(recurrenceCron.trim() ? { recurrenceCron: recurrenceCron.trim() } : {}),
}
mutation.mutate(body)
}
return (
<>
<Button variant="light" leftSection={<Plus size={16} />} onClick={toggle}>
{opened ? m.templates_hide_form() : m.templates_create()}
</Button>
<Collapse expanded={opened}>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 640,
}}
>
<Title
order={4}
mb="md"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.templates_new()}
</Title>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.templates_field_title()}
placeholder={m.templates_field_title_placeholder()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.templates_field_description()}
placeholder={m.templates_field_description_placeholder()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={2}
/>
<Select
label={m.templates_field_category()}
placeholder={m.templates_field_category_placeholder()}
data={CATEGORIES}
value={category}
onChange={(v) => setCategory(v as Category | null)}
required
/>
<Switch
label={m.templates_field_claimable()}
checked={isClaimable}
onChange={(e) => setIsClaimable(e.currentTarget.checked)}
/>
<TextInput
label={m.templates_field_default_location()}
placeholder={m.templates_field_default_location_placeholder()}
value={defaultLocation}
onChange={(e) => setDefaultLocation(e.currentTarget.value)}
/>
<NumberInput
label={m.templates_field_estimated_minutes()}
placeholder={m.templates_field_estimated_minutes_placeholder()}
min={1}
value={estimatedMinutes}
onChange={setEstimatedMinutes}
/>
<TextInput
label={m.templates_field_recurrence()}
placeholder={m.templates_field_recurrence_placeholder()}
description={m.templates_recurrence_hint()}
value={recurrenceCron}
onChange={(e) => setRecurrenceCron(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={toggle}>
{m.common_cancel()}
</Button>
<Button type="submit" loading={mutation.isPending}>
{m.templates_create()}
</Button>
</Group>
</Stack>
</form>
</Card>
</Collapse>
</>
)
}

View File

@@ -0,0 +1,30 @@
import { ActionIcon, Tooltip } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { Languages } from 'lucide-react'
import { useLocale, supportedLocales, type Locale } from '@/i18n/config'
export function LocaleSwitcher() {
const { locale, setLocale } = useLocale()
const toggleLocale = () => {
// Toggle between the supported locales (currently `en` and `de`).
const idx = supportedLocales.indexOf(locale)
const next = supportedLocales[(idx + 1) % supportedLocales.length] as Locale
setLocale(next)
}
return (
<Tooltip label={asI18n(locale.toUpperCase())}>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
radius="xl"
onClick={toggleLocale}
aria-label={asI18n('Switch language')}
>
<Languages size={20} />
</ActionIcon>
</Tooltip>
)
}

View File

@@ -0,0 +1,282 @@
import { useCallback } from 'react'
import {
ActionIcon,
Indicator,
Popover,
Drawer,
Stack,
Text,
Group,
Box,
UnstyledButton,
Anchor,
Divider,
} from '@pikku/mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { Bell, BellOff } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface NotificationItem {
notificationId: string
type: string
title: string
body: string | null
entityType: string | null
entityId: string | null
readAt: string | Date | null
createdAt: string | Date
}
function entityLink(
entityType: string | null,
entityId: string | null,
): string | null {
if (!entityType || !entityId) return null
switch (entityType) {
case 'task':
return '/tasks'
case 'stay':
return '/stays'
case 'boat_trip':
return '/boats'
case 'retreat':
return '/retreats'
default:
return null
}
}
function formatTimestamp(val: string | Date): string {
const d = typeof val === 'string' ? new Date(val) : val
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDays = Math.floor(diffHr / 24)
if (diffDays < 7) return `${diffDays}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function NotificationEntry({
n,
onRead,
}: {
n: NotificationItem
onRead: (id: string, link: string | null) => void
}) {
const isRead = !!n.readAt
const link = entityLink(n.entityType, n.entityId)
return (
<UnstyledButton
onClick={() => onRead(n.notificationId, link)}
style={{
display: 'block',
width: '100%',
padding: '10px 12px',
borderRadius: 8,
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
transition: 'background-color 0.15s ease',
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: isRead ? 'transparent' : '#2A7B88',
flexShrink: 0,
marginTop: 6,
}}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="sm" fw={isRead ? 400 : 600} lineClamp={1}>
{asI18n(n.title)}
</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{asI18n(formatTimestamp(n.createdAt))}
</Text>
</Group>
{n.body && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{asI18n(n.body)}
</Text>
)}
</Box>
</Group>
</UnstyledButton>
)
}
export function NotificationBell() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const isMobile = useMediaQuery('(max-width: 768px)')
const [popoverOpened, { toggle: togglePopover, close: closePopover }] =
useDisclosure()
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure()
const { data } = usePikkuQuery(
'listNotifications',
{ limit: 5, offset: 0 },
{ refetchInterval: 30_000 },
)
const items = (data?.items ?? []) as NotificationItem[]
const unreadCount = items.filter((n) => !n.readAt).length
const markReadMutation = usePikkuMutation('markNotificationRead', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
},
})
const handleRead = useCallback(
(notificationId: string, link: string | null) => {
// Optimistic update
queryClient.setQueryData<{ items?: NotificationItem[] }>(
['listNotifications', { limit: 5, offset: 0 }],
(old) => {
if (!old?.items) return old
return {
...old,
items: old.items.map((n) =>
n.notificationId === notificationId
? { ...n, readAt: new Date().toISOString() }
: n,
),
}
},
)
markReadMutation.mutate({ notificationId })
if (link) {
closePopover()
closeDrawer()
navigate({ to: link })
}
},
[queryClient, markReadMutation, closePopover, closeDrawer, navigate],
)
const handleToggle = () => {
if (isMobile) {
toggleDrawer()
} else {
togglePopover()
}
}
const bellButton = (
<Indicator
size={16}
label={unreadCount > 0 ? asI18n(String(unreadCount)) : undefined}
disabled={unreadCount === 0}
color="red"
offset={4}
processing={unreadCount > 0}
>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={handleToggle}
aria-label={asI18n('Notifications')}
>
<Bell size={20} />
</ActionIcon>
</Indicator>
)
const content = (
<Stack gap={0}>
<Group justify="space-between" px="xs" py="xs">
<Text fw={600} size="sm" style={{ fontFamily: '"Cormorant", serif' }}>
{m.notifications_title()}
</Text>
{unreadCount > 0 && (
<Text size="xs" c="teal" fw={500}>
{m.notifications_unread_count({ count: unreadCount })}
</Text>
)}
</Group>
<Divider />
{items.length === 0 ? (
<Stack align="center" gap="xs" py="lg">
<BellOff size={28} color="#aaa" />
<Text size="sm" c="dimmed">
{m.notifications_empty()}
</Text>
</Stack>
) : (
<Stack gap={2} p={4}>
{items.map((n) => (
<NotificationEntry key={n.notificationId} n={n} onRead={handleRead} />
))}
</Stack>
)}
<Divider />
<Box py="xs" px="xs" style={{ textAlign: 'center' }}>
<Anchor
component="button"
type="button"
size="sm"
onClick={() => {
closePopover()
closeDrawer()
navigate({ to: '/notifications' })
}}
>
{m.notifications_view_all()}
</Anchor>
</Box>
</Stack>
)
if (isMobile) {
return (
<>
{bellButton}
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
position="bottom"
size="60%"
title={undefined}
styles={{
content: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
}}
>
{content}
</Drawer>
</>
)
}
return (
<Popover
opened={popoverOpened}
onChange={(o) => {
if (!o) closePopover()
}}
width={360}
position="bottom-end"
shadow="lg"
radius="md"
>
<Popover.Target>{bellButton}</Popover.Target>
<Popover.Dropdown p={0}>{content}</Popover.Dropdown>
</Popover>
)
}

View File

@@ -0,0 +1,140 @@
import { Group, Text, Box, UnstyledButton } from '@pikku/mantine/core'
import { useNavigate } from '@tanstack/react-router'
import { asI18n } from '@pikku/react'
import { Bell, Check } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface NotificationItem {
notificationId: string
type: string
title: string
body: string | null
entityType: string | null
entityId: string | null
readAt: string | Date | null
createdAt: string | Date
}
function entityLink(
entityType: string | null,
entityId: string | null,
): string | null {
if (!entityType || !entityId) return null
switch (entityType) {
case 'task':
return '/tasks'
case 'stay':
return '/stays'
case 'boat_trip':
return '/boats'
case 'retreat':
return '/retreats'
default:
return null
}
}
function formatTimestamp(val: string | Date): string {
const d = typeof val === 'string' ? new Date(val) : val
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDays = Math.floor(diffHr / 24)
if (diffDays < 7) return `${diffDays}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function NotificationRow({
notification,
}: {
notification: NotificationItem
}) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const isRead = !!notification.readAt
const markReadMutation = usePikkuMutation('markNotificationRead', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
},
})
const handleClick = () => {
if (!isRead) {
markReadMutation.mutate({ notificationId: notification.notificationId })
}
const link = entityLink(notification.entityType, notification.entityId)
if (link) {
navigate({ to: link })
}
}
return (
<UnstyledButton
onClick={handleClick}
style={{
display: 'block',
width: '100%',
padding: '12px 16px',
borderBottom: '1px solid rgba(61, 43, 31, 0.06)',
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
transition: 'background-color 0.15s ease',
}}
>
<Group gap="sm" align="flex-start" wrap="nowrap">
<Box
style={{
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: isRead
? 'rgba(61, 43, 31, 0.06)'
: 'rgba(42, 123, 136, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginTop: 2,
}}
>
{isRead ? (
<Check size={16} color="#aaa" />
) : (
<Bell size={16} color="#2A7B88" />
)}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text
size="sm"
fw={isRead ? 400 : 600}
lineClamp={1}
style={{ color: '#3D2B1F' }}
>
{asI18n(notification.title)}
</Text>
<Text
size="xs"
c="dimmed"
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{asI18n(formatTimestamp(notification.createdAt))}
</Text>
</Group>
{notification.body && (
<Text size="xs" c="dimmed" lineClamp={2} mt={2}>
{asI18n(notification.body)}
</Text>
)}
</Box>
</Group>
</UnstyledButton>
)
}

View File

@@ -0,0 +1,419 @@
import { useState } from 'react'
import { Tabs, Text, Group, ActionIcon, Tooltip } from '@pikku/mantine/core'
import { Check, X, LogIn, LogOut, Ban } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { DataTable, type Column } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { usePermission } from '@/lib/permissions'
import { m } from '@/i18n/messages'
import { asI18n } from '@pikku/react'
interface Stay {
stayId: string
userId: string
userDisplayName?: string | null
stayType: string
startAt: string | Date
endAt: string | Date
status: string
}
interface StayRequest {
requestId: string
userId: string
userDisplayName?: string | null
requestType: string
requestedStartAt: string | Date
requestedEndAt: string | Date
status: string
notes: string | null
}
function formatDate(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
} catch {
return String(dateStr)
}
}
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function StayRequestActions({ requestId }: { requestId: string }) {
const queryClient = useQueryClient()
const approveMutation = usePikkuMutation('approveStayRequest', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_request_approved(),
message: m.stays_request_approved_message(),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_approve_failed(),
color: 'red',
})
},
})
const rejectMutation = usePikkuMutation('rejectStayRequest', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_request_rejected(),
message: m.stays_request_rejected_message(),
color: 'orange',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_reject_failed(),
color: 'red',
})
},
})
return (
<Group gap="xs">
<ActionIcon
variant="light"
color="green"
size="sm"
onClick={() => approveMutation.mutate({ requestId })}
loading={approveMutation.isPending}
aria-label={m.stays_approve()}
>
<Check size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => rejectMutation.mutate({ requestId, reviewNotes: '' })}
loading={rejectMutation.isPending}
aria-label={m.stays_reject()}
>
<X size={14} />
</ActionIcon>
</Group>
)
}
function StayActions({ stayId, status }: { stayId: string; status: string }) {
const queryClient = useQueryClient()
const checkInMutation = usePikkuMutation('checkInStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_checked_in(),
message: m.stays_checked_in_message(),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_check_in_failed(),
color: 'red',
})
},
})
const checkOutMutation = usePikkuMutation('checkOutStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_checked_out(),
message: m.stays_checked_out_message(),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_check_out_failed(),
color: 'red',
})
},
})
const cancelMutation = usePikkuMutation('cancelStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
notifications.show({
title: m.stays_stay_cancelled(),
message: m.stays_stay_cancelled_message(),
color: 'orange',
})
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_cancel_failed(),
color: 'red',
})
},
})
const canCheckIn = status === 'approved' || status === 'confirmed'
const canCheckOut = status === 'checked_in'
const canCancel = status === 'approved' || status === 'confirmed' || status === 'checked_in'
if (!canCheckIn && !canCheckOut && !canCancel) return null
return (
<Group gap="xs">
{canCheckIn && (
<Tooltip label={m.stays_check_in()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => checkInMutation.mutate({ stayId })}
loading={checkInMutation.isPending}
>
<LogIn size={14} />
</ActionIcon>
</Tooltip>
)}
{canCheckOut && (
<Tooltip label={m.stays_check_out()}>
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => checkOutMutation.mutate({ stayId })}
loading={checkOutMutation.isPending}
>
<LogOut size={14} />
</ActionIcon>
</Tooltip>
)}
{canCancel && (
<Tooltip label={m.stays_cancel_stay()}>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => cancelMutation.mutate({ stayId })}
loading={cancelMutation.isPending}
>
<Ban size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
)
}
export function StaysTabs({
stays,
stayRequests,
}: {
stays: Stay[]
stayRequests: StayRequest[]
}) {
const [activeTab, setActiveTab] = useState(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash.slice(1)
if (['active', 'checked_in', 'requests'].includes(hash)) return hash
}
return 'active'
})
const canReview = usePermission('stays.review')
const canManage = usePermission('stays.manage')
const stayColumns: Column<Stay>[] = [
{
key: 'userId',
label: m.stays_user(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userDisplayName || 'Guest')}
</Text>
),
},
{
key: 'stayType',
label: m.stays_type(),
render: (row) => <Text size="sm">{asI18n(formatType(row.stayType))}</Text>,
},
{
key: 'dates',
label: m.stays_dates(),
render: (row) => (
<Text size="sm">
{asI18n(`${formatDate(row.startAt)} - ${formatDate(row.endAt)}`)}
</Text>
),
},
{
key: 'status',
label: m.stays_status(),
render: (row) => <StatusBadge status={row.status} />,
},
...(canManage
? [
{
key: 'actions',
label: m.stays_actions(),
render: (row: Stay) => (
<StayActions stayId={row.stayId} status={row.status} />
),
},
]
: []),
]
const requestColumns: Column<StayRequest>[] = [
{
key: 'userId',
label: m.stays_user(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userDisplayName || 'Guest')}
</Text>
),
},
{
key: 'requestType',
label: m.stays_type(),
render: (row) => <Text size="sm">{asI18n(formatType(row.requestType))}</Text>,
},
{
key: 'dates',
label: m.stays_requested_dates(),
render: (row) => (
<Text size="sm">
{asI18n(
`${formatDate(row.requestedStartAt)} - ${formatDate(row.requestedEndAt)}`,
)}
</Text>
),
},
{
key: 'status',
label: m.stays_status(),
render: (row) => <StatusBadge status={row.status} />,
},
...(canReview
? [
{
key: 'actions',
label: m.stays_actions(),
render: (row: StayRequest) =>
row.status === 'submitted' || row.status === 'under_review' ? (
<StayRequestActions requestId={row.requestId} />
) : null,
},
]
: []),
]
const checkedInStays = stays.filter((s) => s.status === 'checked_in')
const checkedInColumns: Column<Stay>[] = [
{
key: 'userId',
label: m.stays_guest(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userDisplayName || 'Guest')}
</Text>
),
},
{
key: 'stayType',
label: m.stays_type(),
render: (row) => <Text size="sm">{asI18n(formatType(row.stayType))}</Text>,
},
{
key: 'startAt',
label: m.stays_check_in_date(),
render: (row) => <Text size="sm">{asI18n(formatDate(row.startAt))}</Text>,
},
{
key: 'endAt',
label: m.stays_expected_checkout(),
render: (row) => <Text size="sm">{asI18n(formatDate(row.endAt))}</Text>,
},
{
key: 'status',
label: m.stays_status(),
render: (row) => <StatusBadge status={row.status} />,
},
...(canManage
? [
{
key: 'actions',
label: m.stays_actions(),
render: (row: Stay) => (
<StayActions stayId={row.stayId} status={row.status} />
),
},
]
: []),
]
return (
<Tabs
value={activeTab}
onChange={(val) => {
setActiveTab(val || 'active')
window.history.replaceState(null, '', `#${val || 'active'}`)
}}
>
<Tabs.List>
<Tabs.Tab value="active">{m.stays_active_stays()}</Tabs.Tab>
<Tabs.Tab value="checked_in">
{asI18n(`${m.stays_checked_in()} (${checkedInStays.length})`)}
</Tabs.Tab>
<Tabs.Tab value="requests">{m.stays_requests()}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="active" pt="md">
<DataTable
data={stays}
columns={stayColumns}
rowKey={(row) => row.stayId}
emptyMessage={m.stays_no_active_stays()}
/>
</Tabs.Panel>
<Tabs.Panel value="checked_in" pt="md">
<DataTable
data={checkedInStays}
columns={checkedInColumns}
rowKey={(row) => row.stayId}
emptyMessage={m.stays_no_checked_in()}
/>
</Tabs.Panel>
<Tabs.Panel value="requests" pt="md">
<DataTable
data={stayRequests}
columns={requestColumns}
rowKey={(row) => row.requestId}
emptyMessage={m.stays_no_requests()}
/>
</Tabs.Panel>
</Tabs>
)
}

View File

@@ -0,0 +1,208 @@
import { useState } from 'react'
import { Button, Group, Modal, Textarea } from '@pikku/mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { m } from '@/i18n/messages'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface TaskActionsProps {
taskId: string
status: string
}
export function TaskActions({ taskId, status }: TaskActionsProps) {
const queryClient = useQueryClient()
const [blockOpened, { open: openBlock, close: closeBlock }] = useDisclosure()
const [blockReason, setBlockReason] = useState('')
const [completeOpened, { open: openComplete, close: closeComplete }] =
useDisclosure()
const [completeNotes, setCompleteNotes] = useState('')
// Tasks appear under several listing queries (listTasks, listMyTasks, …);
// invalidate any task-related query after a mutation.
const invalidateTasks = () => {
queryClient.invalidateQueries({
predicate: (q) =>
typeof q.queryKey[0] === 'string' &&
(q.queryKey[0] as string).toLowerCase().includes('task'),
})
}
const onError = (err: Error) => {
notifications.show({
title: m.common_error(),
message: err.message ?? 'Something went wrong',
color: 'red',
})
}
const claimMutation = usePikkuMutation('claimTask', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_claimed(),
color: 'teal',
})
invalidateTasks()
},
onError,
})
const startMutation = usePikkuMutation('startTask', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_started(),
color: 'teal',
})
invalidateTasks()
},
onError,
})
const completeMutation = usePikkuMutation('completeTask', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_completed(),
color: 'teal',
})
closeComplete()
setCompleteNotes('')
invalidateTasks()
},
onError,
})
const blockMutation = usePikkuMutation('markTaskBlocked', {
onSuccess: () => {
notifications.show({
title: m.common_success(),
message: m.tasks_blocked(),
color: 'teal',
})
closeBlock()
setBlockReason('')
invalidateTasks()
},
onError,
})
return (
<>
<Group gap="xs" wrap="nowrap">
{status === 'open' && (
<Button
size="xs"
variant="filled"
color="teal"
loading={claimMutation.isPending}
onClick={() => claimMutation.mutate({ taskId })}
>
{m.tasks_claim()}
</Button>
)}
{status === 'assigned' && (
<Button
size="xs"
variant="filled"
color="blue"
loading={startMutation.isPending}
onClick={() => startMutation.mutate({ taskId })}
>
{m.tasks_start()}
</Button>
)}
{status === 'in_progress' && (
<Button
size="xs"
variant="filled"
color="green"
loading={completeMutation.isPending}
onClick={openComplete}
>
{m.tasks_complete()}
</Button>
)}
{(status === 'open' ||
status === 'assigned' ||
status === 'in_progress') && (
<Button
size="xs"
variant="light"
color="red"
loading={blockMutation.isPending}
onClick={openBlock}
>
{m.tasks_block()}
</Button>
)}
</Group>
<Modal
opened={blockOpened}
onClose={closeBlock}
title={m.tasks_block_title()}
centered
>
<Textarea
label={m.tasks_reason()}
placeholder={m.tasks_reason_placeholder()}
value={blockReason}
onChange={(e) => setBlockReason(e.currentTarget.value)}
minRows={3}
mb="md"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeBlock}>
{m.common_cancel()}
</Button>
<Button
color="red"
loading={blockMutation.isPending}
onClick={() => {
if (blockReason.trim())
blockMutation.mutate({ taskId, reason: blockReason })
}}
>
{m.tasks_mark_blocked()}
</Button>
</Group>
</Modal>
<Modal
opened={completeOpened}
onClose={closeComplete}
title={m.tasks_complete_title()}
centered
>
<Textarea
label={m.tasks_notes_optional()}
placeholder={m.tasks_notes_placeholder()}
value={completeNotes}
onChange={(e) => setCompleteNotes(e.currentTarget.value)}
minRows={3}
mb="md"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeComplete}>
{m.common_cancel()}
</Button>
<Button
color="green"
loading={completeMutation.isPending}
onClick={() =>
completeMutation.mutate({
taskId,
...(completeNotes ? { notes: completeNotes } : {}),
})
}
>
{m.tasks_complete()}
</Button>
</Group>
</Modal>
</>
)
}

View File

@@ -0,0 +1,65 @@
import { Select, Group, Avatar, Text } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
interface UserSelectProps {
label?: string
placeholder?: string
value: string | null
onChange: (value: string | null) => void
required?: boolean
searchable?: boolean
clearable?: boolean
}
export function UserSelect({
label,
placeholder,
value,
onChange,
required,
searchable = true,
clearable,
}: UserSelectProps) {
const { data } = usePikkuQuery('listUsers', { limit: 200, offset: 0 })
const users = data?.items ?? []
const options = users.map((u) => ({
value: u.userId,
label: u.displayName ?? u.name ?? u.email,
}))
return (
<Select
label={label ? asI18n(label) : m.user_select_label()}
placeholder={placeholder ? asI18n(placeholder) : undefined}
data={options}
value={value}
onChange={onChange}
required={required}
searchable={searchable}
clearable={clearable}
renderOption={({ option }) => {
const user = users.find((u) => u.userId === option.value)
if (!user) return <Text size="sm">{asI18n(option.label)}</Text>
return (
<Group gap="sm" wrap="nowrap">
<Avatar src={user.avatarUrl} size="sm" radius="xl" color="teal">
{(user.displayName?.[0] || user.name?.[0] || user.email[0]).toUpperCase()}
</Avatar>
<div>
<Text size="sm" fw={500}>
{asI18n(option.label)}
</Text>
<Text size="xs" c="dimmed">
{asI18n(user.email)}
</Text>
</div>
</Group>
)
}}
/>
)
}

101
apps/app/src/i18n/config.ts Normal file
View File

@@ -0,0 +1,101 @@
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
// message functions (`m`).
//
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
import { useSyncExternalStore } from 'react'
import { locales, baseLocale, overwriteGetLocale, getLocale } from '../paraglide/runtime.js'
export const supportedLocales = locales
export const defaultLocale = baseLocale
export type Locale = (typeof locales)[number]
// `getLocale()` reads the active locale (via the overwrite below) — used by
// pages that format dates/values for the current language.
export { getLocale }
const LANGUAGE_STORAGE_KEY = 'app-locale'
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
const normalizeLanguage = (value: string | null | undefined): Locale | undefined => {
const short = value?.slice(0, 2).toLowerCase()
return supportedLocales.includes(short as Locale) ? (short as Locale) : undefined
}
// Initial locale: persisted choice → browser → <html lang> → base. (Ported from
// the old i18next init; germantax has no in-app switcher, locale is read once.)
export function detectInitialLocale(): Locale {
if (typeof window === 'undefined') return defaultLocale
return (
normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)) ??
normalizeLanguage(window.navigator.language) ??
normalizeLanguage(window.document.documentElement.lang) ??
defaultLocale
)
}
// ── reactive locale store ────────────────────────────────────────────────────
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
// it to a tiny external store: `getLocale` reads `activeLocale`; components
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
let activeLocale: Locale = defaultLocale
const listeners = new Set<() => void>()
overwriteGetLocale(() => activeLocale)
export function setActiveLocale(next: Locale): void {
if (next === activeLocale) return
activeLocale = next
// Persist + reflect on <html lang> (ported from the old i18next languageChanged
// handler), so a reload restores the choice.
if (typeof window !== 'undefined') {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next)
window.document.documentElement.lang = next
}
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// Resolve the initial locale once at startup (import side-effect, matching the
// old i18n.ts init). Importing this module from the app entry runs it.
activeLocale = detectInitialLocale()
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
// used to live; it also returns the active locale + direction for components
// that need them (e.g. the language switcher).
export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } {
const locale = useSyncExternalStore<Locale>(subscribe, () => activeLocale, () => activeLocale)
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
}
// ── i18n-debug masking ───────────────────────────────────────────────────────
// When enabled, every *translated* string is masked to block glyphs (█) so any
// readable text left on screen is text that never went through a message (a
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
export function isI18nDebug(): boolean {
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
return window.localStorage?.getItem('i18n-debug') === '1'
}
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
export function maskI18n(s: string): string {
return isI18nDebug() ? s.replace(/\S/g, '█') : s
}

View File

@@ -0,0 +1,12 @@
// Single source of truth for turning an i18next dotted key (`landing.cards.x`)
// into a Paraglide snake_case message identifier (`landing_cards_x`). Used by the
// runtime resolver `mKey` for the unavoidable dynamic cases.
export const identOf = (dotPath: string): string =>
dotPath
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.replace(/[.\-\s]+/g, '_')
.replace(/[^A-Za-z0-9_]/g, '')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase()

View File

@@ -0,0 +1,54 @@
// Typed message functions for the app. `m.landing_title()` returns a branded
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
// `t('landing.title')` used to — no call-site boilerplate.
//
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
// per-message tree-shaking — fine here: locale files are KB and the app bundled
// all of en.json under i18next anyway.
import { m as _m } from '../paraglide/messages.js'
import type { I18nString } from '@pikku/react'
import { identOf } from './ident.js'
import { maskI18n } from './config.js'
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
const _wrapped: Record<string, unknown> = {}
for (const key of Object.keys(_raw)) {
const fn = _raw[key]
_wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn
}
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
export const m = _wrapped as unknown as Branded<typeof _m>
/**
* Runtime key resolver for computed keys — `mKey(`landing.cards.${k}.title`)` or
* `mKey(MAP[k])`. Flattens the dotted key to its snake_case token and calls the
* message. Returns the raw key (and warns) on a miss, mirroring i18next's
* graceful degradation. Prefer static `m.token(...)` wherever the key is known.
*/
export const mKey = (key: string, args?: Record<string, unknown>): I18nString => {
const fn = _raw[identOf(key)]
if (!fn) {
if (typeof console !== 'undefined') console.warn(`[i18n] missing message for key "${key}"`)
return maskI18n(key) as unknown as I18nString
}
return maskI18n(fn(args)) as unknown as I18nString
}
/**
* Resolves an i18next `returnObjects` array (stored as indexed keys `prefix.0`,
* `prefix.1`, …) back into a list. Returns messages until the first gap.
*/
export const mList = (keyPrefix: string, args?: Record<string, unknown>): I18nString[] => {
const out: I18nString[] = []
for (let i = 0; ; i++) {
const fn = _raw[identOf(`${keyPrefix}.${i}`)]
if (!fn) break
out.push(maskI18n(fn(args)) as unknown as I18nString)
}
return out
}

View File

@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react'
export interface SessionUser {
id: string
email?: string | null
name?: string | null
displayName?: string | null
memberRoles?: string | string[] | null
avatarUrl?: string | null
mobileNumber?: string | null
whatsappNumber?: string | null
}
export const AuthContext = createContext<SessionUser | null>(null)
export function useSessionUser(): SessionUser | null {
return useContext(AuthContext)
}

20
apps/app/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createAuthClient } from 'better-auth/client'
import { apiUrl } from './env'
// Better Auth mounts at /api/auth (default basePath); the client appends the
// endpoint verbatim to baseURL, so the base must include /auth — otherwise it
// emits /api/sign-in/email and 404s on both sandbox and deploy.
export const authClient = createAuthClient({
baseURL: `${apiUrl()}/auth`,
})
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
export async function signIn(email: string, password: string): Promise<void> {
const { error } = await authClient.signIn.email({ email, password })
if (error) throw new Error(INVALID_CREDENTIALS)
}
export async function signOut(): Promise<void> {
await authClient.signOut()
}

21
apps/app/src/lib/env.ts Normal file
View File

@@ -0,0 +1,21 @@
// Endpoints come from env, never hardcoded.
//
// In local dev VITE_API_URL is set at build time and Vite replaces the
// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is
// a runtime Worker binding — invisible to Vite at build time — so it's undefined
// in the bundle. We fall back to the current origin + /api, which is correct
// because the dispatcher routes every /api/* path to the API on the same host.
export function apiUrl(): string {
if (typeof window !== 'undefined' && (window as any).__E2E_API_URL) {
return (window as any).__E2E_API_URL as string
}
if (import.meta.env.SSR) {
// SSR context: the auth client is only consumed in the browser. Return the
// build-time var when available or a placeholder so SSR never throws.
return import.meta.env.VITE_API_URL ?? '/__api'
}
// Client: build-time var (local dev) or derive from the current origin.
// Better Auth's client requires an absolute base URL, so never return a bare
// relative '/api' here — '/api/auth' throws "Invalid base URL".
return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`
}

View File

@@ -0,0 +1,21 @@
import { ROLE_PERMISSIONS, hasPermission } from '@perauset/functions/src/lib/roles.js'
import { useSessionUser } from './auth-context'
export { ROLE_PERMISSIONS, hasPermission }
export function useRoles(): string[] {
const raw = useSessionUser()?.memberRoles
if (raw == null) return []
if (Array.isArray(raw)) return raw
try {
return JSON.parse(raw || '[]')
} catch {
return []
}
}
export function usePermission(permission: string): boolean {
const roles = useRoles()
if (roles.length === 0) return false
return hasPermission(roles, permission)
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react'
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MantineProvider } from '@pikku/mantine/core'
import { Notifications } from '@mantine/notifications'
import { PikkuProvider, createPikku } from '@pikku/react'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { PikkuFetch } from '@perauset/functions-sdk/pikku/pikku-fetch.gen'
import { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen'
import { useLocale } from '../i18n/config'
import { theme } from '@perauset/mantine-theme'
declare global {
interface Window {
__E2E_API_URL?: string
}
}
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'PerAuset' },
],
}),
component: RootDocument,
})
function RootDocument() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
}),
)
const runtimeApiUrl =
typeof window !== 'undefined' ? window.__E2E_API_URL : undefined
const [pikku] = useState(() =>
createPikku(PikkuFetch, PikkuRPC, {
transformDate: true,
serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
credentials: 'include',
}),
)
// Reactive locale from the Paraglide store (persists to localStorage +
// reflects <html lang> itself; initial detection runs on import).
const { locale, dir } = useLocale()
return (
<html lang={locale} dir={dir}>
<head>
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={theme}>
<Notifications />
<PikkuProvider pikku={pikku}>
<Outlet />
</PikkuProvider>
</MantineProvider>
</QueryClientProvider>
<Scripts />
</body>
</html>
)
}

View File

@@ -0,0 +1,128 @@
import { createFileRoute } from '@tanstack/react-router'
import { Stack, Badge, Code, Loader, Center, Text } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/audit-log')({
component: AuditLogPage,
})
const actionColor: Record<string, string> = {
INSERT: 'green',
UPDATE: 'blue',
DELETE: 'red',
}
function formatTimestamp(ts: string): string {
return new Date(ts).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface AuditEntry {
auditId: string
occurredAt: string
action: string
tableName: string
recordId: string
userId: string | null
changedFields: Record<string, unknown> | null
}
function AuditLogPage() {
useLocale()
const { data, isLoading } = usePikkuQuery('listAuditLog', {
limit: 50,
offset: 0,
})
const entries = (data?.items ?? []) as AuditEntry[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<AuditEntry>[] = [
{
key: 'occurredAt',
label: m.audit_timestamp(),
render: (entry) => <Text size="sm">{asI18n(formatTimestamp(entry.occurredAt))}</Text>,
},
{
key: 'action',
label: m.audit_action(),
render: (entry) => (
<Badge color={actionColor[entry.action] ?? 'gray'} variant="light" size="sm">
{asI18n(entry.action)}
</Badge>
),
},
{
key: 'tableName',
label: m.audit_table(),
render: (entry) => (
<Text size="sm" ff="monospace">
{asI18n(entry.tableName)}
</Text>
),
},
{
key: 'recordId',
label: m.audit_record_id(),
render: (entry) => (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
{asI18n(entry.recordId)}
</Text>
),
},
{
key: 'userId',
label: m.audit_user(),
render: (entry) => (
<Text size="xs" c="dimmed" lineClamp={1}>
{asI18n(entry.userId ?? '--')}
</Text>
),
},
{
key: 'changedFields',
label: m.audit_changed_fields(),
render: (entry) =>
entry.changedFields ? (
<Code block style={{ fontSize: 11, maxWidth: 300 }}>
{JSON.stringify(entry.changedFields, null, 2)}
</Code>
) : (
<Text size="xs" c="dimmed">
{asI18n('--')}
</Text>
),
},
]
return (
<Stack gap="lg">
<PageHeader title={m.audit_title()} description={m.audit_description()} />
<DataTable
data={entries}
columns={columns}
rowKey={(entry) => entry.auditId}
emptyMessage="No audit log entries found."
/>
</Stack>
)
}

View File

@@ -0,0 +1,394 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Stack,
SimpleGrid,
Card,
Text,
Group,
Loader,
Center,
ActionIcon,
Tooltip,
Drawer,
Textarea,
Button,
Select,
TextInput,
} from '@pikku/mantine/core'
import { Sailboat, Play, Ban, Check, Plus } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/')({
component: BoatsPage,
})
type BoatTrip = {
tripId: string
boatId: string | null
routeId: string
status: string
scheduledDepartureAt: string | Date
scheduledArrivalAt: string | Date | null
notes: string | null
}
function formatDateTime(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return String(dateStr)
}
}
function BoatActions({ onCreateTrip }: { onCreateTrip?: () => void }) {
const canManage = usePermission('boats.manage')
return (
<Group gap="sm">
<Button component={Link} to="/boats/request" variant="light" color="teal" size="sm">
{m.boats_request_seat()}
</Button>
{canManage && onCreateTrip && (
<Button onClick={onCreateTrip} leftSection={<Plus size={16} />} color="teal" size="sm">
{m.boats_create_trip()}
</Button>
)}
</Group>
)
}
function TripActions({ trip }: { trip: BoatTrip }) {
const queryClient = useQueryClient()
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['listBoatTrips'] })
const openMutation = usePikkuMutation('openBoatTrip', {
onSuccess: () => {
invalidate()
notifications.show({ title: 'Trip opened', message: 'Boat trip is now open for seat requests.', color: 'green' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to open trip.', color: 'red' }),
})
const cancelMutation = usePikkuMutation('cancelBoatTrip', {
onSuccess: () => {
invalidate()
notifications.show({ title: 'Trip cancelled', message: 'Boat trip has been cancelled.', color: 'orange' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to cancel trip.', color: 'red' }),
})
const completeMutation = usePikkuMutation('completeBoatTrip', {
onSuccess: () => {
invalidate()
notifications.show({ title: 'Trip completed', message: 'Boat trip has been marked as completed.', color: 'green' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to complete trip.', color: 'red' }),
})
const canOpen = trip.status === 'planned'
const canComplete = trip.status === 'open' || trip.status === 'in_progress'
const canCancel = trip.status === 'planned' || trip.status === 'open'
if (!canOpen && !canComplete && !canCancel) return null
return (
<Group gap="xs" onClick={(e) => e.preventDefault()}>
{canOpen && (
<Tooltip label={m.boats_open_for_requests()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => openMutation.mutate({ tripId: trip.tripId })}
loading={openMutation.isPending}
>
<Play size={14} />
</ActionIcon>
</Tooltip>
)}
{canComplete && (
<Tooltip label={m.boats_complete_trip()}>
<ActionIcon
variant="light"
color="green"
size="sm"
onClick={() => completeMutation.mutate({ tripId: trip.tripId })}
loading={completeMutation.isPending}
>
<Check size={14} />
</ActionIcon>
</Tooltip>
)}
{canCancel && (
<Tooltip label={m.boats_cancel_trip()}>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => cancelMutation.mutate({ tripId: trip.tripId })}
loading={cancelMutation.isPending}
>
<Ban size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
)
}
function CreateTripDrawer({
opened,
onClose,
routeOptions,
boatOptions,
}: {
opened: boolean
onClose: () => void
routeOptions: Array<{ value: string; label: string }>
boatOptions: Array<{ value: string; label: string }>
}) {
const queryClient = useQueryClient()
const [routeId, setRouteId] = useState<string | null>(null)
const [boatId, setBoatId] = useState<string | null>(null)
const [scheduledDepartureAt, setScheduledDepartureAt] = useState('')
const [scheduledArrivalAt, setScheduledArrivalAt] = useState('')
const [notes, setNotes] = useState('')
const resetForm = () => {
setRouteId(null)
setBoatId(null)
setScheduledDepartureAt('')
setScheduledArrivalAt('')
setNotes('')
}
const mutation = usePikkuMutation('createBoatTrip', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoatTrips'] })
notifications.show({ title: 'Trip created', message: 'Boat trip has been created successfully.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create boat trip.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!routeId || !scheduledDepartureAt) {
notifications.show({ title: 'Missing fields', message: 'Route and departure time are required.', color: 'red' })
return
}
mutation.mutate({
routeId,
boatId: boatId || undefined,
scheduledDepartureAt: new Date(scheduledDepartureAt).toISOString(),
scheduledArrivalAt: scheduledArrivalAt ? new Date(scheduledArrivalAt).toISOString() : undefined,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_create_trip()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Select
label={m.boats_route()}
placeholder={m.boats_select_route()}
data={routeOptions}
value={routeId}
onChange={setRouteId}
searchable
required
nothingFoundMessage={m.boats_no_routes_available()}
/>
<Select
label={m.boats_boat()}
placeholder={m.boats_select_boat_optional()}
data={boatOptions}
value={boatId}
onChange={setBoatId}
searchable
clearable
nothingFoundMessage={m.boats_no_boats_available()}
/>
<TextInput
label={m.boats_scheduled_departure()}
type="datetime-local"
value={scheduledDepartureAt}
onChange={(e) => setScheduledDepartureAt(e.currentTarget.value)}
required
/>
<TextInput
label={m.boats_scheduled_arrival()}
type="datetime-local"
value={scheduledArrivalAt}
onChange={(e) => setScheduledArrivalAt(e.currentTarget.value)}
min={scheduledDepartureAt || undefined}
/>
<Textarea
label={m.boats_notes()}
placeholder={m.boats_additional_information()}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={3}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.boats_create_trip()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function BoatsPage() {
useLocale()
const canManage = usePermission('boats.manage')
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const { data, isLoading } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const { data: boatsData } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
const trips = data?.items ?? []
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
const boatMap = new Map((boatsData?.items ?? []).map((b) => [b.boatId, b]))
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.boats_trips_title()}
description={m.boats_trips_description()}
action={<BoatActions onCreateTrip={() => setCreateDrawerOpen(true)} />}
/>
{trips.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{ backgroundColor: '#ffffff', boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)' }}
>
<Text size="sm" c="dimmed" ta="center">
{m.boats_no_trips()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{trips.map((trip) => {
const route = routeMap.get(trip.routeId)
const boat = trip.boatId ? boatMap.get(trip.boatId) : null
const routeLabel = route ? `${route.name} (${route.origin} - ${route.destination})` : 'Route'
const boatLabel = boat ? boat.name : null
return (
<Card
key={trip.tripId}
component={Link}
to="/boats/trips/$tripId"
params={{ tripId: trip.tripId } as never}
padding="lg"
radius="md"
className="card-hover"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textDecoration: 'none',
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Group justify="space-between" align="flex-start" mb="sm">
<Group gap="sm">
<Sailboat size={20} color="#2A7B88" />
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(routeLabel)}
</Text>
</Group>
<Group gap="xs">
<StatusBadge status={trip.status} />
{canManage && <TripActions trip={trip} />}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.boats_departure()}
</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledDepartureAt))}</Text>
</div>
{trip.scheduledArrivalAt && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.boats_arrival()}
</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledArrivalAt))}</Text>
</div>
)}
</Group>
{boatLabel && (
<Text size="xs" c="dimmed" mt="sm">
{asI18n(`${m.boats_boat()}: ${boatLabel}`)}
</Text>
)}
{trip.boatId && !boatLabel && (
<Text size="xs" c="dimmed" mt="sm">
{asI18n(`${m.boats_boat()}: ${m.boats_assigned()}`)}
</Text>
)}
{trip.notes && (
<Text size="xs" c="dimmed" mt="xs" lineClamp={2}>
{asI18n(trip.notes)}
</Text>
)}
</Card>
)
})}
</SimpleGrid>
)}
<CreateTripDrawer
opened={createDrawerOpen}
onClose={() => setCreateDrawerOpen(false)}
routeOptions={(routesData?.items ?? []).map((r) => ({
value: r.routeId,
label: `${r.name} (${r.origin} - ${r.destination})`,
}))}
boatOptions={(boatsData?.items ?? []).map((b) => ({
value: b.boatId,
label: `${b.name} (${b.passengerCapacity} seats)`,
}))}
/>
</Stack>
)
}

View File

@@ -0,0 +1,450 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import {
Stack,
SimpleGrid,
Card,
Text,
Group,
Badge,
Loader,
Center,
Drawer,
TextInput,
NumberInput,
Textarea,
Button,
ActionIcon,
Tooltip,
Tabs,
Switch,
} from '@pikku/mantine/core'
import { Pencil, Plus, Sailboat, Route as RouteIcon } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/manage')({
component: BoatManagePage,
})
type Boat = {
boatId: string
name: string
passengerCapacity: number
cargoCapacityKg: number | null
isActive: boolean
notes: string | null
}
type BoatRoute = {
routeId: string
name: string
origin: string
destination: string
isActive: boolean
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
function CreateBoatDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [passengerCapacity, setPassengerCapacity] = useState<number>(1)
const [cargoCapacityKg, setCargoCapacityKg] = useState<number | undefined>(undefined)
const [notes, setNotes] = useState('')
const resetForm = () => {
setName('')
setPassengerCapacity(1)
setCargoCapacityKg(undefined)
setNotes('')
}
const mutation = usePikkuMutation('createBoat', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoats'] })
notifications.show({ title: 'Boat created', message: 'New boat has been added to the fleet.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create boat.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name) {
notifications.show({ title: 'Missing fields', message: 'Name is required.', color: 'red' })
return
}
mutation.mutate({
name,
passengerCapacity,
cargoCapacityKg: cargoCapacityKg || undefined,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_add_boat()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_boat_name()} placeholder={m.boats_boat_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<NumberInput label={m.boats_passenger_capacity()} value={passengerCapacity} onChange={(val) => setPassengerCapacity(Number(val) || 1)} min={1} />
<NumberInput label={m.boats_cargo_capacity()} placeholder={m.boats_cargo_capacity_placeholder()} value={cargoCapacityKg} onChange={(val) => setCargoCapacityKg(val ? Number(val) : undefined)} min={0} suffix={asI18n(' kg')} />
<Textarea label={m.boats_notes()} placeholder={m.boats_boat_notes_placeholder()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>{m.boats_save_boat()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditBoatDrawer({ boat, onClose }: { boat: Boat | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(boat?.name ?? '')
const [passengerCapacity, setPassengerCapacity] = useState<number>(boat?.passengerCapacity ?? 1)
const [cargoCapacityKg, setCargoCapacityKg] = useState<number | undefined>(boat?.cargoCapacityKg ?? undefined)
const [isActive, setIsActive] = useState(boat?.isActive ?? true)
const [notes, setNotes] = useState(boat?.notes ?? '')
useEffect(() => {
if (boat) {
setName(boat.name)
setPassengerCapacity(boat.passengerCapacity)
setCargoCapacityKg(boat.cargoCapacityKg ?? undefined)
setIsActive(boat.isActive)
setNotes(boat.notes ?? '')
}
}, [boat])
const mutation = usePikkuMutation('updateBoat', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoats'] })
notifications.show({ title: 'Boat updated', message: 'Boat details have been saved.', color: 'green' })
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update boat.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!boat) return
mutation.mutate({
boatId: boat.boatId,
name,
passengerCapacity,
cargoCapacityKg: cargoCapacityKg || undefined,
isActive,
notes: notes || undefined,
})
}
if (!boat) return null
return (
<Drawer position="right" size="md" opened={!!boat} onClose={onClose} title={m.boats_edit_boat()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_boat_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<NumberInput label={m.boats_passenger_capacity()} value={passengerCapacity} onChange={(val) => setPassengerCapacity(Number(val) || 1)} min={1} />
<NumberInput label={m.boats_cargo_capacity()} value={cargoCapacityKg} onChange={(val) => setCargoCapacityKg(val ? Number(val) : undefined)} min={0} suffix={asI18n(' kg')} />
<Switch label={m.boats_active()} checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} color="teal" />
<Textarea label={m.boats_notes()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function CreateRouteDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [origin, setOrigin] = useState('')
const [destination, setDestination] = useState('')
const resetForm = () => {
setName('')
setOrigin('')
setDestination('')
}
const mutation = usePikkuMutation('createBoatRoute', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoatRoutes'] })
notifications.show({ title: 'Route created', message: 'New route has been added.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create route.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !origin || !destination) {
notifications.show({ title: 'Missing fields', message: 'Name, origin, and destination are required.', color: 'red' })
return
}
mutation.mutate({ name, origin, destination })
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_add_route()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_route_name()} placeholder={m.boats_route_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<TextInput label={m.boats_origin()} placeholder={m.boats_origin_placeholder()} value={origin} onChange={(e) => setOrigin(e.currentTarget.value)} required />
<TextInput label={m.boats_destination()} placeholder={m.boats_destination_placeholder()} value={destination} onChange={(e) => setDestination(e.currentTarget.value)} required />
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>{m.boats_save_route()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditRouteDrawer({ route, onClose }: { route: BoatRoute | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(route?.name ?? '')
const [origin, setOrigin] = useState(route?.origin ?? '')
const [destination, setDestination] = useState(route?.destination ?? '')
const [isActive, setIsActive] = useState(route?.isActive ?? true)
useEffect(() => {
if (route) {
setName(route.name)
setOrigin(route.origin)
setDestination(route.destination)
setIsActive(route.isActive)
}
}, [route])
const mutation = usePikkuMutation('updateBoatRoute', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listBoatRoutes'] })
notifications.show({ title: 'Route updated', message: 'Route details have been saved.', color: 'green' })
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update route.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!route) return
mutation.mutate({ routeId: route.routeId, name, origin, destination, isActive })
}
if (!route) return null
return (
<Drawer position="right" size="md" opened={!!route} onClose={onClose} title={m.boats_edit_route()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.boats_route_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<TextInput label={m.boats_origin()} value={origin} onChange={(e) => setOrigin(e.currentTarget.value)} required />
<TextInput label={m.boats_destination()} value={destination} onChange={(e) => setDestination(e.currentTarget.value)} required />
<Switch label={m.boats_active()} checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} color="teal" />
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function BoatManagePage() {
useLocale()
const canManage = usePermission('boats.manage')
const [activeTab, setActiveTab] = useState<string | null>('boats')
const [createBoatOpen, setCreateBoatOpen] = useState(false)
const [editingBoat, setEditingBoat] = useState<Boat | null>(null)
const [createRouteOpen, setCreateRouteOpen] = useState(false)
const [editingRoute, setEditingRoute] = useState<BoatRoute | null>(null)
useEffect(() => {
const hash = window.location.hash.replace('#', '')
if (hash === 'boats' || hash === 'routes') {
setActiveTab(hash)
}
}, [])
const handleTabChange = (value: string | null) => {
setActiveTab(value)
if (value) {
window.history.replaceState(null, '', `#${value}`)
}
}
const { data: boatsData, isLoading: boatsLoading } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
const { data: routesData, isLoading: routesLoading } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const boats = boatsData?.items ?? []
const routes = routesData?.items ?? []
return (
<Stack gap="lg">
<PageHeader
title={m.boats_fleet_management()}
description={m.boats_fleet_description()}
action={
canManage ? (
<Group gap="sm">
{activeTab === 'boats' && (
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setCreateBoatOpen(true)}>
{m.boats_add_boat()}
</Button>
)}
{activeTab === 'routes' && (
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setCreateRouteOpen(true)}>
{m.boats_add_route()}
</Button>
)}
</Group>
) : undefined
}
/>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab value="boats" leftSection={<Sailboat size={16} />}>
{m.boats_boats()}
</Tabs.Tab>
<Tabs.Tab value="routes" leftSection={<RouteIcon size={16} />}>
{m.boats_routes()}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="boats" pt="md">
{boatsLoading ? (
<Center py="xl">
<Loader color="teal" />
</Center>
) : boats.length === 0 ? (
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.boats_no_boats()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
{boats.map((boat) => (
<Card key={boat.boatId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
<Group justify="space-between" align="flex-start" mb="sm">
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(boat.name)}
</Text>
<Group gap="xs">
<Badge color={boat.isActive ? 'green' : 'gray'} variant="light" size="sm">
{boat.isActive ? m.boats_active() : m.boats_inactive()}
</Badge>
{canManage && (
<Tooltip label={m.boats_edit_boat()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingBoat(boat)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_passengers()}</Text>
<Text size="sm">{boat.passengerCapacity}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_cargo_kg()}</Text>
<Text size="sm">{asI18n(boat.cargoCapacityKg != null ? String(boat.cargoCapacityKg) : '---')}</Text>
</div>
</Group>
{boat.notes && (
<Text size="xs" c="dimmed" mt="sm" lineClamp={2}>
{asI18n(boat.notes)}
</Text>
)}
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
<Tabs.Panel value="routes" pt="md">
{routesLoading ? (
<Center py="xl">
<Loader color="teal" />
</Center>
) : routes.length === 0 ? (
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.boats_no_routes()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
{routes.map((route) => (
<Card key={route.routeId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
<Group justify="space-between" align="flex-start" mb="sm">
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(route.name)}
</Text>
<Group gap="xs">
<Badge color={route.isActive ? 'green' : 'gray'} variant="light" size="sm">
{route.isActive ? m.boats_active() : m.boats_inactive()}
</Badge>
{canManage && (
<Tooltip label={m.boats_edit_route()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingRoute(route)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_origin()}</Text>
<Text size="sm">{asI18n(route.origin)}</Text>
</div>
<div>
<Text size="sm" c="dimmed">{asI18n(' -> ')}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_destination()}</Text>
<Text size="sm">{asI18n(route.destination)}</Text>
</div>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
</Tabs>
<CreateBoatDrawer opened={createBoatOpen} onClose={() => setCreateBoatOpen(false)} />
<EditBoatDrawer boat={editingBoat} onClose={() => setEditingBoat(null)} />
<CreateRouteDrawer opened={createRouteOpen} onClose={() => setCreateRouteOpen(false)} />
<EditRouteDrawer route={editingRoute} onClose={() => setEditingRoute(null)} />
</Stack>
)
}

View File

@@ -0,0 +1,128 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Stack, Card, Select, Textarea, NumberInput, Button, Group } from '@pikku/mantine/core'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/request')({
validateSearch: (search: Record<string, unknown>): { tripId?: string } => ({
tripId: typeof search.tripId === 'string' ? search.tripId : undefined,
}),
component: RequestBoatSeatPage,
})
function RequestBoatSeatPage() {
useLocale()
const navigate = useNavigate()
const { tripId: preselectedTripId } = Route.useSearch()
const [tripId, setTripId] = useState<string | null>(preselectedTripId ?? null)
const [requestedSeats, setRequestedSeats] = useState<number>(1)
const [cargoNotes, setCargoNotes] = useState('')
const [specialRequirements, setSpecialRequirements] = useState('')
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const { data: tripsData } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
const tripOptions = (tripsData?.items ?? [])
.filter((t) => t.status === 'open')
.map((t) => {
const route = routeMap.get(t.routeId)
const routeLabel = route ? `${route.name} (${route.origin} - ${route.destination})` : 'Trip'
const dateLabel = new Date(t.scheduledDepartureAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return { value: t.tripId, label: `${routeLabel} - ${dateLabel}` }
})
const mutation = usePikkuMutation('requestBoatSeat', {
onSuccess: () => {
notifications.show({ title: 'Seat requested', message: 'Your seat request has been submitted.', color: 'green' })
navigate({ to: '/boats' })
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Something went wrong.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!tripId) {
notifications.show({ title: 'Missing fields', message: 'Please select a trip.', color: 'red' })
return
}
mutation.mutate({
tripId,
requestedSeats,
cargoNotes: cargoNotes || undefined,
specialRequirements: specialRequirements || undefined,
})
}
return (
<Stack gap="lg">
<PageHeader title={m.boats_request_seat_title()} description={m.boats_request_seat_description()} />
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 560,
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Select
label={m.boats_trip()}
placeholder={m.boats_select_trip()}
data={tripOptions}
value={tripId}
onChange={setTripId}
required
searchable
nothingFoundMessage={asI18n(tripOptions.length === 0 ? m.boats_no_open_trips() : m.boats_no_matching_trips())}
/>
<NumberInput
label={m.boats_number_of_seats()}
value={requestedSeats}
onChange={(val) => setRequestedSeats(Number(val) || 1)}
min={1}
max={10}
required
/>
<Textarea
label={m.boats_cargo_notes()}
placeholder={m.boats_describe_cargo()}
value={cargoNotes}
onChange={(e) => setCargoNotes(e.currentTarget.value)}
minRows={2}
/>
<Textarea
label={m.boats_special_requirements()}
placeholder={m.boats_accessibility_needs()}
value={specialRequirements}
onChange={(e) => setSpecialRequirements(e.currentTarget.value)}
minRows={2}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" component={Link} to="/boats">
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.boats_request_seat()}
</Button>
</Group>
</Stack>
</form>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,306 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import {
Stack,
Card,
Text,
Group,
Button,
Loader,
Center,
Table,
Title,
Badge,
ActionIcon,
} from '@pikku/mantine/core'
import { Printer, Check, X } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { usePermission } from '@/lib/permissions'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/boats/trips/$tripId')({
component: TripDetailPage,
})
type SeatRequest = {
requestId: string
tripId: string
userId: string
status: string
requestedSeats: number
cargoNotes: string | null
specialRequirements: string | null
priorityReason: string | null
createdAt: string | Date
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
function formatDateTime(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return String(dateStr)
}
}
function formatDate(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return String(dateStr)
}
}
function RequestActions({ requestId }: { requestId: string }) {
const queryClient = useQueryClient()
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['listBoatRequests'] })
const confirmMutation = usePikkuMutation('confirmBoatRequest', { onSuccess: invalidate })
const declineMutation = usePikkuMutation('declineBoatRequest', { onSuccess: invalidate })
return (
<Group gap="xs">
<ActionIcon
variant="light"
color="green"
size="sm"
onClick={() => confirmMutation.mutate({ requestId })}
loading={confirmMutation.isPending}
aria-label={m.boats_confirm()}
>
<Check size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => declineMutation.mutate({ requestId })}
loading={declineMutation.isPending}
aria-label={m.boats_decline()}
>
<X size={14} />
</ActionIcon>
</Group>
)
}
function SeatRequestsSection({ seatRequests }: { seatRequests: SeatRequest[] }) {
const canManage = usePermission('boats.manage')
const columns: Column<SeatRequest>[] = [
{
key: 'userId',
label: m.boats_user(),
render: (row) => (
<Text size="sm" truncate>
{asI18n(row.userId)}
</Text>
),
},
{
key: 'seats',
label: m.boats_seats(),
render: (row) => <Text size="sm">{row.requestedSeats}</Text>,
},
{
key: 'status',
label: m.boats_status(),
render: (row) => <StatusBadge status={row.status} />,
},
{
key: 'createdAt',
label: m.boats_requested(),
render: (row) => <Text size="sm">{asI18n(formatDate(row.createdAt))}</Text>,
},
{
key: 'cargo',
label: m.boats_cargo(),
render: (row) => (
<Text size="sm" c="dimmed" lineClamp={1}>
{asI18n(row.cargoNotes ?? '-')}
</Text>
),
},
...(canManage
? [
{
key: 'actions',
label: m.boats_actions(),
render: (row: SeatRequest) =>
row.status === 'submitted' || row.status === 'under_review' ? <RequestActions requestId={row.requestId} /> : null,
},
]
: []),
]
return (
<Stack gap="sm">
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.boats_seat_requests()}
</Title>
<DataTable
data={seatRequests}
columns={columns}
rowKey={(row) => row.requestId}
emptyMessage="No seat requests for this trip."
/>
</Stack>
)
}
function TripDetailPage() {
useLocale()
const { tripId } = Route.useParams()
const { data: tripsData, isLoading: tripsLoading } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
const { data: boatsData } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
const boatMap = new Map((boatsData?.items ?? []).map((b) => [b.boatId, b]))
const { data: manifestData } = usePikkuQuery('getBoatManifest', { tripId }, { enabled: !!tripId })
const { data: requestsData } = usePikkuQuery('listBoatRequests', { tripId, limit: 50, offset: 0 }, { enabled: !!tripId })
const trip = (tripsData?.items ?? []).find((t) => t.tripId === tripId) ?? null
const seatRequests = (requestsData?.items ?? []) as SeatRequest[]
const passengers = manifestData?.passengers ?? []
if (tripsLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
if (!trip) {
return (
<Stack gap="lg">
<PageHeader title={m.boats_trip_not_found()} />
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.boats_trip_not_found_message()}
</Text>
</Card>
</Stack>
)
}
const route = routeMap.get(trip.routeId)
return (
<Stack gap="lg">
<PageHeader
title={m.boats_trip_details()}
description={asI18n(route ? `${route.name} (${route.origin} - ${route.destination})` : m.boats_trip_details())}
action={
trip.status === 'open' ? (
<Button component={Link} to="/boats/request" search={{ tripId: trip.tripId } as never} color="teal" size="sm">
{m.boats_request_seat()}
</Button>
) : undefined
}
/>
<Card padding="lg" radius="md" style={cardStyle}>
<Group gap="xl" wrap="wrap">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_status()}</Text>
<StatusBadge status={trip.status} />
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_departure()}</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledDepartureAt))}</Text>
</div>
{trip.scheduledArrivalAt && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_arrival()}</Text>
<Text size="sm">{asI18n(formatDateTime(trip.scheduledArrivalAt))}</Text>
</div>
)}
{trip.boatId && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_boat()}</Text>
<Text size="sm">{asI18n(boatMap.get(trip.boatId)?.name ?? m.boats_unknown_boat())}</Text>
</div>
)}
</Group>
{trip.notes && (
<Text size="sm" c="dimmed" mt="md">
{asI18n(trip.notes)}
</Text>
)}
</Card>
<Card padding="lg" radius="md" style={cardStyle}>
<Group justify="space-between" mb="md">
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.boats_manifest()}
</Title>
<Button variant="light" color="teal" size="xs" leftSection={<Printer size={14} />} onClick={() => window.print()}>
{m.boats_print_manifest()}
</Button>
</Group>
{passengers.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{m.boats_no_confirmed_passengers()}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.boats_name()}</Table.Th>
<Table.Th>{m.boats_seats()}</Table.Th>
<Table.Th>{m.boats_cargo_notes()}</Table.Th>
<Table.Th>{m.boats_special_requirements()}</Table.Th>
<Table.Th>{m.boats_status()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{passengers.map((p) => (
<Table.Tr key={p.requestId}>
<Table.Td>
<Text size="sm" fw={500}>{asI18n(p.displayName || p.email || 'Passenger')}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{p.requestedSeats}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{asI18n(p.cargoNotes || '-')}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{asI18n(p.specialRequirements || '-')}</Text>
</Table.Td>
<Table.Td>
<Badge color="green" variant="light" size="sm">{asI18n(p.status)}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<SeatRequestsSection seatRequests={seatRequests} />
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/boats')({
component: Outlet,
})

View File

@@ -0,0 +1,899 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Center,
Drawer,
TextInput,
NumberInput,
Select,
ActionIcon,
Tooltip,
Textarea,
SimpleGrid,
Table,
} from '@pikku/mantine/core'
import { DollarSign, Plus, Pencil, Link2 } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/finance')({
component: FinancePage,
})
interface FinanceRecord {
financeId: string
name: string
description: string | null
amount: number
amountEgp: number | null
currency: string
recordType: string
purchasedByUserId: string | null
purchasedAt: string
category: string
createdByUserId: string
}
interface FinanceLink {
linkId: string
financeId: string
entityType: string
entityId: string
notes: string | null
}
const RECORD_TYPES = [
{ value: 'expense', label: 'Expense' },
{ value: 'income', label: 'Income' },
{ value: 'reimbursement', label: 'Reimbursement' },
]
const CATEGORIES = [
{ value: 'operations', label: 'Operations' },
{ value: 'kitchen', label: 'Kitchen' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'transport', label: 'Transport' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'housekeeping', label: 'Housekeeping' },
{ value: 'other', label: 'Other' },
]
const ENTITY_TYPES = [
{ value: 'inventory_item', label: 'Inventory Item' },
{ value: 'stay', label: 'Stay' },
{ value: 'boat_trip', label: 'Boat Trip' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'task', label: 'Task' },
]
const recordTypeBadgeColor: Record<string, string> = {
expense: 'red',
income: 'green',
reimbursement: 'blue',
}
const entityTypeBadgeColor: Record<string, string> = {
inventory_item: 'orange',
stay: 'blue',
boat_trip: 'cyan',
retreat: 'grape',
task: 'teal',
}
type Currency = 'EUR' | 'USD' | 'GBP' | 'EGP'
const CURRENCY_OPTIONS = [
{ value: 'EUR', label: 'EUR (€)' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'GBP', label: 'GBP (£)' },
{ value: 'EGP', label: 'EGP (ج.م)' },
]
function AddRecordDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [amount, setAmount] = useState<number>(0)
const [currency, setCurrency] = useState<Currency>('EUR')
const [recordType, setRecordType] = useState<string>('expense')
const [category, setCategory] = useState<string>('operations')
const resetForm = () => {
setName('')
setDescription('')
setAmount(0)
setCurrency('EUR')
setRecordType('expense')
setCategory('operations')
}
const mutation = usePikkuMutation('createFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
notifications.show({
title: 'Record created',
message: 'Finance record has been added.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create record.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !amount) {
notifications.show({
title: 'Missing fields',
message: 'Name and amount are required.',
color: 'red',
})
return
}
mutation.mutate({
name,
description: description || undefined,
amount,
currency,
recordType: recordType as 'expense' | 'income' | 'reimbursement',
category: category as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.finance_add_record()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.finance_name()}
placeholder={asI18n('e.g. Office Supplies, Boat Fuel')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_description()}
placeholder={asI18n('Optional details')}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.finance_amount()}
value={amount}
onChange={(val) => setAmount(Number(val) || 0)}
min={0}
decimalScale={2}
required
/>
<Select
label={m.finance_currency()}
data={CURRENCY_OPTIONS}
value={currency}
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
/>
</Group>
<Group grow>
<Select
label={m.finance_type()}
data={RECORD_TYPES}
value={recordType}
onChange={(val) => setRecordType(val ?? 'expense')}
/>
<Select
label={m.finance_category()}
data={CATEGORIES}
value={category}
onChange={(val) => setCategory(val ?? 'operations')}
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_create_record()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditRecordDrawer({
record,
onClose,
}: {
record: FinanceRecord | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [name, setName] = useState(record?.name ?? '')
const [description, setDescription] = useState(record?.description ?? '')
const [amount, setAmount] = useState<number>(record?.amount ?? 0)
const [currency, setCurrency] = useState<Currency>((record?.currency as Currency) ?? 'EUR')
const [recordType, setRecordType] = useState<string>(record?.recordType ?? 'expense')
const [category, setCategory] = useState<string>(record?.category ?? 'operations')
const mutation = usePikkuMutation('updateFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
notifications.show({
title: 'Record updated',
message: 'Finance record has been updated.',
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to update record.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!record) return
mutation.mutate({
financeId: record.financeId,
name,
description: description || undefined,
amount,
currency,
recordType: recordType as 'expense' | 'income' | 'reimbursement',
category: category as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
})
}
if (!record) return null
return (
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={m.finance_edit_record()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.finance_name()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_description()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.finance_amount()}
value={amount}
onChange={(val) => setAmount(Number(val) || 0)}
min={0}
decimalScale={2}
/>
<Select
label={m.finance_currency()}
data={CURRENCY_OPTIONS}
value={currency}
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
/>
</Group>
<Group grow>
<Select
label={m.finance_type()}
data={RECORD_TYPES}
value={recordType}
onChange={(val) => setRecordType(val ?? 'expense')}
/>
<Select
label={m.finance_category()}
data={CATEGORIES}
value={category}
onChange={(val) => setCategory(val ?? 'operations')}
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_save_changes()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function LinkDrawer({
record,
onClose,
}: {
record: FinanceRecord | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [entityType, setEntityType] = useState<string>('inventory_item')
const [entityId, setEntityId] = useState('')
const [notes, setNotes] = useState('')
const { data: linksData, isLoading: linksLoading } = usePikkuQuery(
'listFinanceLinks',
{ financeId: record?.financeId ?? '', limit: 50, offset: 0 },
{ enabled: !!record },
)
const existingLinks = (linksData?.items ?? []) as FinanceLink[]
const mutation = usePikkuMutation('linkFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceLinks'] })
notifications.show({
title: 'Link created',
message: 'Entity has been linked to this finance record.',
color: 'green',
})
setEntityId('')
setNotes('')
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create link.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!record || !entityId) {
notifications.show({
title: 'Missing fields',
message: 'Entity ID is required.',
color: 'red',
})
return
}
mutation.mutate({
financeId: record.financeId,
entityType: entityType as
| 'inventory_item'
| 'inventory_request'
| 'stay'
| 'boat_trip'
| 'retreat'
| 'task'
| 'room',
entityId,
notes: notes || undefined,
})
}
if (!record) return null
return (
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={asI18n(`Link: ${record.name}`)}>
<Stack gap="md">
{linksLoading ? (
<Center py="sm">
<Loader color="teal" size="sm" />
</Center>
) : existingLinks.length > 0 ? (
<div>
<Text size="sm" fw={600} mb="xs">
{m.finance_existing_links()}
</Text>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.finance_type()}</Table.Th>
<Table.Th>{m.finance_reference()}</Table.Th>
<Table.Th>{m.finance_notes()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{existingLinks.map((link) => (
<Table.Tr key={link.linkId}>
<Table.Td>
<Badge
size="xs"
variant="light"
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
>
{asI18n(link.entityType.replace(/_/g, ' '))}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{asI18n(link.notes || 'Linked')}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{asI18n(link.notes || '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
) : (
<Text size="sm" c="dimmed">
{m.finance_no_links()}
</Text>
)}
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm" fw={600}>
{m.finance_add_link()}
</Text>
<Select
label={m.finance_entity_type()}
data={ENTITY_TYPES}
value={entityType}
onChange={(val) => setEntityType(val ?? 'inventory_item')}
/>
<TextInput
label={m.finance_reference_id()}
description={asI18n('Enter the identifier of the entity to link (e.g. from the relevant list page)')}
placeholder={asI18n('Entity reference')}
value={entityId}
onChange={(e) => setEntityId(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_notes()}
placeholder={asI18n('Optional notes about this link')}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_close()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_link_entity()}
</Button>
</Group>
</Stack>
</form>
</Stack>
</Drawer>
)
}
function RecordLinkBadges({ financeId }: { financeId: string }) {
const { data } = usePikkuQuery('listFinanceLinks', {
financeId,
limit: 50,
offset: 0,
})
const links = (data?.items ?? []) as FinanceLink[]
if (links.length === 0) return null
return (
<Group gap={4} mt={4}>
{links.map((link) => (
<Badge
key={link.linkId}
size="xs"
variant="dot"
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
>
{asI18n(link.entityType.replace(/_/g, ' '))}
</Badge>
))}
</Group>
)
}
function FinancePage() {
useLocale()
const canManage = usePermission('finance.manage')
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<FinanceRecord | null>(null)
const [linkingRecord, setLinkingRecord] = useState<FinanceRecord | null>(null)
const [filterCategory, setFilterCategory] = useState<string | null>(null)
const [filterType, setFilterType] = useState<string | null>(null)
const queryClient = useQueryClient()
const { data: summary } = usePikkuQuery('getFinanceSummary', {})
const { data: ratesData } = usePikkuQuery('getExchangeRates', {})
const fetchRatesMutation = usePikkuMutation('fetchExchangeRates', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['getExchangeRates'] })
notifications.show({
title: 'Exchange rates updated',
message: 'Latest rates fetched successfully.',
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to fetch rates.',
color: 'red',
})
},
})
const { data, isLoading } = usePikkuQuery('listFinanceRecords', {
limit: 50,
offset: 0,
...(filterCategory
? {
category: filterCategory as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
}
: {}),
...(filterType
? { recordType: filterType as 'expense' | 'income' | 'reimbursement' }
: {}),
})
const items = (data?.items ?? []) as FinanceRecord[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<FinanceRecord>[] = [
{
key: 'name',
label: m.finance_name(),
render: (record) => (
<div>
<Text size="sm" fw={500}>
{asI18n(record.name)}
</Text>
<RecordLinkBadges financeId={record.financeId} />
</div>
),
},
{
key: 'amount',
label: m.finance_amount(),
render: (record) => (
<div>
<Text size="sm" fw={600}>
{asI18n(`${record.currency} ${record.amount.toFixed(2)}`)}
</Text>
{record.amountEgp != null && record.currency !== 'EGP' && (
<Text size="xs" c="dimmed">
{asI18n(`≈ EGP ${record.amountEgp.toFixed(2)}`)}
</Text>
)}
</div>
),
},
{
key: 'recordType',
label: m.finance_type(),
render: (record) => (
<Badge color={recordTypeBadgeColor[record.recordType] ?? 'gray'} variant="light" size="sm">
{asI18n(record.recordType)}
</Badge>
),
},
{
key: 'category',
label: m.finance_category(),
render: (record) => (
<Badge color="teal" variant="outline" size="sm">
{asI18n(record.category)}
</Badge>
),
},
{
key: 'purchasedAt',
label: m.finance_date(),
render: (record) => (
<Text size="sm" c="dimmed">
{asI18n(new Date(record.purchasedAt).toLocaleDateString())}
</Text>
),
},
...(canManage
? [
{
key: 'actions',
label: m.finance_actions(),
render: (record: FinanceRecord) => (
<Group gap={4}>
<Tooltip label={m.finance_link_entity()}>
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => setLinkingRecord(record)}
>
<Link2 size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={m.finance_edit_record()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => setEditingRecord(record)}
>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
</Group>
),
},
]
: []),
]
return (
<Stack gap="lg">
<PageHeader
title={m.finance_title()}
description={m.finance_subtitle()}
action={
canManage ? (
<Button
leftSection={<Plus size={16} />}
color="teal"
radius="md"
onClick={() => setAddModalOpen(true)}
>
{m.finance_add_record()}
</Button>
) : undefined
}
/>
{summary && (
<>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_total_income()}
</Text>
<Text size="xl" fw={700} c="green">
{asI18n(`$${summary.totalIncome.toFixed(2)}`)}
</Text>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_total_expenses()}
</Text>
<Text size="xl" fw={700} c="red">
{asI18n(`$${summary.totalExpenses.toFixed(2)}`)}
</Text>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_net()}
</Text>
<Text
size="xl"
fw={700}
c={summary.totalIncome - summary.totalExpenses >= 0 ? 'teal' : 'red'}
>
{asI18n(`$${(summary.totalIncome - summary.totalExpenses).toFixed(2)}`)}
</Text>
</Card>
</SimpleGrid>
{summary.byCategory.length > 0 && (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text
size="sm"
fw={600}
mb="sm"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.finance_by_category()}
</Text>
<Group gap="lg" wrap="wrap">
{summary.byCategory.map((cat) => (
<Group key={cat.category} gap="xs">
<Badge color="teal" variant="outline" size="sm">
{asI18n(cat.category)}
</Badge>
<Text size="sm" fw={500}>
{asI18n(`$${cat.total.toFixed(2)}`)}
</Text>
</Group>
))}
</Group>
</Card>
)}
</>
)}
{ratesData && ratesData.rates.length > 0 && (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Group justify="space-between" mb="sm">
<div>
<Text
size="sm"
fw={600}
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{asI18n(`${m.finance_exchange_rates()} (${m.finance_base()}: ${ratesData.base})`)}
</Text>
<Text size="xs" c="dimmed">
{asI18n(`${m.finance_last_updated()}: ${ratesData.date}`)}
</Text>
</div>
<Button
size="xs"
variant="light"
color="teal"
onClick={() => fetchRatesMutation.mutate({})}
loading={fetchRatesMutation.isPending}
>
{m.finance_refresh_rates()}
</Button>
</Group>
<Group gap="lg" wrap="wrap">
{ratesData.rates
.filter((r) => r.targetCurrency !== ratesData.base)
.map((r) => (
<Group key={r.targetCurrency} gap="xs">
<Badge color="blue" variant="outline" size="sm">
{asI18n(r.targetCurrency)}
</Badge>
<Text size="sm" fw={500}>
{asI18n(r.rate.toFixed(4))}
</Text>
</Group>
))}
</Group>
</Card>
)}
<Group gap="sm">
<Select
placeholder={asI18n('All categories')}
data={[{ value: '', label: 'All categories' }, ...CATEGORIES]}
value={filterCategory}
onChange={(val) => setFilterCategory(val || null)}
clearable
size="sm"
w={180}
/>
<Select
placeholder={asI18n('All types')}
data={[{ value: '', label: 'All types' }, ...RECORD_TYPES]}
value={filterType}
onChange={(val) => setFilterType(val || null)}
clearable
size="sm"
w={180}
/>
</Group>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack align="center" py="xl">
<DollarSign size={48} strokeWidth={1} color="#aaa" />
<Text c="dimmed">{m.finance_empty()}</Text>
</Stack>
</Card>
) : (
<DataTable
data={items}
columns={columns}
rowKey={(record) => record.financeId}
emptyMessage="No finance records found."
/>
)}
<AddRecordDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
<EditRecordDrawer record={editingRecord} onClose={() => setEditingRecord(null)} />
<LinkDrawer record={linkingRecord} onClose={() => setLinkingRecord(null)} />
</Stack>
)
}

View File

@@ -0,0 +1,36 @@
import { createFileRoute } from '@tanstack/react-router'
import { Title, Text, SimpleGrid, Card, Group, Stack } from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import type { I18nNode } from '@pikku/react'
import { BedDouble, CheckSquare, Sailboat, CalendarDays } from 'lucide-react'
export const Route = createFileRoute('/_authenticated/')({
component: DashboardPage,
})
function DashboardPage() {
useLocale()
return (
<Stack gap="lg">
<Title order={2}>{m.dashboard_title()}</Title>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<SummaryCard icon={BedDouble} label={m.dashboard_stays()} color="teal" />
<SummaryCard icon={CheckSquare} label={m.dashboard_tasks()} color="blue" />
<SummaryCard icon={Sailboat} label={m.dashboard_boats()} color="cyan" />
<SummaryCard icon={CalendarDays} label={m.dashboard_retreats()} color="grape" />
</SimpleGrid>
</Stack>
)
}
function SummaryCard({ icon: Icon, label, color }: { icon: React.ComponentType<any>; label: I18nNode; color: string }) {
return (
<Card withBorder radius="md" p="md">
<Group>
<Icon size={24} color={`var(--mantine-color-${color}-6)`} />
<Text fw={600}>{label}</Text>
</Group>
</Card>
)
}

View File

@@ -0,0 +1,703 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Tabs,
Center,
Drawer,
TextInput,
NumberInput,
Select,
ActionIcon,
Tooltip,
Table,
Textarea,
} from '@pikku/mantine/core'
import { Package, Plus, AlertTriangle, Pencil, History, ClipboardCheck } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { DataTable, type Column } from '@perauset/components'
export const Route = createFileRoute('/_authenticated/inventory')({
component: InventoryPage,
})
type InventoryCategory = 'kitchen' | 'rooms' | 'equipment' | 'garden' | 'other'
interface InventoryItem {
itemId: string
name: string
currentQuantity: number
minimumQuantity: number
unit: string
isActive: boolean
category: string
subcategory: string | null
}
interface InventoryTransaction {
transactionId: string
itemId: string
quantityChange: number
reason: string
notes: string | null
createdByUserId: string
createdAt: string | Date
}
const CATEGORY_OPTIONS = [
{ value: 'kitchen', label: m.inventory_cat_kitchen() },
{ value: 'rooms', label: m.inventory_cat_rooms() },
{ value: 'equipment', label: m.inventory_cat_equipment() },
{ value: 'garden', label: m.inventory_cat_garden() },
{ value: 'other', label: m.inventory_cat_other() },
]
function AddItemDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [unit, setUnit] = useState('')
const [currentQuantity, setCurrentQuantity] = useState<number>(0)
const [minimumQuantity, setMinimumQuantity] = useState<number>(0)
const [category, setCategory] = useState<InventoryCategory>('kitchen')
const mutation = usePikkuMutation('createInventoryItem', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_item_created_title(),
message: m.inventory_item_created_message(),
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_create(),
color: 'red',
})
},
})
const resetForm = () => {
setName('')
setUnit('')
setCurrentQuantity(0)
setMinimumQuantity(0)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !unit) {
notifications.show({
title: m.inventory_missing_fields_title(),
message: m.inventory_missing_fields_message(),
color: 'red',
})
return
}
mutation.mutate({ name, unit, currentQuantity, minimumQuantity, category })
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.inventory_add_item_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.inventory_field_name()}
placeholder={m.inventory_name_placeholder()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<TextInput
label={m.inventory_field_unit()}
placeholder={m.inventory_unit_placeholder()}
value={unit}
onChange={(e) => setUnit(e.currentTarget.value)}
required
/>
<NumberInput
label={m.inventory_current_quantity()}
value={currentQuantity}
onChange={(val) => setCurrentQuantity(Number(val) || 0)}
min={0}
/>
<NumberInput
label={m.inventory_minimum_quantity()}
description={m.inventory_alert_threshold()}
value={minimumQuantity}
onChange={(val) => setMinimumQuantity(Number(val) || 0)}
min={0}
/>
<Select
label={m.inventory_category()}
data={CATEGORY_OPTIONS}
value={category}
onChange={(val) => setCategory((val as InventoryCategory) || 'kitchen')}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_create_item()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditItemDrawer({ item, onClose }: { item: InventoryItem | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(item?.name ?? '')
const [unit, setUnit] = useState(item?.unit ?? '')
const [currentQuantity, setCurrentQuantity] = useState<number>(item?.currentQuantity ?? 0)
const [minimumQuantity, setMinimumQuantity] = useState<number>(item?.minimumQuantity ?? 0)
const mutation = usePikkuMutation('updateInventoryItem', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_item_updated_title(),
message: m.inventory_item_updated_message(),
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_update(),
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!item) return
mutation.mutate({
itemId: item.itemId,
name,
unit,
currentQuantity,
minimumQuantity,
})
}
if (!item) return null
return (
<Drawer position="right" size="md" opened={!!item} onClose={onClose} title={m.inventory_edit_item_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.inventory_field_name()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<TextInput
label={m.inventory_field_unit()}
value={unit}
onChange={(e) => setUnit(e.currentTarget.value)}
required
/>
<NumberInput
label={m.inventory_current_quantity()}
value={currentQuantity}
onChange={(val) => setCurrentQuantity(Number(val) || 0)}
min={0}
/>
<NumberInput
label={m.inventory_minimum_quantity()}
description={m.inventory_alert_threshold()}
value={minimumQuantity}
onChange={(val) => setMinimumQuantity(Number(val) || 0)}
min={0}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_save_changes()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function HistoryDrawer({ item, onClose }: { item: InventoryItem | null; onClose: () => void }) {
const { data, isLoading } = usePikkuQuery(
'listInventoryTransactions',
{ itemId: item?.itemId ?? '', limit: 50, offset: 0 },
{ enabled: !!item }
)
const transactions = (data?.items ?? []) as InventoryTransaction[]
if (!item) return null
return (
<Drawer
position="right"
size="lg"
opened={!!item}
onClose={onClose}
title={m.inventory_history_title({ name: item.name })}
>
{isLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : transactions.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
{m.inventory_no_history()}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.inventory_col_date()}</Table.Th>
<Table.Th>{m.inventory_col_change()}</Table.Th>
<Table.Th>{m.inventory_col_reason()}</Table.Th>
<Table.Th>{m.inventory_col_notes()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{transactions.map((tx) => (
<Table.Tr key={tx.transactionId}>
<Table.Td>
<Text size="xs">{asI18n(new Date(tx.createdAt).toLocaleString())}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600} c={tx.quantityChange >= 0 ? 'green' : 'red'}>
{asI18n(`${tx.quantityChange >= 0 ? '+' : ''}${tx.quantityChange}`)}
</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color="gray">
{asI18n(tx.reason)}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" lineClamp={1}>
{asI18n(tx.notes || '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Drawer>
)
}
function StocktakeDrawer({
opened,
onClose,
items,
}: {
opened: boolean
onClose: () => void
items: InventoryItem[]
}) {
const queryClient = useQueryClient()
const [quantities, setQuantities] = useState<Record<string, number>>(() => {
const q: Record<string, number> = {}
for (const item of items) {
q[item.itemId] = item.currentQuantity
}
return q
})
const mutation = usePikkuMutation('submitStocktake', {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_stocktake_submitted_title(),
message: m.inventory_stocktake_submitted_message({ count: result.adjustedCount }),
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_stocktake(),
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const stocktakeItems = items.map((item) => ({
itemId: item.itemId,
actualQuantity: quantities[item.itemId] ?? item.currentQuantity,
}))
mutation.mutate({ items: stocktakeItems })
}
return (
<Drawer position="right" size="lg" opened={opened} onClose={onClose} title={m.inventory_stocktake_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Text size="sm" c="dimmed" mb="sm">
{m.inventory_stocktake_intro()}
</Text>
{items.map((item) => (
<Group key={item.itemId} justify="space-between" align="center">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{asI18n(item.name)}
</Text>
<Text size="xs" c="dimmed">
{m.inventory_stocktake_current({ quantity: item.currentQuantity, unit: item.unit })}
</Text>
</div>
<NumberInput
w={120}
size="sm"
value={quantities[item.itemId] ?? item.currentQuantity}
onChange={(val) =>
setQuantities((prev) => ({ ...prev, [item.itemId]: Number(val) || 0 }))
}
min={0}
/>
</Group>
))}
<Group justify="flex-end" mt="md">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_submit_stocktake()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function RequestItemDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [itemName, setItemName] = useState('')
const [quantity, setQuantity] = useState<number | string>('')
const [unit, setUnit] = useState('')
const [purpose, setPurpose] = useState('')
const mutation = usePikkuMutation('requestInventory', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
notifications.show({
title: m.inventory_request_submitted_title(),
message: m.inventory_request_submitted_message(),
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.inventory_error_title(),
message: err.message || m.inventory_error_request(),
color: 'red',
})
},
})
const resetForm = () => {
setItemName('')
setQuantity('')
setUnit('')
setPurpose('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
...(itemName ? { itemName } : {}),
quantity: Number(quantity),
unit,
...(purpose ? { purpose } : {}),
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.inventory_request_item_title()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.inventory_field_name()}
placeholder={m.inventory_request_name_placeholder()}
required
value={itemName}
onChange={(e) => setItemName(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.inventory_request_quantity()}
placeholder={m.inventory_request_quantity_placeholder()}
required
min={1}
value={quantity}
onChange={setQuantity}
/>
<TextInput
label={m.inventory_field_unit()}
placeholder={m.inventory_request_unit_placeholder()}
required
value={unit}
onChange={(e) => setUnit(e.currentTarget.value)}
/>
</Group>
<Textarea
label={m.inventory_request_purpose()}
placeholder={m.inventory_request_purpose_placeholder()}
minRows={2}
value={purpose}
onChange={(e) => setPurpose(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.inventory_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.inventory_submit_request()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function InventoryPage() {
useLocale()
const canManage = usePermission('inventory.manage')
const [addModalOpen, setAddModalOpen] = useState(false)
const [requestDrawerOpen, setRequestDrawerOpen] = useState(false)
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null)
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null)
const [stocktakeOpen, setStocktakeOpen] = useState(false)
const [activeCategory, setActiveCategory] = useState<string>(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash.slice(1)
if (['all', 'kitchen', 'rooms', 'equipment', 'garden', 'other'].includes(hash)) return hash
}
return 'all'
})
const { data, isLoading } = usePikkuQuery('listInventory', {
activeOnly: true,
limit: 200,
offset: 0,
})
const allItems = (data?.items ?? []) as InventoryItem[]
const items =
activeCategory === 'all' ? allItems : allItems.filter((i) => i.category === activeCategory)
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<InventoryItem>[] = [
{
key: 'name',
label: m.inventory_col_item(),
render: (item) => (
<Text size="sm" fw={500}>
{asI18n(item.name)}
</Text>
),
},
{
key: 'quantity',
label: m.inventory_col_quantity(),
render: (item) => {
const isLow = item.currentQuantity <= item.minimumQuantity
return (
<Group gap={6}>
<Text size="sm">{item.currentQuantity}</Text>
{isLow && <AlertTriangle size={14} style={{ color: '#e67700' }} />}
</Group>
)
},
},
{
key: 'unit',
label: m.inventory_col_unit(),
render: (item) => (
<Text size="sm" c="dimmed">
{asI18n(item.unit)}
</Text>
),
},
{
key: 'status',
label: m.inventory_col_status(),
render: (item) => {
const isLow = item.currentQuantity <= item.minimumQuantity
return isLow ? (
<Badge color="orange" variant="light" size="sm">
{m.inventory_low_stock()}
</Badge>
) : (
<Badge color="green" variant="light" size="sm">
{m.inventory_in_stock()}
</Badge>
)
},
},
...(canManage
? [
{
key: 'actions',
label: m.inventory_col_actions(),
render: (item: InventoryItem) => (
<Group gap={4}>
<Tooltip label={m.inventory_transaction_history()}>
<ActionIcon variant="light" color="gray" size="sm" onClick={() => setHistoryItem(item)}>
<History size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={m.inventory_edit_item()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingItem(item)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
</Group>
),
},
]
: []),
]
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.inventory_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.inventory_description()}
</Text>
</div>
<Group gap="sm">
<Button onClick={() => setRequestDrawerOpen(true)} variant="light" color="teal" radius="md">
{m.inventory_request_item()}
</Button>
{canManage && (
<>
<Button
leftSection={<ClipboardCheck size={16} />}
variant="light"
color="orange"
radius="md"
onClick={() => setStocktakeOpen(true)}
>
{m.inventory_stocktake()}
</Button>
<Button
leftSection={<Plus size={16} />}
color="teal"
radius="md"
onClick={() => setAddModalOpen(true)}
>
{m.inventory_add_item()}
</Button>
</>
)}
</Group>
</Group>
<Tabs
value={activeCategory}
onChange={(val) => {
setActiveCategory(val || 'all')
window.history.replaceState(null, '', `#${val || 'all'}`)
}}
>
<Tabs.List>
<Tabs.Tab value="all">{m.inventory_tab_all({ count: allItems.length })}</Tabs.Tab>
<Tabs.Tab value="kitchen">
{m.inventory_tab_kitchen({ count: allItems.filter((i) => i.category === 'kitchen').length })}
</Tabs.Tab>
<Tabs.Tab value="rooms">
{m.inventory_tab_rooms({ count: allItems.filter((i) => i.category === 'rooms').length })}
</Tabs.Tab>
<Tabs.Tab value="equipment">
{m.inventory_tab_equipment({ count: allItems.filter((i) => i.category === 'equipment').length })}
</Tabs.Tab>
<Tabs.Tab value="garden">
{m.inventory_tab_garden({ count: allItems.filter((i) => i.category === 'garden').length })}
</Tabs.Tab>
<Tabs.Tab value="other">
{m.inventory_tab_other({ count: allItems.filter((i) => i.category === 'other').length })}
</Tabs.Tab>
</Tabs.List>
</Tabs>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack align="center" py="xl">
<Package size={48} style={{ color: '#aaa' }} />
<Text c="dimmed">{m.inventory_no_items()}</Text>
</Stack>
</Card>
) : (
<DataTable
data={items}
columns={columns}
rowKey={(item) => item.itemId}
emptyMessage={m.inventory_no_items()}
/>
)}
<AddItemDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
<EditItemDrawer item={editingItem} onClose={() => setEditingItem(null)} />
<HistoryDrawer item={historyItem} onClose={() => setHistoryItem(null)} />
<StocktakeDrawer opened={stocktakeOpen} onClose={() => setStocktakeOpen(false)} items={items} />
<RequestItemDrawer opened={requestDrawerOpen} onClose={() => setRequestDrawerOpen(false)} />
</Stack>
)
}

View File

@@ -0,0 +1,163 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Checkbox,
Textarea,
Button,
Stack,
Group,
Alert,
SimpleGrid,
} from '@pikku/mantine/core'
import { AlertCircle, Check } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
export const Route = createFileRoute('/_authenticated/kitchen/dietary')({
component: DietaryProfilePage,
})
function DietaryProfilePage() {
useLocale()
const [isVegan, setIsVegan] = useState(false)
const [isVegetarian, setIsVegetarian] = useState(false)
const [isGlutenFree, setIsGlutenFree] = useState(false)
const [isDairyFree, setIsDairyFree] = useState(false)
const [hasNutAllergy, setHasNutAllergy] = useState(false)
const [allergyNotes, setAllergyNotes] = useState('')
const [otherRequirements, setOtherRequirements] = useState('')
const [dislikes, setDislikes] = useState('')
const mutation = usePikkuMutation('upsertDietaryProfile')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
isVegan,
isVegetarian,
isGlutenFree,
isDairyFree,
hasNutAllergy,
...(allergyNotes ? { allergyNotes } : {}),
...(otherRequirements ? { otherRequirements } : {}),
...(dislikes ? { dislikes } : {}),
})
}
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.kitchen_dietary_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.kitchen_dietary_description()}
</Text>
</div>
<Card
padding="xl"
radius="md"
maw={600}
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{asI18n(mutation.error?.message ?? m.kitchen_dietary_error())}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.kitchen_dietary_success()}
</Alert>
)}
<Text size="sm" fw={500} style={{ color: '#3D2B1F' }}>
{m.kitchen_dietary_preferences()}
</Text>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
<Checkbox
label={m.kitchen_dietary_vegan()}
checked={isVegan}
onChange={(e) => setIsVegan(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_vegetarian()}
checked={isVegetarian}
onChange={(e) => setIsVegetarian(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_gluten_free()}
checked={isGlutenFree}
onChange={(e) => setIsGlutenFree(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_dairy_free()}
checked={isDairyFree}
onChange={(e) => setIsDairyFree(e.currentTarget.checked)}
color="teal"
/>
<Checkbox
label={m.kitchen_dietary_nut_allergy()}
checked={hasNutAllergy}
onChange={(e) => setHasNutAllergy(e.currentTarget.checked)}
color="red"
/>
</SimpleGrid>
<Textarea
label={m.kitchen_dietary_allergy_notes()}
placeholder={m.kitchen_dietary_allergy_notes_placeholder()}
minRows={2}
value={allergyNotes}
onChange={(e) => setAllergyNotes(e.currentTarget.value)}
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
/>
<Textarea
label={m.kitchen_dietary_other_requirements()}
placeholder={m.kitchen_dietary_other_requirements_placeholder()}
minRows={2}
value={otherRequirements}
onChange={(e) => setOtherRequirements(e.currentTarget.value)}
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
/>
<Textarea
label={m.kitchen_dietary_dislikes()}
placeholder={m.kitchen_dietary_dislikes_placeholder()}
minRows={2}
value={dislikes}
onChange={(e) => setDislikes(e.currentTarget.value)}
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
/>
<Group justify="flex-end" mt="sm">
<Button component={Link} to="/kitchen" variant="subtle" color="gray">
{m.kitchen_dietary_cancel()}
</Button>
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
{m.kitchen_dietary_save()}
</Button>
</Group>
</Stack>
</form>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,265 @@
import { createFileRoute } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
SimpleGrid,
Loader,
Center,
Drawer,
Select,
NumberInput,
TextInput,
Textarea,
} from '@pikku/mantine/core'
import { UtensilsCrossed, Plus } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useState } from 'react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
export const Route = createFileRoute('/_authenticated/kitchen/')({
component: KitchenPage,
})
const mealTypeIcon: Record<string, string> = {
breakfast: '\u{1F305}',
lunch: '\u{2600}',
dinner: '\u{1F319}',
snack: '\u{1F34E}',
}
const statusColor: Record<string, string> = {
planned: 'blue',
preparing: 'orange',
served: 'green',
cancelled: 'red',
}
const STATUS_LABEL: Record<string, () => string> = {
planned: () => m.kitchen_status_planned(),
preparing: () => m.kitchen_status_preparing(),
served: () => m.kitchen_status_served(),
cancelled: () => m.kitchen_status_cancelled(),
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})
}
function KitchenPage() {
useLocale()
const queryClient = useQueryClient()
const [opened, { open, close }] = useDisclosure(false)
const [serviceType, setServiceType] = useState<string | null>(null)
const [serviceDate, setServiceDate] = useState('')
const [plannedHeadcount, setPlannedHeadcount] = useState<number | string>('')
const [menuNotes, setMenuNotes] = useState('')
const { data, isLoading } = usePikkuQuery('listMealServices', { limit: 50, offset: 0 })
const createMutation = usePikkuMutation('createMealService', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listMealServices'] })
notifications.show({
title: m.kitchen_meal_created_title(),
message: m.kitchen_meal_created_message(),
color: 'green',
})
resetForm()
close()
},
onError: (err) => {
notifications.show({
title: m.kitchen_error_title(),
message: err.message || m.kitchen_error_create_meal(),
color: 'red',
})
},
})
function resetForm() {
setServiceType(null)
setServiceDate('')
setPlannedHeadcount('')
setMenuNotes('')
}
function handleSubmit() {
if (!serviceType || !serviceDate) return
createMutation.mutate({
serviceType: serviceType as 'breakfast' | 'lunch' | 'dinner' | 'snack',
serviceDate: new Date(serviceDate).toISOString(),
...(plannedHeadcount !== '' ? { plannedHeadcount: Number(plannedHeadcount) } : {}),
...(menuNotes.trim() ? { menuNotes: menuNotes.trim() } : {}),
})
}
const meals = data?.items ?? []
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.kitchen_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.kitchen_description()}
</Text>
</div>
<Group gap="sm">
<Button leftSection={<Plus size={16} />} color="teal" radius="md" onClick={open}>
{m.kitchen_create_meal()}
</Button>
</Group>
</Group>
{meals.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<UtensilsCrossed size={48} style={{ color: '#aaa', marginBottom: 8 }} />
<Text c="dimmed">{m.kitchen_no_meals()}</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{meals.map((meal) => (
<Card
key={meal.serviceId}
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease',
}}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Group gap="xs">
<Text size="lg">{asI18n(mealTypeIcon[meal.serviceType] ?? '\u{1F37D}')}</Text>
<Title
order={4}
style={{
fontFamily: '"Cormorant", serif',
color: '#3D2B1F',
textTransform: 'capitalize',
}}
>
{asI18n(meal.serviceType)}
</Title>
</Group>
<Badge color={statusColor[meal.status] ?? 'gray'} variant="light" size="sm">
{asI18n(STATUS_LABEL[meal.status]?.() ?? meal.status)}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{asI18n(formatDate(meal.serviceDate))}
</Text>
{meal.plannedHeadcount != null && (
<Text size="sm" c="dimmed">
{m.kitchen_expected_headcount({ count: meal.plannedHeadcount })}
</Text>
)}
{meal.menuNotes && (
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(meal.menuNotes)}
</Text>
)}
</Stack>
</Card>
))}
</SimpleGrid>
)}
</Stack>
<Drawer opened={opened} onClose={close} title={m.kitchen_create_meal_service()} position="right" size="md">
<Stack gap="md">
<Select
label={m.kitchen_service_type()}
placeholder={m.kitchen_select_meal_type()}
data={[
{ value: 'breakfast', label: m.kitchen_meal_type_breakfast() },
{ value: 'lunch', label: m.kitchen_meal_type_lunch() },
{ value: 'dinner', label: m.kitchen_meal_type_dinner() },
{ value: 'snack', label: m.kitchen_meal_type_snack() },
]}
value={serviceType}
onChange={setServiceType}
required
/>
<TextInput
label={m.kitchen_service_date()}
type="date"
value={serviceDate}
onChange={(e) => setServiceDate(e.currentTarget.value)}
required
/>
<NumberInput
label={m.kitchen_planned_headcount()}
placeholder={m.kitchen_planned_headcount_placeholder()}
min={1}
value={plannedHeadcount}
onChange={setPlannedHeadcount}
/>
<Textarea
label={m.kitchen_menu_notes()}
placeholder={m.kitchen_menu_notes_placeholder()}
rows={4}
value={menuNotes}
onChange={(e) => setMenuNotes(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={close}>
{m.kitchen_cancel()}
</Button>
<Button
color="teal"
onClick={handleSubmit}
loading={createMutation.isPending}
disabled={!serviceType || !serviceDate}
>
{m.kitchen_create_meal()}
</Button>
</Group>
</Stack>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/kitchen')({
component: Outlet,
})

View File

@@ -0,0 +1,274 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
TextInput,
Button,
Alert,
Loader,
Center,
ThemeIcon,
SimpleGrid,
} from '@pikku/mantine/core'
import { Link } from '@tanstack/react-router'
import {
AlertCircle,
Check,
User,
Mail,
Phone,
MessageCircle,
Salad,
ChevronRight,
} from 'lucide-react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useSessionUser } from '@/lib/auth-context'
export const Route = createFileRoute('/_authenticated/my/profile')({
component: MyProfilePage,
})
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 'calc(40rem * var(--mantine-scale))',
}
const labelStyle = { label: { fontWeight: 500, color: '#3D2B1F' } }
function MyProfilePage() {
useLocale()
const sessionUser = useSessionUser()
const queryClient = useQueryClient()
const userId = sessionUser?.id
const { data: user, isLoading } = usePikkuQuery(
'getUser',
{ userId: userId! },
{ enabled: !!userId },
)
const [name, setName] = useState('')
const [displayName, setDisplayName] = useState('')
const [mobileNumber, setMobileNumber] = useState('')
const [whatsappNumber, setWhatsappNumber] = useState('')
const [formInitialized, setFormInitialized] = useState(false)
if (user && !formInitialized) {
setName(user.name ?? '')
setDisplayName(user.displayName ?? '')
setMobileNumber(user.mobileNumber ?? '')
setWhatsappNumber(user.whatsappNumber ?? '')
setFormInitialized(true)
}
const mutation = usePikkuMutation('updateUserProfile', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['getUser'] })
},
})
if (!sessionUser) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_profile_title()}
</Title>
<Text c="dimmed">{m.my_sign_in_to_view()}</Text>
</Stack>
)
}
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
userId: userId!,
name: name || undefined,
displayName: displayName || undefined,
mobileNumber: mobileNumber || undefined,
whatsappNumber: whatsappNumber || undefined,
})
}
const fullName =
user?.displayName ??
user?.name ??
sessionUser?.name ??
'User'
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_profile_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.my_profile_description()}
</Text>
</div>
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<div>
<Group gap={6} mb={4}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<User size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.my_field_name()}
</Text>
</Group>
<Text size="sm" pl={26}>
{asI18n(fullName)}
</Text>
</div>
<div>
<Group gap={6} mb={4}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<Mail size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.my_field_email()}
</Text>
</Group>
<Text size="sm" pl={26}>
{asI18n(user?.email ?? sessionUser?.email ?? '--')}
</Text>
</div>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>
{m.my_field_roles()}
</Text>
<Group gap={4}>
{(user?.memberRoles ?? []).map((role: string) => (
<Badge key={role} color="teal" variant="light" size="sm">
{asI18n(role.replace(/_/g, ' '))}
</Badge>
))}
</Group>
</div>
</Stack>
</Card>
<Card padding="lg" radius="md" maw={640} style={cardStyle}>
<Title order={4} mb="md" style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_edit_profile()}
</Title>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{mutation.error?.message
? asI18n(mutation.error.message)
: m.my_update_failed()}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.my_update_success()}
</Alert>
)}
<TextInput
label={m.profile_name()}
placeholder={m.profile_name_placeholder()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
styles={labelStyle}
/>
<TextInput
label={m.my_display_name()}
placeholder={m.my_display_name_placeholder()}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
styles={labelStyle}
/>
<Group grow>
<TextInput
label={m.my_mobile_number()}
placeholder={m.my_phone_placeholder()}
value={mobileNumber}
onChange={(e) => setMobileNumber(e.currentTarget.value)}
leftSection={<Phone size={16} />}
styles={labelStyle}
/>
<TextInput
label={m.my_whatsapp_number()}
placeholder={m.my_phone_placeholder()}
value={whatsappNumber}
onChange={(e) => setWhatsappNumber(e.currentTarget.value)}
leftSection={<MessageCircle size={16} />}
styles={labelStyle}
/>
</Group>
<Group justify="flex-end">
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
{m.my_save_profile()}
</Button>
</Group>
</Stack>
</form>
</Card>
<Card
padding="lg"
radius="md"
component={Link}
to={'/kitchen/dietary' as any}
style={{
...cardStyle,
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
textDecoration: 'none',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon size={48} variant="light" color="teal" radius="xl">
<Salad size={24} />
</ThemeIcon>
<div>
<Text
fw={600}
style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}
>
{m.my_dietary_profile()}
</Text>
<Text size="sm" c="dimmed">
{m.my_dietary_profile_description()}
</Text>
</div>
</Group>
<ThemeIcon size="md" variant="transparent" color="gray">
<ChevronRight size={20} />
</ThemeIcon>
</Group>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,237 @@
import { createFileRoute } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Stack,
Badge,
Loader,
Center,
Group,
SimpleGrid,
ThemeIcon,
} from '@pikku/mantine/core'
import { Home, FileText, CalendarDays, NotebookPen } from 'lucide-react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { StatusBadge } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n, type I18nNode } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/my/stays')({
component: MyStaysPage,
})
interface StayRequest {
requestId: string
requestType: string
requestedStartAt: string | Date
requestedEndAt: string | Date
status: string
notes: string | null
}
interface Stay {
stayId: string
stayType: string
startAt: string | Date
endAt: string | Date
status: string
}
function formatDateRange(startStr: string | Date, endStr: string | Date): string {
try {
const start = new Date(startStr)
const end = new Date(endStr)
const startFormatted = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
const sameYear = start.getFullYear() === end.getFullYear()
const endFormatted = end.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
if (sameYear) {
return `${startFormatted} ${endFormatted}`
}
const startWithYear = start.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return `${startWithYear} ${endFormatted}`
} catch {
return `${String(startStr)} - ${String(endStr)}`
}
}
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 'calc(40rem * var(--mantine-scale))',
}
function StayRequestCard({ request }: { request: StayRequest }) {
return (
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={600} style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}>
{asI18n(formatType(request.requestType))}
</Text>
<StatusBadge status={request.status} />
</Group>
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<CalendarDays size={16} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(formatDateRange(request.requestedStartAt, request.requestedEndAt))}
</Text>
</Group>
{request.notes && (
<Group gap={6} align="flex-start">
<ThemeIcon size="sm" variant="transparent" color="gray">
<NotebookPen size={16} />
</ThemeIcon>
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(request.notes)}
</Text>
</Group>
)}
</Stack>
</Card>
)
}
function StayCard({ stay }: { stay: Stay }) {
return (
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={600} style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}>
{asI18n(formatType(stay.stayType))}
</Text>
<StatusBadge status={stay.status} />
</Group>
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="teal">
<CalendarDays size={16} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(formatDateRange(stay.startAt, stay.endAt))}
</Text>
</Group>
</Stack>
</Card>
)
}
function EmptyState({ message, icon }: { message: I18nNode; icon: React.ReactNode }) {
return (
<Card padding="xl" radius="md" style={{ ...cardStyle, textAlign: 'center' as const }}>
<Stack align="center" gap="sm">
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
{icon}
</ThemeIcon>
<Text c="dimmed" size="sm">
{message}
</Text>
</Stack>
</Card>
)
}
function MyStaysPage() {
useLocale()
const { data: requestsData, isLoading: requestsLoading } = usePikkuQuery('listStayRequests', {
limit: 50,
offset: 0,
})
const { data: staysData, isLoading: staysLoading } = usePikkuQuery('listStays', {
limit: 50,
offset: 0,
})
const stayRequests = requestsData?.items ?? []
const stays = staysData?.items ?? []
if (requestsLoading || staysLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_stays_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.my_stays_description()}
</Text>
</div>
<div>
<Group gap="xs" mb="md">
<ThemeIcon size="md" variant="light" color="teal" radius="xl">
<FileText size={16} />
</ThemeIcon>
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_requests()}
</Title>
{stayRequests.length > 0 && (
<Badge size="sm" color="teal" variant="light">
{stayRequests.length}
</Badge>
)}
</Group>
{stayRequests.length === 0 ? (
<EmptyState message={m.my_no_requests()} icon={<FileText size={24} />} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{stayRequests.map((request) => (
<StayRequestCard key={request.requestId} request={request} />
))}
</SimpleGrid>
)}
</div>
<div>
<Group gap="xs" mb="md">
<ThemeIcon size="md" variant="light" color="teal" radius="xl">
<Home size={16} />
</ThemeIcon>
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_active_stays()}
</Title>
{stays.length > 0 && (
<Badge size="sm" color="teal" variant="light">
{stays.length}
</Badge>
)}
</Group>
{stays.length === 0 ? (
<EmptyState message={m.my_no_active_stays()} icon={<Home size={24} />} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{stays.map((stay) => (
<StayCard key={stay.stayId} stay={stay} />
))}
</SimpleGrid>
)}
</div>
</Stack>
)
}

View File

@@ -0,0 +1,220 @@
import { createFileRoute } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Stack,
Badge,
Group,
Loader,
Center,
SimpleGrid,
ThemeIcon,
} from '@pikku/mantine/core'
import { MapPin, Clock, CheckSquare, Smile } from 'lucide-react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { TaskActions } from '@/components/task-actions'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n, type I18nNode } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/my/tasks')({
component: MyTasksPage,
})
const STATUS_CONFIG: Record<string, { labelKey: string; color: string }> = {
in_progress: { labelKey: 'my.tasks_in_progress', color: 'orange' },
assigned: { labelKey: 'my.tasks_assigned', color: 'blue' },
open: { labelKey: 'my.tasks_open', color: 'teal' },
blocked: { labelKey: 'my.tasks_blocked', color: 'red' },
}
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
const DISPLAY_STATUSES = ['in_progress', 'assigned', 'open', 'blocked']
interface TaskItem {
taskId: string
templateId: string | null
title: string
description: string | null
category: string
status: string
location: string | null
scheduledStartAt: string | Date | null
scheduledEndAt: string | Date | null
createdByUserId: string
}
function formatTime(val: string | Date | null): string | null {
if (!val) return null
const d = typeof val === 'string' ? new Date(val) : val
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
maxWidth: 'calc(40rem * var(--mantine-scale))',
}
function TaskCard({ task }: { task: TaskItem }) {
const cfg = STATUS_CONFIG[task.status]
return (
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text
fw={600}
lineClamp={2}
style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}
>
{asI18n(task.title)}
</Text>
<Badge size="sm" color={cfg?.color ?? 'gray'} variant="light" style={{ flexShrink: 0 }}>
{cfg ? mKey(cfg.labelKey) : asI18n(task.status)}
</Badge>
</Group>
<Group gap="xs">
<Badge size="sm" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
{asI18n(task.category)}
</Badge>
</Group>
{task.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(task.description)}
</Text>
)}
{task.location && (
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="gray">
<MapPin size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(task.location)}
</Text>
</Group>
)}
{task.scheduledStartAt && (
<Group gap={6}>
<ThemeIcon size="sm" variant="transparent" color="gray">
<Clock size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{asI18n(
`${formatTime(task.scheduledStartAt)}${
task.scheduledEndAt ? ` ${formatTime(task.scheduledEndAt)}` : ''
}`,
)}
</Text>
</Group>
)}
<TaskActions taskId={task.taskId} status={task.status} />
</Stack>
</Card>
)
}
function EmptyState({ message }: { message: I18nNode }) {
return (
<Card padding="xl" radius="md" style={{ ...cardStyle, textAlign: 'center' as const }}>
<Stack align="center" gap="sm">
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
<Smile size={24} />
</ThemeIcon>
<Text c="dimmed" size="sm">
{message}
</Text>
</Stack>
</Card>
)
}
function MyTasksPage() {
useLocale()
const { data, isLoading } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
const tasks = (data?.items ?? []) as TaskItem[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const grouped: Record<string, TaskItem[]> = {}
for (const status of DISPLAY_STATUSES) {
grouped[status] = []
}
for (const task of tasks) {
if (DISPLAY_STATUSES.includes(task.status)) {
grouped[task.status]!.push(task)
}
}
const hasActiveTasks = DISPLAY_STATUSES.some((s) => (grouped[s]?.length ?? 0) > 0)
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.my_tasks_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.my_tasks_description()}
</Text>
</div>
{!hasActiveTasks ? (
<EmptyState message={m.my_no_active_tasks()} />
) : (
DISPLAY_STATUSES.map((status) => {
const items = grouped[status]!
if (items.length === 0) return null
const cfg = STATUS_CONFIG[status]!
return (
<div key={status}>
<Group gap="xs" mb="md">
<ThemeIcon size="md" variant="light" color={cfg.color} radius="xl">
<CheckSquare size={16} />
</ThemeIcon>
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{mKey(cfg.labelKey)}
</Title>
<Badge size="sm" color={cfg.color} variant="light">
{items.length}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{items.map((task) => (
<TaskCard key={task.taskId} task={task} />
))}
</SimpleGrid>
</div>
)
})
)}
</Stack>
)
}

View File

@@ -0,0 +1,91 @@
import { createFileRoute } from '@tanstack/react-router'
import { Card, Stack, Loader, Center, Text } from '@pikku/mantine/core'
import { CheckCheck } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { NotificationRow } from '@/components/notification-row'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/notifications')({
component: NotificationsPage,
})
function NotificationsPage() {
useLocale()
const { data, isLoading, error } = usePikkuQuery('listNotifications', {
limit: 50,
offset: 0,
})
const items = data?.items ?? []
const unreadCount = items.filter((n) => !n.readAt).length
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.notifications_title()}
description={
unreadCount > 0
? m.notifications_unread_count({ count: unreadCount })
: m.notifications_all_caught_up()
}
/>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{asI18n(error.message ?? 'Failed to load notifications')}
</Text>
</Card>
)}
{items.length === 0 && !error ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<Stack align="center" gap="sm">
<CheckCheck size={40} strokeWidth={1} color="#aaa" />
<Text size="sm" c="dimmed">
{m.notifications_empty()}
</Text>
</Stack>
</Card>
) : (
<Card
padding={0}
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
overflow: 'hidden',
}}
>
<Stack gap={0}>
{items.map((n) => (
<NotificationRow key={n.notificationId} notification={n} />
))}
</Stack>
</Card>
)}
</Stack>
)
}

View File

@@ -0,0 +1,224 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Anchor,
TextInput,
Button,
Alert,
Loader,
Center,
} from '@pikku/mantine/core'
import { AlertCircle, Check } from 'lucide-react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { useSessionUser } from '@/lib/auth-context'
export const Route = createFileRoute('/_authenticated/profile')({
component: ProfilePage,
})
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
const labelStyle = { label: { fontWeight: 500, color: '#3D2B1F' } }
function ProfileEditor({
userId,
name: initialName,
displayName: initialDisplay,
mobileNumber: initialMobile,
whatsappNumber: initialWhatsapp,
}: {
userId: string
name: string
displayName: string
mobileNumber: string
whatsappNumber: string
}) {
const [name, setName] = useState(initialName)
const [displayName, setDisplayName] = useState(initialDisplay)
const [mobileNumber, setMobileNumber] = useState(initialMobile)
const [whatsappNumber, setWhatsappNumber] = useState(initialWhatsapp)
const mutation = usePikkuMutation('updateUserProfile')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
userId,
name: name || undefined,
displayName: displayName || undefined,
mobileNumber: mobileNumber || undefined,
whatsappNumber: whatsappNumber || undefined,
})
}
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{mutation.error?.message ? asI18n(mutation.error.message) : m.profile_update_failed()}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.profile_update_success()}
</Alert>
)}
<TextInput
label={m.profile_name()}
placeholder={m.profile_name_placeholder()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
styles={labelStyle}
/>
<TextInput
label={m.profile_display_name()}
placeholder={m.profile_display_name_placeholder()}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
styles={labelStyle}
/>
<Group grow>
<TextInput
label={m.profile_mobile_number()}
placeholder={m.profile_mobile_number_placeholder()}
value={mobileNumber}
onChange={(e) => setMobileNumber(e.currentTarget.value)}
styles={labelStyle}
/>
<TextInput
label={m.profile_whatsapp_number()}
placeholder={m.profile_whatsapp_number_placeholder()}
value={whatsappNumber}
onChange={(e) => setWhatsappNumber(e.currentTarget.value)}
styles={labelStyle}
/>
</Group>
<Group justify="flex-end">
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
{m.profile_save_profile()}
</Button>
</Group>
</Stack>
</form>
)
}
function ProfilePage() {
useLocale()
const sessionUser = useSessionUser()
const userId = sessionUser?.id
const { data: user, isLoading } = usePikkuQuery(
'getUser',
{ userId: userId! },
{ enabled: !!userId },
)
if (!sessionUser) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.profile_title()}
</Title>
<Text c="dimmed">{m.profile_sign_in_to_view()}</Text>
</Stack>
)
}
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.profile_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.profile_description()}
</Text>
</div>
<Card padding="lg" radius="md" style={cardStyle}>
<Stack gap="md">
<Group gap="lg">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.profile_field_email()}
</Text>
<Text size="sm" mt={4}>
{asI18n(user?.email ?? sessionUser?.email ?? '--')}
</Text>
</div>
</Group>
<Group gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.profile_field_roles()}
</Text>
</Group>
<Group gap={4}>
{(user?.memberRoles ?? []).map((role: string) => (
<Badge key={role} color="teal" variant="light" size="sm">
{asI18n(role.replace(/_/g, ' '))}
</Badge>
))}
</Group>
</Stack>
</Card>
<Card padding="lg" radius="md" maw={600} style={cardStyle}>
<Title order={4} mb="md" style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.profile_edit_profile()}
</Title>
<ProfileEditor
userId={userId!}
name={user?.name ?? ''}
displayName={user?.displayName ?? ''}
mobileNumber={user?.mobileNumber ?? ''}
whatsappNumber={user?.whatsappNumber ?? ''}
/>
</Card>
<Card padding="lg" radius="md" style={cardStyle}>
<Group justify="space-between" align="center">
<div>
<Text size="sm" fw={500} style={{ color: '#3D2B1F' }}>
{m.profile_dietary_profile()}
</Text>
<Text size="xs" c="dimmed">
{m.profile_dietary_profile_description()}
</Text>
</div>
<Anchor component={Link} to={'/kitchen/dietary' as any} size="sm">
{m.profile_edit_dietary_profile()}
</Anchor>
</Group>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,896 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Tabs,
Table,
Button,
Anchor,
Loader,
Center,
Drawer,
TextInput,
Select,
Textarea,
NumberInput,
SimpleGrid,
} from '@pikku/mantine/core'
import { CalendarDays, Users, ClipboardList, Plus, UtensilsCrossed, Bell } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { UserSelect } from '@/components/user-select'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/retreats/$retreatId')({
component: RetreatDetailPage,
})
const statusColor: Record<string, string> = {
draft: 'gray',
published: 'blue',
active: 'teal',
completed: 'green',
cancelled: 'red',
}
const roleColor: Record<string, string> = {
participant: 'teal',
facilitator: 'violet',
assistant_facilitator: 'grape',
organizer: 'orange',
support_staff: 'blue',
}
const mealTypeColor: Record<string, string> = {
breakfast: 'yellow',
lunch: 'orange',
dinner: 'violet',
snack: 'pink',
}
const mealStatusColor: Record<string, string> = {
planned: 'gray',
ready: 'blue',
served: 'teal',
completed: 'green',
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatTime(d: string | Date): string {
return new Date(d).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function RetreatDetailPage() {
useLocale()
const { retreatId } = Route.useParams()
const queryClient = useQueryClient()
const [personDrawerOpened, { open: openPersonDrawer, close: closePersonDrawer }] =
useDisclosure(false)
const [scheduleDrawerOpened, { open: openScheduleDrawer, close: closeScheduleDrawer }] =
useDisclosure(false)
const [mealDrawerOpened, { open: openMealDrawer, close: closeMealDrawer }] =
useDisclosure(false)
const [notifyDrawerOpened, { open: openNotifyDrawer, close: closeNotifyDrawer }] =
useDisclosure(false)
// Person form state
const [personUserId, setPersonUserId] = useState('')
const [personRole, setPersonRole] = useState<string | null>(null)
// Schedule form state
const [scheduleTitle, setScheduleTitle] = useState('')
const [scheduleDescription, setScheduleDescription] = useState('')
const [scheduleStartsAt, setScheduleStartsAt] = useState('')
const [scheduleEndsAt, setScheduleEndsAt] = useState('')
const [scheduleLocation, setScheduleLocation] = useState('')
const [scheduleFacilitatorId, setScheduleFacilitatorId] = useState('')
// Meal form state
const [mealServiceType, setMealServiceType] = useState<string | null>(null)
const [mealServiceDate, setMealServiceDate] = useState('')
const [mealHeadcount, setMealHeadcount] = useState<number | string>('')
const [mealMenuNotes, setMealMenuNotes] = useState('')
// Notify form state
const [notifyTitle, setNotifyTitle] = useState('')
const [notifyBody, setNotifyBody] = useState('')
const { data, isLoading } = usePikkuQuery('listRetreats', { limit: 50, offset: 0 })
const { data: personsData, isLoading: personsLoading } = usePikkuQuery('listRetreatPersons', {
retreatId,
})
const { data: scheduleData, isLoading: scheduleLoading } = usePikkuQuery(
'listRetreatSchedule',
{ retreatId },
)
const { data: mealsData, isLoading: mealsLoading } = usePikkuQuery('listRetreatMeals', {
retreatId,
limit: 100,
offset: 0,
})
const addPersonMutation = usePikkuMutation('addRetreatPerson', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreatPersons'] })
notifications.show({
title: 'Person added',
message: 'Person has been added to the retreat.',
color: 'green',
})
resetPersonForm()
closePersonDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to add person.',
color: 'red',
})
},
})
const addScheduleMutation = usePikkuMutation('addRetreatScheduleItem', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreatSchedule'] })
notifications.show({
title: 'Schedule item added',
message: 'Schedule item has been added to the retreat.',
color: 'green',
})
resetScheduleForm()
closeScheduleDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to add schedule item.',
color: 'red',
})
},
})
const addMealMutation = usePikkuMutation('createRetreatMeal', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreatMeals'] })
notifications.show({
title: 'Meal added',
message: 'Meal service has been added to the retreat.',
color: 'green',
})
resetMealForm()
closeMealDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to add meal service.',
color: 'red',
})
},
})
const notifyMutation = usePikkuMutation('notifyRetreatParticipants', {
onSuccess: (result) => {
notifications.show({
title: 'Notifications sent',
message: `Notified ${result.notified} participant(s).`,
color: 'green',
})
resetNotifyForm()
closeNotifyDrawer()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to send notifications.',
color: 'red',
})
},
})
function resetPersonForm() {
setPersonUserId('')
setPersonRole(null)
}
function resetScheduleForm() {
setScheduleTitle('')
setScheduleDescription('')
setScheduleStartsAt('')
setScheduleEndsAt('')
setScheduleLocation('')
setScheduleFacilitatorId('')
}
function resetMealForm() {
setMealServiceType(null)
setMealServiceDate('')
setMealHeadcount('')
setMealMenuNotes('')
}
function resetNotifyForm() {
setNotifyTitle('')
setNotifyBody('')
}
function handleAddPerson() {
if (!personRole) return
addPersonMutation.mutate({
retreatId,
...(personUserId.trim() ? { userId: personUserId.trim() } : {}),
roleType: personRole as
| 'participant'
| 'facilitator'
| 'assistant_facilitator'
| 'organizer'
| 'support_staff',
})
}
function handleAddScheduleItem() {
if (!scheduleTitle.trim() || !scheduleStartsAt || !scheduleEndsAt) return
addScheduleMutation.mutate({
retreatId,
title: scheduleTitle.trim(),
...(scheduleDescription.trim() ? { description: scheduleDescription.trim() } : {}),
startsAt: new Date(scheduleStartsAt).toISOString(),
endsAt: new Date(scheduleEndsAt).toISOString(),
...(scheduleLocation.trim() ? { location: scheduleLocation.trim() } : {}),
...(scheduleFacilitatorId.trim()
? { leadRetreatPersonId: scheduleFacilitatorId.trim() }
: {}),
})
}
function handleAddMeal() {
if (!mealServiceType || !mealServiceDate) return
addMealMutation.mutate({
retreatId,
serviceType: mealServiceType as 'breakfast' | 'lunch' | 'dinner' | 'snack',
serviceDate: mealServiceDate,
...(typeof mealHeadcount === 'number' && mealHeadcount > 0
? { plannedHeadcount: mealHeadcount }
: {}),
...(mealMenuNotes.trim() ? { menuNotes: mealMenuNotes.trim() } : {}),
})
}
function handleNotify() {
if (!notifyTitle.trim() || !notifyBody.trim()) return
notifyMutation.mutate({
retreatId,
title: notifyTitle.trim(),
body: notifyBody.trim(),
})
}
const retreat = (data?.items ?? []).find((r) => r.retreatId === retreatId) ?? null
const people = personsData?.items ?? []
const schedule = scheduleData?.items ?? []
const meals = mealsData?.items ?? []
// Group meals by date
const mealsByDate: Record<string, typeof meals> = {}
for (const meal of meals) {
const dateKey = new Date(meal.serviceDate).toISOString().split('T')[0]
if (!mealsByDate[dateKey]) mealsByDate[dateKey] = []
mealsByDate[dateKey].push(meal)
}
const sortedMealDates = Object.keys(mealsByDate).sort()
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
if (!retreat) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.retreats_not_found_title()}
</Title>
<Text c="dimmed">{m.retreats_not_found_body()}</Text>
<Anchor component={Link} to="/retreats" size="sm">
{m.retreats_back()}
</Anchor>
</Stack>
)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Group gap="sm" align="center">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(retreat.name)}
</Title>
<Badge color={statusColor[retreat.status] ?? 'gray'} variant="light" size="lg">
{asI18n(retreat.status)}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={4}>
{asI18n(
`${formatDate(retreat.startAt)} ${formatDate(retreat.endAt)}${retreat.capacity != null ? ` | Capacity: ${retreat.capacity}` : ''}`,
)}
</Text>
</div>
<Group gap="xs">
<Button
size="sm"
variant="light"
color="violet"
leftSection={<Bell size={14} />}
onClick={openNotifyDrawer}
>
{m.retreats_notify_participants()}
</Button>
<Anchor component={Link} to="/retreats" size="sm">
{m.retreats_back()}
</Anchor>
</Group>
</Group>
<Tabs defaultValue="overview" color="teal">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<CalendarDays size={16} />}>
{m.retreats_tab_overview()}
</Tabs.Tab>
<Tabs.Tab value="people" leftSection={<Users size={16} />}>
{asI18n(`${m.retreats_tab_people()} (${people.length})`)}
</Tabs.Tab>
<Tabs.Tab value="schedule" leftSection={<ClipboardList size={16} />}>
{asI18n(`${m.retreats_tab_schedule()} (${schedule.length})`)}
</Tabs.Tab>
<Tabs.Tab value="meals" leftSection={<UtensilsCrossed size={16} />}>
{asI18n(`${m.retreats_tab_meals()} (${meals.length})`)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="sm">
<Group gap="lg">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_status()}
</Text>
<Badge color={statusColor[retreat.status] ?? 'gray'} variant="light" mt={4}>
{asI18n(retreat.status)}
</Badge>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_slug()}
</Text>
<Text size="sm" mt={4}>
{asI18n(retreat.slug)}
</Text>
</div>
{retreat.capacity != null && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_capacity()}
</Text>
<Text size="sm" mt={4}>
{asI18n(String(retreat.capacity))}
</Text>
</div>
)}
</Group>
{retreat.description && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.retreats_description()}
</Text>
<Text size="sm" mt={4}>
{asI18n(retreat.description)}
</Text>
</div>
)}
</Stack>
</Card>
</Tabs.Panel>
<Tabs.Panel value="people" pt="md">
<Stack gap="md">
<Group justify="flex-end">
<Button
size="sm"
variant="light"
color="teal"
leftSection={<Plus size={14} />}
onClick={openPersonDrawer}
>
{m.retreats_add_person()}
</Button>
</Group>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
{personsLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : people.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{m.retreats_no_people()}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.retreats_person_name()}</Table.Th>
<Table.Th>{m.retreats_role()}</Table.Th>
<Table.Th>{m.retreats_status()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{people.map((person) => (
<Table.Tr key={person.retreatPersonId}>
<Table.Td>{person.fullName ?? person.email ?? 'Unknown'}</Table.Td>
<Table.Td>
<Badge
color={roleColor[person.roleType] ?? 'gray'}
variant="light"
size="sm"
>
{asI18n(person.roleType?.replace(/_/g, ' ') ?? '')}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="dot" color="teal" size="sm">
{asI18n(person.attendanceStatus)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="schedule" pt="md">
<Stack gap="md">
<Group justify="flex-end">
<Button
size="sm"
variant="light"
color="teal"
leftSection={<Plus size={14} />}
onClick={openScheduleDrawer}
>
{m.retreats_add_schedule_item()}
</Button>
</Group>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
{scheduleLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : schedule.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{m.retreats_no_schedule()}
</Text>
) : (
<Stack gap="sm">
{schedule.map((item) => (
<Group key={item.itemId} gap="md" align="flex-start">
<Text size="sm" fw={600} style={{ minWidth: 140 }}>
{asI18n(formatTime(item.startsAt))}
</Text>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{asI18n(item.title)}
</Text>
{item.description && (
<Text size="xs" c="dimmed">
{asI18n(item.description)}
</Text>
)}
{item.location && (
<Text size="xs" c="dimmed">
{asI18n(`${m.retreats_location()}: ${item.location}`)}
</Text>
)}
</div>
<Text size="xs" c="dimmed">
{asI18n(formatTime(item.endsAt))}
</Text>
</Group>
))}
</Stack>
)}
</Card>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="meals" pt="md">
<Stack gap="md">
<Group justify="flex-end">
<Button
size="sm"
variant="light"
color="teal"
leftSection={<Plus size={14} />}
onClick={openMealDrawer}
>
{m.retreats_add_meal()}
</Button>
</Group>
{mealsLoading ? (
<Center py="xl">
<Loader color="teal" size="sm" />
</Center>
) : meals.length === 0 ? (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="sm" c="dimmed" ta="center" py="xl">
{m.retreats_no_meals()}
</Text>
</Card>
) : (
sortedMealDates.map((dateKey) => (
<div key={dateKey}>
<Text
size="sm"
fw={700}
mb="xs"
style={{
fontFamily: '"Cormorant", serif',
fontSize: 18,
color: '#3D2B1F',
}}
>
{asI18n(formatDate(dateKey))}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="md">
{mealsByDate[dateKey].map((meal) => (
<Card
key={meal.serviceId}
padding="md"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="xs">
<Group justify="space-between">
<Badge
color={mealTypeColor[meal.serviceType] ?? 'gray'}
variant="light"
size="sm"
tt="capitalize"
>
{asI18n(meal.serviceType)}
</Badge>
<Badge
color={mealStatusColor[meal.status] ?? 'gray'}
variant="dot"
size="sm"
>
{asI18n(meal.status)}
</Badge>
</Group>
{meal.plannedHeadcount != null && (
<Text size="xs" c="dimmed">
{asI18n(`${m.retreats_headcount()}: ${meal.plannedHeadcount}`)}
</Text>
)}
{meal.menuNotes && (
<Text size="xs" c="dimmed" lineClamp={3}>
{asI18n(meal.menuNotes)}
</Text>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</div>
))
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Stack>
<Drawer
opened={personDrawerOpened}
onClose={closePersonDrawer}
title={m.retreats_add_person_title()}
position="right"
size="md"
>
<Stack gap="md">
<UserSelect
label="User"
placeholder="Search for a user..."
value={personUserId || null}
onChange={(val) => setPersonUserId(val ?? '')}
clearable
/>
<Select
label={m.retreats_role()}
placeholder={asI18n('Select role')}
data={[
{ value: 'participant', label: 'Participant' },
{ value: 'facilitator', label: 'Facilitator' },
{ value: 'assistant_facilitator', label: 'Assistant Facilitator' },
{ value: 'organizer', label: 'Organizer' },
{ value: 'support_staff', label: 'Support Staff' },
]}
value={personRole}
onChange={setPersonRole}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closePersonDrawer}>
{m.common_cancel()}
</Button>
<Button
color="teal"
onClick={handleAddPerson}
loading={addPersonMutation.isPending}
disabled={!personRole}
>
{m.retreats_add_person()}
</Button>
</Group>
</Stack>
</Drawer>
<Drawer
opened={scheduleDrawerOpened}
onClose={closeScheduleDrawer}
title={m.retreats_add_schedule_item()}
position="right"
size="md"
>
<Stack gap="md">
<TextInput
label={m.retreats_schedule_title()}
placeholder={asI18n('Schedule item title')}
value={scheduleTitle}
onChange={(e) => setScheduleTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.retreats_description()}
placeholder={asI18n('Optional description')}
rows={3}
value={scheduleDescription}
onChange={(e) => setScheduleDescription(e.currentTarget.value)}
/>
<TextInput
label={m.retreats_starts_at()}
type="datetime-local"
value={scheduleStartsAt}
onChange={(e) => setScheduleStartsAt(e.currentTarget.value)}
required
/>
<TextInput
label={m.retreats_ends_at()}
type="datetime-local"
value={scheduleEndsAt}
onChange={(e) => setScheduleEndsAt(e.currentTarget.value)}
min={scheduleStartsAt || undefined}
required
/>
<TextInput
label={m.retreats_location()}
placeholder={asI18n('Optional location')}
value={scheduleLocation}
onChange={(e) => setScheduleLocation(e.currentTarget.value)}
/>
<Select
label={m.retreats_lead_facilitator()}
placeholder={asI18n('Select a retreat participant...')}
data={people.map((p) => ({
value: p.retreatPersonId,
label: p.fullName ?? p.email ?? p.roleType?.replace(/_/g, ' ') ?? 'Unknown',
}))}
value={scheduleFacilitatorId || null}
onChange={(val) => setScheduleFacilitatorId(val ?? '')}
searchable
clearable
nothingFoundMessage={asI18n('No people added to retreat')}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeScheduleDrawer}>
{m.common_cancel()}
</Button>
<Button
color="teal"
onClick={handleAddScheduleItem}
loading={addScheduleMutation.isPending}
disabled={!scheduleTitle.trim() || !scheduleStartsAt || !scheduleEndsAt}
>
{m.retreats_add_schedule_item()}
</Button>
</Group>
</Stack>
</Drawer>
<Drawer
opened={mealDrawerOpened}
onClose={closeMealDrawer}
title={m.retreats_add_meal_service()}
position="right"
size="md"
>
<Stack gap="md">
<Select
label={m.retreats_service_type()}
placeholder={asI18n('Select meal type')}
data={[
{ value: 'breakfast', label: 'Breakfast' },
{ value: 'lunch', label: 'Lunch' },
{ value: 'dinner', label: 'Dinner' },
{ value: 'snack', label: 'Snack' },
]}
value={mealServiceType}
onChange={setMealServiceType}
required
/>
<TextInput
label={m.retreats_service_date()}
type="date"
value={mealServiceDate}
onChange={(e) => setMealServiceDate(e.currentTarget.value)}
required
/>
<NumberInput
label={m.retreats_planned_headcount()}
placeholder={asI18n('Expected number of diners')}
min={1}
value={mealHeadcount}
onChange={setMealHeadcount}
/>
<Textarea
label={m.retreats_menu_notes()}
placeholder={asI18n('Optional menu description or notes')}
rows={3}
value={mealMenuNotes}
onChange={(e) => setMealMenuNotes(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeMealDrawer}>
{m.common_cancel()}
</Button>
<Button
color="teal"
onClick={handleAddMeal}
loading={addMealMutation.isPending}
disabled={!mealServiceType || !mealServiceDate}
>
{m.retreats_add_meal()}
</Button>
</Group>
</Stack>
</Drawer>
<Drawer
opened={notifyDrawerOpened}
onClose={closeNotifyDrawer}
title={m.retreats_notify_title()}
position="right"
size="md"
>
<Stack gap="md">
<TextInput
label={m.retreats_notify_subject()}
placeholder={asI18n('Notification title')}
value={notifyTitle}
onChange={(e) => setNotifyTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.retreats_notify_body()}
placeholder={asI18n('Notification message')}
rows={5}
value={notifyBody}
onChange={(e) => setNotifyBody(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeNotifyDrawer}>
{m.common_cancel()}
</Button>
<Button
color="violet"
onClick={handleNotify}
loading={notifyMutation.isPending}
disabled={!notifyTitle.trim() || !notifyBody.trim()}
leftSection={<Bell size={14} />}
>
{m.retreats_send_notification()}
</Button>
</Group>
</Stack>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,314 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
SimpleGrid,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Center,
Drawer,
TextInput,
Textarea,
NumberInput,
Alert,
Title,
} from '@pikku/mantine/core'
import { CalendarDays, Plus, User, Users, AlertCircle } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/retreats/')({
component: RetreatsPage,
})
const statusColor: Record<string, string> = {
draft: 'gray',
published: 'blue',
active: 'teal',
completed: 'green',
cancelled: 'red',
}
const statusLabel: Record<string, string> = {
draft: 'Draft',
published: 'Open',
active: 'Active',
completed: 'Completed',
cancelled: 'Cancelled',
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function CreateRetreatDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [description, setDescription] = useState('')
const [startAt, setStartAt] = useState('')
const [endAt, setEndAt] = useState('')
const [capacity, setCapacity] = useState<number | string>('')
const handleNameChange = (val: string) => {
setName(val)
setSlug(
val
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
)
}
const resetForm = () => {
setName('')
setSlug('')
setDescription('')
setStartAt('')
setEndAt('')
setCapacity('')
}
const mutation = usePikkuMutation('createRetreat', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRetreats'] })
notifications.show({
title: 'Retreat created',
message: 'Retreat has been created successfully.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create retreat.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({
name,
slug,
description: description || undefined,
startAt: startAt ? new Date(startAt).toISOString() : '',
endAt: endAt ? new Date(endAt).toISOString() : '',
capacity: capacity ? Number(capacity) : undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.retreats_create()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{asI18n(mutation.error?.message ?? 'Failed to create retreat')}
</Alert>
)}
<TextInput
label={m.retreats_name()}
placeholder={asI18n('Spring Equinox Retreat')}
required
value={name}
onChange={(e) => handleNameChange(e.currentTarget.value)}
/>
<TextInput
label={m.retreats_slug()}
placeholder={asI18n('spring-equinox-retreat')}
required
value={slug}
onChange={(e) => setSlug(e.currentTarget.value)}
/>
<Textarea
label={m.retreats_description()}
placeholder={asI18n('Describe the retreat...')}
minRows={3}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<TextInput
label={m.retreats_start_date()}
type="date"
value={startAt}
onChange={(e) => setStartAt(e.currentTarget.value)}
required
/>
<TextInput
label={m.retreats_end_date()}
type="date"
value={endAt}
onChange={(e) => setEndAt(e.currentTarget.value)}
min={startAt || undefined}
required
/>
<NumberInput
label={m.retreats_capacity()}
placeholder={asI18n('Maximum participants')}
min={1}
value={capacity}
onChange={setCapacity}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.retreats_create()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function RetreatsPage() {
useLocale()
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const { data, isLoading } = usePikkuQuery('listRetreats', { limit: 50, offset: 0 })
const retreats = data?.items ?? []
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.retreats_title()}
description={m.retreats_subtitle()}
action={
<Button
onClick={() => setCreateDrawerOpen(true)}
leftSection={<Plus size={16} />}
color="teal"
radius="md"
>
{m.retreats_create()}
</Button>
}
/>
{retreats.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<Stack align="center" gap="sm">
<CalendarDays size={48} strokeWidth={1} color="#aaa" />
<Text c="dimmed">{m.retreats_empty()}</Text>
</Stack>
</Card>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{retreats.map((retreat) => (
<Card
key={retreat.retreatId}
padding="lg"
radius="md"
component={Link}
to="/retreats/$retreatId"
params={{ retreatId: retreat.retreatId } as never}
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
textDecoration: 'none',
cursor: 'pointer',
}}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Title
order={4}
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{asI18n(retreat.name)}
</Title>
<Badge
color={statusColor[retreat.status] ?? 'gray'}
variant="light"
size="sm"
>
{asI18n(statusLabel[retreat.status] ?? retreat.status)}
</Badge>
</Group>
{retreat.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{asI18n(retreat.description)}
</Text>
)}
<Text size="sm" c="dimmed">
{asI18n(`${formatDate(retreat.startAt)} ${formatDate(retreat.endAt)}`)}
</Text>
{retreat.facilitatorName && (
<Group gap="xs">
<User size={14} strokeWidth={1.5} color="#2A7B88" />
<Text size="sm" fw={500} c="teal">
{asI18n(retreat.facilitatorName)}
</Text>
</Group>
)}
{retreat.capacity != null && (
<Group gap="xs">
<Users size={14} strokeWidth={1.5} color="#888" />
<Text size="sm" c="dimmed">
{asI18n(`${m.retreats_capacity()}: ${retreat.capacity}`)}
</Text>
</Group>
)}
</Stack>
</Card>
))}
</SimpleGrid>
)}
<CreateRetreatDrawer
opened={createDrawerOpen}
onClose={() => setCreateDrawerOpen(false)}
/>
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/retreats')({
component: Outlet,
})

View File

@@ -0,0 +1,115 @@
import { createFileRoute } from '@tanstack/react-router'
import { Stack, Loader, Center, Text } from '@pikku/mantine/core'
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
import { PageHeader } from '@perauset/components'
import { StatusBadge } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/rooms/allocations')({
validateSearch: (search: Record<string, unknown>): { roomId?: string } => ({
roomId: typeof search.roomId === 'string' ? search.roomId : undefined,
}),
component: RoomAllocationsPage,
})
type RoomAllocation = {
allocationId: string
roomId: string
stayId: string
startsAt: string | Date
endsAt: string | Date
status: string
allocationReason: string | null
createdByUserId: string
createdAt: string | Date
}
function formatDate(dateStr: string | Date): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return String(dateStr)
}
}
function AllocationsTable({ allocations }: { allocations: RoomAllocation[] }) {
const { data: roomsData } = usePikkuQuery('listRooms', { limit: 100, offset: 0 })
const roomMap = new Map((roomsData?.items ?? []).map((r) => [r.roomId, r]))
const columns: Column<RoomAllocation>[] = [
{
key: 'roomId',
label: m.rooms_room(),
render: (row) => {
const room = roomMap.get(row.roomId)
return (
<Text size="sm" truncate>
{asI18n(room ? `${room.name} (${room.code})` : 'Room')}
</Text>
)
},
},
{
key: 'dates',
label: m.rooms_dates(),
render: (row) => (
<Text size="sm">
{asI18n(`${formatDate(row.startsAt)} - ${formatDate(row.endsAt)}`)}
</Text>
),
},
{
key: 'status',
label: m.rooms_status(),
render: (row) => <StatusBadge status={row.status} />,
},
{
key: 'reason',
label: m.rooms_reason(),
render: (row) => (
<Text size="sm" c="dimmed" lineClamp={1}>
{asI18n(row.allocationReason ?? '-')}
</Text>
),
},
]
return (
<DataTable
data={allocations}
columns={columns}
rowKey={(row) => row.allocationId}
emptyMessage="No room allocations found."
/>
)
}
function RoomAllocationsPage() {
useLocale()
const { roomId } = Route.useSearch()
const { data, isLoading } = usePikkuQuery('listRoomAllocations', { roomId, limit: 50, offset: 0 })
const allocations = (data?.items ?? []) as RoomAllocation[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.rooms_allocations_title()}
description={roomId ? m.rooms_allocations_room_description() : m.rooms_allocations_all_description()}
/>
<AllocationsTable allocations={allocations} />
</Stack>
)
}

View File

@@ -0,0 +1,314 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Stack,
SimpleGrid,
Card,
Text,
Group,
Badge,
Loader,
Center,
Drawer,
TextInput,
NumberInput,
Select,
Textarea,
Button,
ActionIcon,
Tooltip,
} from '@pikku/mantine/core'
import { Pencil, Search, Plus } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
type RoomType = 'dorm' | 'facilitator' | 'private' | 'shared' | 'staff'
export const Route = createFileRoute('/_authenticated/rooms/')({
component: RoomsPage,
})
type Room = {
roomId: string
code: string
name: string
roomType: string
capacity: number
pricePerNight: number | null
isActive: boolean
notes: string | null
}
const ROOM_TYPES = [
{ value: 'dorm', label: 'Dorm' },
{ value: 'facilitator', label: 'Facilitator' },
{ value: 'private', label: 'Private' },
{ value: 'shared', label: 'Shared' },
{ value: 'staff', label: 'Staff' },
]
const cardStyle = {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function RoomActions({ onCreateRoom }: { onCreateRoom?: () => void }) {
const canManage = usePermission('rooms.manage')
if (!canManage) return null
return (
<Group gap="sm">
<Button component={Link} to="/rooms/allocations" variant="light" color="teal" size="sm">
{m.rooms_view_allocations()}
</Button>
<Button leftSection={<Plus size={16} />} color="teal" size="sm" onClick={onCreateRoom}>
{m.rooms_create_room()}
</Button>
</Group>
)
}
function CreateRoomDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [code, setCode] = useState('')
const [name, setName] = useState('')
const [roomType, setRoomType] = useState<RoomType | null>(null)
const [capacity, setCapacity] = useState<number>(1)
const [pricePerNight, setPricePerNight] = useState<number | undefined>(undefined)
const [notes, setNotes] = useState('')
const resetForm = () => {
setCode('')
setName('')
setRoomType(null)
setCapacity(1)
setPricePerNight(undefined)
setNotes('')
}
const mutation = usePikkuMutation('createRoom', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRooms'] })
notifications.show({ title: 'Room created', message: 'New room has been added.', color: 'green' })
resetForm()
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create room.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!code || !name || !roomType) {
notifications.show({ title: 'Missing fields', message: 'Code, name, and room type are required.', color: 'red' })
return
}
mutation.mutate({
code,
name,
roomType,
capacity,
pricePerNight: pricePerNight || undefined,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.rooms_create_room()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.rooms_room_code()} placeholder={m.rooms_room_code_placeholder()} value={code} onChange={(e) => setCode(e.currentTarget.value)} required />
<TextInput label={m.rooms_room_name()} placeholder={m.rooms_room_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<Select label={m.rooms_room_type()} placeholder={m.rooms_select_type()} data={ROOM_TYPES} value={roomType} onChange={(v) => setRoomType(v as RoomType | null)} required />
<NumberInput label={m.rooms_capacity()} value={capacity} onChange={(val) => setCapacity(Number(val) || 1)} min={1} />
<NumberInput label={m.rooms_price_per_night()} placeholder={m.rooms_price_placeholder()} value={pricePerNight} onChange={(val) => setPricePerNight(val ? Number(val) : undefined)} min={0} decimalScale={2} prefix={asI18n('€')} />
<Textarea label={m.rooms_notes()} placeholder={m.rooms_notes_placeholder()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>{m.rooms_save_room()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditRoomDrawer({ room, onClose }: { room: Room | null; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState(room?.name ?? '')
const [roomType, setRoomType] = useState<string | null>(room?.roomType ?? null)
const [capacity, setCapacity] = useState<number>(room?.capacity ?? 1)
const [notes, setNotes] = useState(room?.notes ?? '')
const mutation = usePikkuMutation('updateRoom', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listRooms'] })
notifications.show({ title: 'Room updated', message: 'Room details have been saved.', color: 'green' })
onClose()
},
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update room.', color: 'red' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!room) return
mutation.mutate({
roomId: room.roomId,
name,
roomType: roomType ?? undefined,
capacity,
notes: notes || undefined,
})
}
if (!room) return null
return (
<Drawer position="right" size="md" opened={!!room} onClose={onClose} title={m.rooms_edit_room()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput label={m.rooms_room_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
<Select label={m.rooms_room_type()} data={ROOM_TYPES} value={roomType} onChange={setRoomType} required />
<NumberInput label={m.rooms_capacity()} value={capacity} onChange={(val) => setCapacity(Number(val) || 1)} min={1} />
<Textarea label={m.rooms_notes()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function RoomsPage() {
useLocale()
const canManage = usePermission('rooms.manage')
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | null>(null)
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const { data, isLoading } = usePikkuQuery('listRooms', { limit: 50, offset: 0 })
const { data: availabilityData } = usePikkuQuery(
'checkRoomAvailability',
{ startDate, endDate },
{ enabled: !!startDate && !!endDate },
)
const availabilityMap = new Map((availabilityData?.rooms ?? []).map((r) => [r.roomId, r]))
const rooms = data?.items ?? []
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.rooms_title()}
description={m.rooms_description()}
action={<RoomActions onCreateRoom={() => setCreateModalOpen(true)} />}
/>
<Card padding="md" radius="md" style={cardStyle}>
<Group gap="md" align="flex-end">
<TextInput type="date" label={m.rooms_availability_start()} value={startDate} onChange={(e) => setStartDate(e.currentTarget.value)} size="sm" />
<TextInput type="date" label={m.rooms_availability_end()} value={endDate} onChange={(e) => setEndDate(e.currentTarget.value)} size="sm" min={startDate || undefined} />
{startDate && endDate && (
<Badge color="teal" variant="light" size="lg">
<Search size={12} style={{ marginRight: 4 }} />
{m.rooms_showing_availability()}
</Badge>
)}
</Group>
</Card>
{rooms.length === 0 ? (
<Card padding="xl" radius="md" style={cardStyle}>
<Text size="sm" c="dimmed" ta="center">
{m.rooms_no_rooms()}
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
{rooms.map((room) => {
const availability = availabilityMap.get(room.roomId)
return (
<Card key={room.roomId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
<Group justify="space-between" align="flex-start" mb="sm">
<Link to="/rooms/allocations" search={{ roomId: room.roomId }} style={{ textDecoration: 'none', flex: 1 }}>
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(room.name)}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{asI18n(room.code)}
</Text>
</Link>
<Group gap="xs">
<Badge color={room.isActive ? 'green' : 'gray'} variant="light" size="sm">
{room.isActive ? m.rooms_active() : m.rooms_inactive()}
</Badge>
{canManage && (
<Tooltip label={m.rooms_edit_room()}>
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingRoom(room)}>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Group gap="lg" mt="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_type()}</Text>
<Text size="sm">{asI18n(formatType(room.roomType))}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_capacity()}</Text>
<Text size="sm">{room.capacity}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_price_night()}</Text>
<Text size="sm" fw={500}>{asI18n(room.pricePerNight != null ? `${Number(room.pricePerNight).toFixed(2)}` : '—')}</Text>
</div>
{availability && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_availability()}</Text>
<Badge color={availability.available ? 'green' : 'red'} variant="filled" size="sm">
{availability.available ? m.rooms_available() : m.rooms_full()}
</Badge>
</div>
)}
</Group>
{room.notes && (
<Text size="xs" c="dimmed" mt="sm" lineClamp={2}>
{asI18n(room.notes)}
</Text>
)}
</Card>
)
})}
</SimpleGrid>
)}
<CreateRoomDrawer opened={createModalOpen} onClose={() => setCreateModalOpen(false)} />
<EditRoomDrawer room={editingRoom} onClose={() => setEditingRoom(null)} />
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/rooms')({
component: Outlet,
})

View File

@@ -0,0 +1,320 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Stack,
Button,
Loader,
Center,
Group,
Select,
Textarea,
TextInput,
Drawer,
Text,
Alert,
} from '@pikku/mantine/core'
import { Plus, Info } from 'lucide-react'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { StaysTabs } from '@/components/stays-tabs'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
export const Route = createFileRoute('/_authenticated/stays')({
component: StaysPage,
})
type RequestType = 'day_visit' | 'facilitator' | 'guest' | 'staff' | 'volunteer'
const REQUEST_TYPES: { value: RequestType; labelKey: string }[] = [
{ value: 'day_visit', labelKey: 'stays.type_day_visit' },
{ value: 'facilitator', labelKey: 'stays.type_facilitator' },
{ value: 'guest', labelKey: 'stays.type_guest' },
{ value: 'staff', labelKey: 'stays.type_staff' },
{ value: 'volunteer', labelKey: 'stays.type_volunteer' },
]
function RequestStayDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [requestType, setRequestType] = useState<string | null>(null)
const [startAt, setStartAt] = useState('')
const [endAt, setEndAt] = useState('')
const [notes, setNotes] = useState('')
const resetForm = () => {
setRequestType(null)
setStartAt('')
setEndAt('')
setNotes('')
}
const mutation = usePikkuMutation('requestStay', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listStays'] })
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
notifications.show({
title: m.stays_request_submitted(),
message: m.stays_request_submitted_message(),
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.common_error(),
message: err.message ? asI18n(err.message) : m.stays_something_went_wrong(),
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!requestType || !startAt || !endAt) {
notifications.show({
title: m.stays_missing_fields(),
message: m.stays_missing_fields_message(),
color: 'red',
})
return
}
mutation.mutate({
requestType: requestType as RequestType,
requestedStartAt: startAt,
requestedEndAt: endAt,
notes: notes || undefined,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.stays_request_stay()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Select
label={m.stays_request_type()}
placeholder={m.stays_select_type()}
data={REQUEST_TYPES.map((t) => ({ value: t.value, label: String(mKey(t.labelKey)) }))}
value={requestType}
onChange={setRequestType}
required
/>
<TextInput
type="date"
label={m.stays_start_date()}
value={startAt}
onChange={(e) => setStartAt(e.currentTarget.value)}
required
/>
<TextInput
type="date"
label={m.stays_end_date()}
value={endAt}
onChange={(e) => setEndAt(e.currentTarget.value)}
required
/>
<Textarea
label={m.stays_notes()}
placeholder={m.stays_notes_placeholder()}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={3}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.stays_submit_request()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function CreateStayDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [requestType, setRequestType] = useState<string | null>(null)
const [startAt, setStartAt] = useState('')
const [endAt, setEndAt] = useState('')
const [notes, setNotes] = useState('')
const [isPending, setIsPending] = useState(false)
const resetForm = () => {
setRequestType(null)
setStartAt('')
setEndAt('')
setNotes('')
}
const requestStay = usePikkuMutation('requestStay')
const approveStayRequest = usePikkuMutation('approveStayRequest')
const createStayFromRequest = usePikkuMutation('createStayFromRequest')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!requestType || !startAt || !endAt) {
notifications.show({
title: m.stays_missing_fields(),
message: m.stays_missing_fields_message(),
color: 'red',
})
return
}
setIsPending(true)
try {
const requestResult = await requestStay.mutateAsync({
requestType: requestType as RequestType,
requestedStartAt: startAt,
requestedEndAt: endAt,
notes: notes || undefined,
})
await approveStayRequest.mutateAsync({ requestId: requestResult.requestId })
await createStayFromRequest.mutateAsync({ requestId: requestResult.requestId })
queryClient.invalidateQueries({ queryKey: ['listStays'] })
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
notifications.show({
title: m.stays_stay_created(),
message: m.stays_stay_created_message(),
color: 'green',
})
resetForm()
onClose()
} catch (err) {
notifications.show({
title: m.common_error(),
message: err instanceof Error && err.message ? asI18n(err.message) : m.stays_create_failed(),
color: 'red',
})
} finally {
setIsPending(false)
}
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.stays_create_stay()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Alert icon={<Info size={16} />} color="teal" variant="light">
<Text size="sm">{m.stays_create_stay_hint()}</Text>
</Alert>
<Select
label={m.stays_stay_type()}
placeholder={m.stays_select_type()}
data={REQUEST_TYPES.map((t) => ({ value: t.value, label: String(mKey(t.labelKey)) }))}
value={requestType}
onChange={setRequestType}
required
/>
<TextInput
type="date"
label={m.stays_start_date()}
value={startAt}
onChange={(e) => setStartAt(e.currentTarget.value)}
required
/>
<TextInput
type="date"
label={m.stays_end_date()}
value={endAt}
onChange={(e) => setEndAt(e.currentTarget.value)}
required
/>
<Textarea
label={m.stays_notes()}
placeholder={m.stays_notes_placeholder()}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
minRows={3}
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={isPending}>
{m.stays_create_stay()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function StaysPage() {
useLocale()
const canManage = usePermission('stays.manage')
const [requestDrawerOpen, setRequestDrawerOpen] = useState(false)
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const { data: staysData, isLoading: staysLoading } = usePikkuQuery('listStays', {
limit: 50,
offset: 0,
})
const { data: requestsData, isLoading: requestsLoading } = usePikkuQuery('listStayRequests', {
limit: 50,
offset: 0,
})
const stays = staysData?.items ?? []
const stayRequests = requestsData?.items ?? []
if (staysLoading || requestsLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
return (
<Stack gap="lg">
<PageHeader
title={m.stays_title()}
description={m.stays_description()}
action={
<Group gap="sm">
<Button onClick={() => setRequestDrawerOpen(true)} variant="light" color="teal" size="sm">
{m.stays_request_stay()}
</Button>
{canManage && (
<Button
onClick={() => setCreateDrawerOpen(true)}
leftSection={<Plus size={16} />}
color="teal"
size="sm"
>
{m.stays_create_stay()}
</Button>
)}
</Group>
}
/>
<StaysTabs stays={stays} stayRequests={stayRequests} />
<RequestStayDrawer opened={requestDrawerOpen} onClose={() => setRequestDrawerOpen(false)} />
<CreateStayDrawer opened={createDrawerOpen} onClose={() => setCreateDrawerOpen(false)} />
</Stack>
)
}

View File

@@ -0,0 +1,577 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import {
Title,
Text,
Card,
Group,
Stack,
Badge,
Button,
SimpleGrid,
Accordion,
Anchor,
Box,
Drawer,
Select,
TextInput,
Textarea,
} from '@pikku/mantine/core'
import { MapPin, Clock, Plus, Repeat, ShieldCheck } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useState } from 'react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { TaskActions } from '@/components/task-actions'
export const Route = createFileRoute('/_authenticated/tasks/')({
component: TasksPage,
})
const STATUS_CONFIG: Record<string, { color: string; order: number }> = {
open: { color: 'teal', order: 0 },
assigned: { color: 'blue', order: 1 },
in_progress: { color: 'orange', order: 2 },
blocked: { color: 'red', order: 3 },
planned: { color: 'gray', order: 4 },
completed: { color: 'green', order: 5 },
cancelled: { color: 'gray', order: 6 },
}
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
const KANBAN_STATUSES = ['open', 'assigned', 'in_progress', 'blocked']
const CATEGORIES = [
{ value: 'housekeeping', label: m.tasks_category_housekeeping() },
{ value: 'kitchen', label: m.tasks_category_kitchen() },
{ value: 'maintenance', label: m.tasks_category_maintenance() },
{ value: 'operations', label: m.tasks_category_operations() },
{ value: 'retreat', label: m.tasks_category_retreat() },
{ value: 'transport', label: m.tasks_category_transport() },
]
const ROLE_OPTIONS = [
{ value: 'admin', label: m.tasks_role_admin() },
{ value: 'coordinator', label: m.tasks_role_coordinator() },
{ value: 'facilitator', label: m.tasks_role_facilitator() },
{ value: 'boat_coordinator', label: m.tasks_role_boat_coordinator() },
{ value: 'kitchen_lead', label: m.tasks_role_kitchen_lead() },
{ value: 'staff', label: m.tasks_role_staff() },
{ value: 'volunteer', label: m.tasks_role_volunteer() },
{ value: 'community_member', label: m.tasks_role_community_member() },
]
const STATUS_LABEL: Record<string, () => string> = {
open: () => m.tasks_status_open(),
assigned: () => m.tasks_status_assigned(),
in_progress: () => m.tasks_status_in_progress(),
blocked: () => m.tasks_status_blocked(),
planned: () => m.tasks_status_planned(),
completed: () => m.tasks_status_completed(),
cancelled: () => m.tasks_status_cancelled(),
}
interface TaskItem {
taskId: string
templateId: string | null
title: string
description: string | null
category: string
status: string
location: string | null
scheduledStartAt: string | Date | null
scheduledEndAt: string | Date | null
assignedRole: string | null
createdByUserId: string
}
function formatTime(val: string | Date | null): string | null {
if (!val) return null
const d = typeof val === 'string' ? new Date(val) : val
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function statusLabel(status: string): string {
return STATUS_LABEL[status]?.() ?? status
}
function TaskCard({ task, onAssignRole }: { task: TaskItem; onAssignRole: (taskId: string) => void }) {
const cfg = STATUS_CONFIG[task.status] ?? { color: 'gray', order: 99 }
return (
<Card
padding="md"
radius="md"
className="card-hover"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Stack gap="xs">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text
fw={600}
size="sm"
lineClamp={2}
style={{ fontFamily: '"Cormorant", serif', fontSize: 16 }}
>
{asI18n(task.title)}
</Text>
<Badge size="xs" color={cfg.color} variant="light" style={{ flexShrink: 0 }}>
{asI18n(statusLabel(task.status))}
</Badge>
</Group>
<Group gap="xs">
<Badge size="xs" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
{asI18n(task.category)}
</Badge>
{task.assignedRole && (
<Badge
size="xs"
variant="light"
color="grape"
leftSection={<ShieldCheck size={10} />}
>
{asI18n(task.assignedRole.replace(/_/g, ' '))}
</Badge>
)}
</Group>
{task.location && (
<Group gap={4}>
<MapPin size={14} color="#888" />
<Text size="xs" c="dimmed">
{asI18n(task.location)}
</Text>
</Group>
)}
{task.scheduledStartAt && (
<Group gap={4}>
<Clock size={14} color="#888" />
<Text size="xs" c="dimmed">
{asI18n(
`${formatTime(task.scheduledStartAt)}${task.scheduledEndAt ? ` - ${formatTime(task.scheduledEndAt)}` : ''}`
)}
</Text>
</Group>
)}
<Group gap="xs">
<TaskActions taskId={task.taskId} status={task.status} />
{(task.status === 'open' || task.status === 'planned') && (
<Button size="xs" variant="light" color="grape" onClick={() => onAssignRole(task.taskId)}>
{m.tasks_assign_role()}
</Button>
)}
</Group>
</Stack>
</Card>
)
}
function CreateTaskDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<string | null>(null)
const [location, setLocation] = useState('')
const [scheduledStartAt, setScheduledStartAt] = useState('')
const [scheduledEndAt, setScheduledEndAt] = useState('')
const [assignedRole, setAssignedRole] = useState<string | null>(null)
const assignToRole = usePikkuMutation('assignTaskToRole')
const mutation = usePikkuMutation('createTaskInstance', {
onSuccess: async (result) => {
if (assignedRole && result.taskId) {
await assignToRole.mutateAsync({ taskId: result.taskId, role: assignedRole })
}
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_created_title(),
message: m.tasks_created_message({ title }),
color: 'teal',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message ?? m.tasks_error_generic(),
color: 'red',
})
},
})
const resetForm = () => {
setTitle('')
setDescription('')
setCategory(null)
setLocation('')
setScheduledStartAt('')
setScheduledEndAt('')
setAssignedRole(null)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !category) {
notifications.show({
title: m.tasks_validation_title(),
message: m.tasks_validation_message(),
color: 'orange',
})
return
}
mutation.mutate({
title: title.trim(),
category: category as 'housekeeping' | 'kitchen' | 'maintenance' | 'operations' | 'retreat' | 'transport',
...(description.trim() ? { description: description.trim() } : {}),
...(location.trim() ? { location: location.trim() } : {}),
...(scheduledStartAt
? { scheduledStartAt: new Date(scheduledStartAt).toISOString() }
: {}),
...(scheduledEndAt
? { scheduledEndAt: new Date(scheduledEndAt).toISOString() }
: {}),
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.tasks_create_new_task()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.tasks_field_title()}
placeholder={m.tasks_title_placeholder()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
<Textarea
label={m.tasks_field_description()}
placeholder={m.tasks_description_placeholder()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={3}
/>
<Select
label={m.tasks_category()}
placeholder={m.tasks_select_category()}
data={CATEGORIES}
value={category}
onChange={setCategory}
required
/>
<TextInput
label={m.tasks_location()}
placeholder={m.tasks_location_placeholder()}
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
/>
<Group grow>
<TextInput
label={m.tasks_scheduled_start()}
type="datetime-local"
value={scheduledStartAt}
onChange={(e) => setScheduledStartAt(e.currentTarget.value)}
/>
<TextInput
label={m.tasks_scheduled_end()}
type="datetime-local"
value={scheduledEndAt}
onChange={(e) => setScheduledEndAt(e.currentTarget.value)}
min={scheduledStartAt || undefined}
/>
</Group>
<Select
label={m.tasks_assign_to_role_optional()}
placeholder={m.tasks_select_role()}
data={ROLE_OPTIONS}
value={assignedRole}
onChange={setAssignedRole}
clearable
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.tasks_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending || assignToRole.isPending}>
{m.tasks_create_task()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function TasksPage() {
useLocale()
const queryClient = useQueryClient()
const [roleDrawerOpened, { open: openRoleDrawer, close: closeRoleDrawer }] = useDisclosure(false)
const [createDrawerOpened, { open: openCreateDrawer, close: closeCreateDrawer }] = useDisclosure(false)
const [roleTaskId, setRoleTaskId] = useState<string | null>(null)
const [selectedRole, setSelectedRole] = useState<string | null>(null)
function handleOpenRoleDrawer(taskId: string) {
setRoleTaskId(taskId)
setSelectedRole(null)
openRoleDrawer()
}
const assignRoleMutation = usePikkuMutation('assignTaskToRole', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_role_assigned_title(),
message: m.tasks_role_assigned_message(),
color: 'green',
})
closeRoleDrawer()
setRoleTaskId(null)
setSelectedRole(null)
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message || m.tasks_error_assign_role(),
color: 'red',
})
},
})
const generateMutation = usePikkuMutation('generateRecurringTasks', {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
notifications.show({
title: m.tasks_generated_title(),
message: m.tasks_generated_message({ count: result.generated }),
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: m.tasks_error_title(),
message: err.message || m.tasks_error_generate(),
color: 'red',
})
},
})
const { data, error: queryError } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
const tasks = (data?.items ?? []) as TaskItem[]
const error = queryError?.message ?? null
const grouped: Record<string, TaskItem[]> = {}
for (const status of KANBAN_STATUSES) {
grouped[status] = []
}
for (const task of tasks) {
const key = KANBAN_STATUSES.includes(task.status) ? task.status : 'open'
grouped[key]!.push(task)
}
return (
<>
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.tasks_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.tasks_description()}
</Text>
</div>
<Group gap="xs">
<Button
variant="light"
color="teal"
size="xs"
leftSection={<Repeat size={14} />}
onClick={() => generateMutation.mutate({ date: new Date().toISOString().split('T')[0] })}
loading={generateMutation.isPending}
>
{m.tasks_generate_recurring()}
</Button>
<Anchor component={Link} to="/tasks/templates" size="sm" c="dimmed" style={{ textDecoration: 'none' }}>
{m.tasks_templates()}
</Anchor>
<Button variant="subtle" size="xs" onClick={openCreateDrawer} leftSection={<Plus size={14} />}>
{m.tasks_new_task()}
</Button>
</Group>
</Group>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{asI18n(error)}
</Text>
</Card>
)}
{/* Mobile: Accordion */}
<Box hiddenFrom="sm">
<Accordion
variant="separated"
radius="md"
multiple
defaultValue={KANBAN_STATUSES}
styles={{
item: {
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
},
}}
>
{KANBAN_STATUSES.map((status) => {
const cfg = STATUS_CONFIG[status]!
const items = grouped[status]!
return (
<Accordion.Item key={status} value={status}>
<Accordion.Control>
<Group gap="xs">
<Badge size="sm" color={cfg.color} variant="light">
{items.length}
</Badge>
<Text fw={600} size="sm">
{asI18n(statusLabel(status))}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
{items.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{m.tasks_no_status_tasks({ status: statusLabel(status).toLowerCase() })}
</Text>
) : (
<Stack gap="sm">
{items.map((task) => (
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
))}
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
)
})}
</Accordion>
</Box>
{/* Desktop: Kanban columns */}
<Box visibleFrom="sm">
<SimpleGrid cols={4} spacing="md">
{KANBAN_STATUSES.map((status) => {
const cfg = STATUS_CONFIG[status]!
const items = grouped[status]!
return (
<Stack key={status} gap="sm">
<Group gap="xs" mb={4}>
<Badge size="sm" color={cfg.color} variant="filled">
{items.length}
</Badge>
<Text
fw={600}
size="sm"
style={{ fontFamily: '"Cormorant", serif', fontSize: 16, color: '#3D2B1F' }}
>
{asI18n(statusLabel(status))}
</Text>
</Group>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: 'rgba(245, 240, 232, 0.5)',
border: '2px dashed rgba(61, 43, 31, 0.1)',
}}
>
<Text size="sm" c="dimmed" ta="center">
{m.tasks_no_tasks()}
</Text>
</Card>
) : (
items.map((task) => (
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
))
)}
</Stack>
)
})}
</SimpleGrid>
</Box>
</Stack>
<Drawer
opened={roleDrawerOpened}
onClose={closeRoleDrawer}
title={m.tasks_assign_to_role()}
position="right"
size="md"
>
<Stack gap="md">
<Select
label={m.tasks_role()}
placeholder={m.tasks_select_role()}
data={ROLE_OPTIONS}
value={selectedRole}
onChange={setSelectedRole}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={closeRoleDrawer}>
{m.tasks_cancel()}
</Button>
<Button
color="grape"
onClick={() => {
if (roleTaskId && selectedRole) {
assignRoleMutation.mutate({ taskId: roleTaskId, role: selectedRole })
}
}}
loading={assignRoleMutation.isPending}
disabled={!selectedRole}
>
{m.tasks_assign_role()}
</Button>
</Group>
</Stack>
</Drawer>
<CreateTaskDrawer opened={createDrawerOpened} onClose={closeCreateDrawer} />
</>
)
}

View File

@@ -0,0 +1,180 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Title, Text, Card, Group, Stack, Badge, Table, Anchor, Box } from '@pikku/mantine/core'
import { LayoutTemplate } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useLocale } from '@/i18n/config'
import { m } from '@/i18n/messages'
import { CreateTemplateForm } from '@/components/create-template-form'
export const Route = createFileRoute('/_authenticated/tasks/templates')({
component: TaskTemplatesPage,
})
const CATEGORY_COLORS: Record<string, string> = {
housekeeping: 'pink',
kitchen: 'yellow',
maintenance: 'orange',
operations: 'blue',
retreat: 'violet',
transport: 'cyan',
}
interface TaskTemplate {
templateId: string
title: string
description: string | null
category: string
isClaimable: boolean
defaultLocation: string | null
estimatedMinutes: number | null
recurrenceCron: string | null
}
function TaskTemplatesPage() {
useLocale()
// There is no list-templates RPC; new templates are created via the form below.
const templates: TaskTemplate[] = []
const error: string | null = null
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Anchor
component={Link}
to="/tasks"
size="sm"
c="dimmed"
style={{ textDecoration: 'none', marginBottom: 8, display: 'block' }}
>
{m.tasks_templates_back()}
</Anchor>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.tasks_templates_title()}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{m.tasks_templates_description()}
</Text>
</div>
</Group>
{error && (
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
<Text size="sm" c="red">
{error}
</Text>
</Card>
)}
<CreateTemplateForm />
{templates.length === 0 && !error ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
textAlign: 'center',
}}
>
<Stack align="center" gap="sm">
<LayoutTemplate size={40} color="#aaa" />
<Text size="sm" c="dimmed">
{m.tasks_templates_empty()}
</Text>
</Stack>
</Card>
) : (
<Card
padding={0}
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
overflow: 'hidden',
}}
>
{/* Desktop table */}
<Box visibleFrom="sm">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.tasks_templates_col_title()}</Table.Th>
<Table.Th>{m.tasks_templates_col_category()}</Table.Th>
<Table.Th>{m.tasks_templates_col_claimable()}</Table.Th>
<Table.Th>{m.tasks_templates_col_recurrence()}</Table.Th>
<Table.Th>{m.tasks_templates_col_duration()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{templates.map((t) => (
<Table.Tr key={t.templateId}>
<Table.Td>
<Text fw={500} size="sm">
{asI18n(t.title)}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={CATEGORY_COLORS[t.category] ?? 'gray'}>
{asI18n(t.category)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={t.isClaimable ? 'teal' : 'gray'}>
{t.isClaimable ? m.tasks_templates_yes() : m.tasks_templates_no()}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{asI18n(t.recurrenceCron ?? m.tasks_templates_one_time())}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{asI18n(t.estimatedMinutes ? m.tasks_templates_minutes({ minutes: t.estimatedMinutes }) : '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
{/* Mobile cards */}
<Box hiddenFrom="sm" p="md">
<Stack gap="sm">
{templates.map((t) => (
<Card key={t.templateId} padding="sm" radius="sm" withBorder>
<Group justify="space-between" mb={4}>
<Text fw={600} size="sm">
{asI18n(t.title)}
</Text>
<Badge size="xs" variant="light" color={CATEGORY_COLORS[t.category] ?? 'gray'}>
{asI18n(t.category)}
</Badge>
</Group>
<Group gap="xs">
<Badge size="xs" variant="light" color={t.isClaimable ? 'teal' : 'gray'}>
{t.isClaimable ? m.tasks_templates_claimable() : m.tasks_templates_not_claimable()}
</Badge>
{t.recurrenceCron && (
<Badge size="xs" variant="light" color="grape">
{m.tasks_templates_recurring()}
</Badge>
)}
{t.estimatedMinutes && (
<Text size="xs" c="dimmed">
{m.tasks_templates_minutes({ minutes: t.estimatedMinutes })}
</Text>
)}
</Group>
</Card>
))}
</Stack>
</Box>
</Card>
)}
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/tasks')({
component: Outlet,
})

View File

@@ -0,0 +1,331 @@
import { createFileRoute, Outlet, Navigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
AppShell,
Burger,
Group,
NavLink,
Text,
ActionIcon,
Menu,
Avatar,
Divider,
Box,
Stack,
Center,
Loader,
} from '@pikku/mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { m, mKey } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Home,
BedDouble,
CheckSquare,
User,
Sailboat,
DoorOpen,
CalendarDays,
UtensilsCrossed,
Package,
Users,
FileText,
Bell,
LogOut,
Menu as MenuIcon,
LayoutDashboard,
FileText as FileDescription,
DollarSign,
Bot,
} from 'lucide-react'
import { authClient, signOut } from '../lib/auth'
import { useNavigate } from '@tanstack/react-router'
import { useRoles, hasPermission } from '@/lib/permissions'
import { AuthContext, useSessionUser, type SessionUser } from '@/lib/auth-context'
import { NotificationBell } from '@/components/notification-bell'
import { LocaleSwitcher } from '@/components/locale-switcher'
import { AIChatDrawer } from '@/components/ai-chat'
export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const { data: user, isPending } = useQuery({
queryKey: ['session'],
queryFn: async () => {
const { data } = await authClient.getSession()
return (data?.user as SessionUser | undefined) ?? null
},
retry: false,
})
if (isPending) {
return (
<Center h="100vh">
<Loader color="teal" />
</Center>
)
}
if (!user) {
return <Navigate to="/login" />
}
return (
<AuthContext.Provider value={user}>
<AuthenticatedShell />
</AuthContext.Provider>
)
}
interface NavItem {
labelKey: string
href: string
icon: React.ElementType
permission?: string
}
const personalNav: NavItem[] = [
{ labelKey: 'nav.dashboard', href: '/', icon: Home },
{ labelKey: 'nav.my_stays', href: '/my/stays', icon: BedDouble },
{ labelKey: 'nav.my_tasks', href: '/my/tasks', icon: CheckSquare },
{ labelKey: 'nav.my_profile', href: '/my/profile', icon: User },
]
const operationsNav: NavItem[] = [
{ labelKey: 'nav.stays', href: '/stays', icon: BedDouble, permission: 'stays.view_all' },
{ labelKey: 'nav.boats', href: '/boats', icon: Sailboat, permission: 'boats.view_all' },
{ labelKey: 'nav.tasks', href: '/tasks', icon: CheckSquare, permission: 'tasks.view_all' },
{ labelKey: 'nav.kitchen', href: '/kitchen', icon: UtensilsCrossed, permission: 'kitchen.view' },
{ labelKey: 'nav.inventory', href: '/inventory', icon: Package, permission: 'inventory.request' },
{ labelKey: 'nav.retreats', href: '/retreats', icon: CalendarDays, permission: 'retreats.view' },
{ labelKey: 'nav.finance', href: '/finance', icon: DollarSign, permission: 'finance.view' },
]
const managementNav: NavItem[] = [
{ labelKey: 'nav.rooms', href: '/rooms', icon: DoorOpen, permission: 'rooms.view_all' },
{ labelKey: 'nav.boat_management', href: '/boats/manage', icon: Sailboat, permission: 'boats.manage' },
{ labelKey: 'nav.users', href: '/users', icon: Users, permission: 'users.manage' },
{ labelKey: 'nav.audit_log', href: '/audit-log', icon: FileText, permission: 'audit_log.view' },
]
const mobileTabItems: NavItem[] = [
{ labelKey: 'nav.dashboard', href: '/', icon: LayoutDashboard },
{ labelKey: 'nav.my_stays', href: '/my/stays', icon: FileDescription },
{ labelKey: 'nav.my_tasks', href: '/my/tasks', icon: CheckSquare },
{ labelKey: 'nav.more', href: '#more', icon: MenuIcon },
]
function AuthenticatedShell() {
useLocale()
const user = useSessionUser()
const navigate = useNavigate()
const [opened, { toggle, close }] = useDisclosure()
const isMobile = useMediaQuery('(max-width: 768px)')
const [chatOpen, setChatOpen] = useState(false)
const roles = useRoles()
const can = (permission?: string) =>
!permission || hasPermission(roles, permission)
const canUseAI = can('ai_agent.use')
const visibleOps = operationsNav.filter((item) => can(item.permission))
const visibleMgmt = managementNav.filter((item) => can(item.permission))
const handleSignOut = async () => {
await signOut()
navigate({ to: '/login' })
}
const go = (href: string) => {
navigate({ to: href as any })
close()
}
const renderNavLink = (item: NavItem) => (
<NavLink
key={item.href}
label={mKey(item.labelKey)}
leftSection={<item.icon size={18} />}
onClick={() => go(item.href)}
styles={{ root: { borderRadius: 8 } }}
/>
)
const sidebar = (
<Stack gap={0} p="sm">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
{m.nav_personal()}
</Text>
{personalNav.map(renderNavLink)}
{visibleOps.length > 0 && (
<>
<Divider my="sm" />
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
{m.nav_operations()}
</Text>
{visibleOps.map(renderNavLink)}
</>
)}
{visibleMgmt.length > 0 && (
<>
<Divider my="sm" />
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
{m.nav_management()}
</Text>
{visibleMgmt.map(renderNavLink)}
</>
)}
</Stack>
)
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
styles={{
main: {
backgroundColor: '#F5F0E8',
minHeight: '100vh',
paddingBottom: isMobile ? 72 : undefined,
},
header: {
backgroundColor: '#ffffff',
borderBottom: '1px solid rgba(61, 43, 31, 0.08)',
},
navbar: {
backgroundColor: '#ffffff',
borderRight: '1px solid rgba(61, 43, 31, 0.08)',
overflowY: 'auto',
},
}}
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Group gap={8} align="center">
<img src="/favicon-32x32.png" alt="PerAuset" width={28} height={28} />
<Text
size="xl"
fw={700}
style={{ fontFamily: '"Cormorant", serif', color: '#2A7B88' }}
>
{m.brand()}
</Text>
</Group>
</Group>
<Group gap="sm">
<LocaleSwitcher />
{canUseAI && (
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={() => setChatOpen(true)}
aria-label={asI18n('AI Assistant')}
>
<Bot size={20} />
</ActionIcon>
)}
<NotificationBell />
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
<Avatar size="sm" color="teal" radius="xl">
{user?.name?.[0]?.toUpperCase() ?? 'U'}
</Avatar>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{asI18n(user?.name ?? 'User')}
<Text size="xs" c="dimmed">
{asI18n(user?.email ?? '')}
</Text>
</Menu.Label>
<Menu.Divider />
<Menu.Item
leftSection={<User size={14} />}
onClick={() => navigate({ to: '/my/profile' as any })}
>
{m.nav_profile()}
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={14} />}
color="red"
onClick={handleSignOut}
>
{m.nav_sign_out()}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar>{sidebar}</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
{/* Mobile bottom tab bar */}
{isMobile && (
<Box
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 64,
backgroundColor: '#ffffff',
borderTop: '1px solid rgba(61, 43, 31, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
zIndex: 200,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{mobileTabItems.map((item) => (
<Box
key={item.labelKey}
component="button"
onClick={() => (item.href === '#more' ? toggle() : go(item.href))}
style={mobileTabStyle}
>
<item.icon size={22} />
<span>{mKey(item.labelKey)}</span>
</Box>
))}
</Box>
)}
{canUseAI && (
<AIChatDrawer opened={chatOpen} onClose={() => setChatOpen(false)} />
)}
</AppShell>
)
}
const mobileTabStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
padding: '8px 12px',
textDecoration: 'none',
color: '#3D2B1F',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 11,
fontFamily: '"Raleway", sans-serif',
}

View File

@@ -0,0 +1,184 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Card,
Group,
Stack,
Anchor,
Loader,
Center,
Title,
Checkbox,
Button,
Alert,
SimpleGrid,
} from '@pikku/mantine/core'
import { AlertCircle, Check } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/users/$userId')({
component: UserDetailPage,
})
function RoleEditor({
userId,
currentRoles,
availableRoles,
}: {
userId: string
currentRoles: string[]
availableRoles: string[]
}) {
const [roles, setRoles] = useState<string[]>(currentRoles)
const mutation = usePikkuMutation('setUserRoles')
const toggleRole = (role: string) => {
setRoles((prev) =>
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
)
mutation.reset()
}
const handleSave = () => {
mutation.mutate({ userId, memberRoles: roles })
}
return (
<Stack gap="md">
{mutation.isError && (
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
{asI18n(mutation.error?.message ?? 'Failed to update roles')}
</Alert>
)}
{mutation.isSuccess && (
<Alert icon={<Check size={16} />} color="green" variant="light">
{m.users_roles_updated()}
</Alert>
)}
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }} spacing="sm">
{availableRoles.map((role) => (
<Checkbox
key={role}
label={asI18n(role.replace(/_/g, ' '))}
checked={roles.includes(role)}
onChange={() => toggleRole(role)}
color="teal"
styles={{ label: { textTransform: 'capitalize' } }}
/>
))}
</SimpleGrid>
<Group justify="flex-end">
<Button onClick={handleSave} loading={mutation.isPending} color="teal" radius="md">
{m.users_save_roles()}
</Button>
</Group>
</Stack>
)
}
function UserDetailPage() {
useLocale()
const { userId } = Route.useParams()
const { data: user, isLoading: userLoading } = usePikkuQuery('getUser', { userId })
const { data: rolesData } = usePikkuQuery('listRoles', {})
const availableRoles: string[] = (rolesData?.roles ?? []).map((r) => r.key)
if (userLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
if (!user) {
return (
<Stack gap="lg">
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{m.users_not_found_title()}
</Title>
<Text c="dimmed">{m.users_not_found_body()}</Text>
<Anchor component={Link} to="/users" size="sm">
{m.users_back()}
</Anchor>
</Stack>
)
}
const name = user.displayName ?? user.name ?? user.email
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
{asI18n(name)}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{asI18n(`${user.email}${user.mobileNumber ? ` | ${user.mobileNumber}` : ''}`)}
</Text>
</div>
<Anchor component={Link} to="/users" size="sm">
{m.users_back()}
</Anchor>
</Group>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="md">
<Group gap="lg">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.users_display_name()}
</Text>
<Text size="sm" mt={4}>
{asI18n(user.displayName ?? '--')}
</Text>
</div>
</Group>
</Stack>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Title
order={4}
mb="md"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.users_role_management()}
</Title>
<RoleEditor
userId={user.userId}
currentRoles={user.memberRoles ?? []}
availableRoles={availableRoles}
/>
</Card>
</Stack>
)
}

View File

@@ -0,0 +1,263 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Group,
Stack,
Badge,
Anchor,
Loader,
Center,
Button,
Drawer,
TextInput,
MultiSelect,
} from '@pikku/mantine/core'
import { UserPlus } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/users/')({
component: UsersPage,
})
const roleColor: Record<string, string> = {
admin: 'red',
coordinator: 'violet',
facilitator: 'grape',
boat_coordinator: 'blue',
kitchen_lead: 'orange',
staff: 'teal',
volunteer: 'green',
community_member: 'cyan',
guest: 'gray',
}
const AVAILABLE_ROLES = [
{ value: 'coordinator', label: 'Coordinator' },
{ value: 'facilitator', label: 'Facilitator' },
{ value: 'boat_coordinator', label: 'Boat Coordinator' },
{ value: 'kitchen_lead', label: 'Kitchen Lead' },
{ value: 'staff', label: 'Staff' },
{ value: 'volunteer', label: 'Volunteer' },
{ value: 'community_member', label: 'Community Member' },
{ value: 'guest', label: 'Guest' },
]
interface UserRow {
userId: string
name: string | null
displayName: string | null
email: string
memberRoles: string[]
avatarUrl: string | null
}
function InviteUserDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [displayName, setDisplayName] = useState('')
const [roles, setRoles] = useState<string[]>([])
const resetForm = () => {
setEmail('')
setName('')
setDisplayName('')
setRoles([])
}
const mutation = usePikkuMutation('inviteUser', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listUsers'] })
notifications.show({
title: 'User invited',
message: 'Invitation has been sent.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to invite user.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!email || !name || roles.length === 0) {
notifications.show({
title: 'Missing fields',
message: 'Email, name, and at least one role are required.',
color: 'red',
})
return
}
mutation.mutate({
email,
name,
displayName: displayName || undefined,
memberRoles: roles,
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.users_invite()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.common_email()}
placeholder={asI18n('user@example.com')}
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<TextInput
label={m.profile_name()}
placeholder={asI18n('Name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<TextInput
label={m.users_display_name()}
placeholder={asI18n('Display name (optional)')}
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
/>
<MultiSelect
label={m.users_roles()}
placeholder={asI18n('Select roles')}
data={AVAILABLE_ROLES}
value={roles}
onChange={setRoles}
required
/>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.users_send_invitation()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function UsersPage() {
useLocale()
const canManage = usePermission('users.manage')
const [inviteOpen, setInviteOpen] = useState(false)
const { data, isLoading } = usePikkuQuery('listUsers', { limit: 50, offset: 0 })
const users = (data?.items ?? []) as UserRow[]
const total = data?.total ?? users.length
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<UserRow>[] = [
{
key: 'name',
label: m.users_name(),
render: (user) => {
const name = user.displayName ?? user.name ?? '--'
return (
<Text size="sm" fw={500}>
{asI18n(name)}
</Text>
)
},
},
{
key: 'email',
label: m.common_email(),
render: (user) => (
<Text size="sm" c="dimmed">
{asI18n(user.email)}
</Text>
),
},
{
key: 'roles',
label: m.users_roles(),
render: (user) => {
const roles = user.memberRoles ?? []
return (
<Group gap={4}>
{roles.map((role) => (
<Badge key={role} color={roleColor[role] ?? 'gray'} variant="light" size="xs">
{asI18n(role.replace(/_/g, ' '))}
</Badge>
))}
{roles.length === 0 && (
<Text size="xs" c="dimmed">
{m.users_no_roles()}
</Text>
)}
</Group>
)
},
},
{
key: 'actions',
label: asI18n(''),
render: (user) => (
<Anchor component={Link} to="/users/$userId" params={{ userId: user.userId } as never} size="sm">
{m.users_edit_roles()}
</Anchor>
),
},
]
return (
<Stack gap="lg">
<PageHeader
title={m.users_title()}
description={m.users_registered_count({ count: total })}
action={
canManage ? (
<Button
leftSection={<UserPlus size={16} />}
color="teal"
radius="md"
onClick={() => setInviteOpen(true)}
>
{m.users_invite()}
</Button>
) : undefined
}
/>
<DataTable
data={users}
columns={columns}
rowKey={(user) => user.userId}
emptyMessage="No users found."
/>
<InviteUserDrawer opened={inviteOpen} onClose={() => setInviteOpen(false)} />
</Stack>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/users')({
component: Outlet,
})

View File

@@ -0,0 +1,96 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Box,
Card,
TextInput,
PasswordInput,
Button,
Text,
Stack,
Alert,
Group,
} from '@pikku/mantine/core'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import type { I18nString } from '@pikku/react'
import { signIn, INVALID_CREDENTIALS } from '../lib/auth'
function LoginPage() {
useLocale()
const navigate = useNavigate()
const qc = useQueryClient()
const [email, setEmail] = useState(import.meta.env.DEV ? 'admin@perauset.org' : '')
const [password, setPassword] = useState(import.meta.env.DEV ? 'test' : '')
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<I18nString | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsPending(true)
setError(null)
try {
await signIn(email, password)
await qc.invalidateQueries({ queryKey: ['me'] })
navigate({ to: '/' })
} catch (err: any) {
setError(err.message === INVALID_CREDENTIALS ? m.login_invalid_credentials() : m.login_error())
} finally {
setIsPending(false)
}
}
return (
<Box
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F5F0E8',
padding: 16,
}}
>
<Card shadow="lg" padding="xl" radius="lg" w={400} maw="100%" style={{ backgroundColor: '#ffffff' }}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Group justify="center">
<Text size="xl" fw={700} style={{ color: '#2A7B88', fontSize: 28 }}>
{m.brand()}
</Text>
</Group>
<Text ta="center" size="sm" c="dimmed">
{m.login_subtitle()}
</Text>
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label={m.common_email()}
type="email"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<PasswordInput
label={m.common_password()}
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Button type="submit" fullWidth loading={isPending} color="teal" size="md" radius="md" mt="sm">
{m.login_cta()}
</Button>
</Stack>
</form>
</Card>
</Box>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})

16
apps/app/src/router.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

25
apps/app/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"allowJs": true,
"checkJs": false,
"moduleDetection": "force",
"jsx": "react-jsx",
"types": ["vite/client", "node"],
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

44
apps/app/vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import path from 'node:path'
export default defineConfig({
plugins: [
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR on
// message edits. Must run first.
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
tanstackStart({
router: {
routesDirectory: path.resolve(import.meta.dirname, 'src/pages'),
generatedRouteTree: path.resolve(import.meta.dirname, 'src/routeTree.gen.ts'),
routeFileIgnorePrefix: '-',
quoteStyle: 'single',
},
}),
react(),
],
server: {
port: 6001,
host: '0.0.0.0',
// The standalone Pikku backend serves better-auth under /api/auth but data
// RPC + REST routes at the root (/rpc, /users, …). The app calls both under
// a single /api base, so strip /api for everything except /api/auth. Keeping
// it same-origin (via this proxy) means no CORS and better-auth sees a
// consistent Origin. Backend URL overridable for e2e via VITE_BACKEND_URL.
proxy: {
'/api/auth': process.env.VITE_BACKEND_URL || 'http://localhost:6002',
'/api': {
target: process.env.VITE_BACKEND_URL || 'http://localhost:6002',
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
resolve: {
dedupe: ['react', 'react-dom'],
alias: {
'@': path.resolve(import.meta.dirname, 'src'),
},
},
})

54
backend/bin/db-migrate.ts Normal file
View File

@@ -0,0 +1,54 @@
import pg from 'pg'
import { migrate } from 'postgres-migrations'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const dbConfig: string | pg.ClientConfig = process.env.DATABASE_URL
? process.env.DATABASE_URL
: {
host: process.env.DB_HOST ?? '0.0.0.0',
port: Number(process.env.DB_PORT ?? 5432),
user: process.env.DB_USER ?? 'yasser',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'perauset',
}
async function main() {
// Create database if it doesn't exist (skip when using connection string — DB is pre-provisioned)
if (typeof dbConfig !== 'string') {
const client = new pg.Client({ ...dbConfig, database: 'postgres' })
try {
await client.connect()
if (process.env.RESET_DB === 'true') {
console.log('Dropping database...')
await client.query(`DROP DATABASE IF EXISTS "${dbConfig.database}"`)
}
console.log(`Creating database "${dbConfig.database}"...`)
await client.query(`CREATE DATABASE "${dbConfig.database}"`)
} catch (e: any) {
if (e.code === '42P04') {
console.log('Database already exists')
} else {
throw e
}
} finally {
await client.end()
}
}
// Run migrations
const client = new pg.Client(dbConfig as any)
await client.connect()
console.log('Running migrations...')
await migrate({ client }, `${__dirname}/../../sql`, { logger: undefined })
console.log('Migrations complete')
await client.end()
}
main().catch((e) => {
console.error('Migration failed:', e)
process.exit(1)
})

73
backend/bin/start-e2e.ts Normal file
View File

@@ -0,0 +1,73 @@
// Isolated SQLite backend for e2e tests.
//
// The shipped runtime (Pikku Fabric) serves the API with an injected SQLite
// kysely built via `createSQLiteKysely`. The standalone `start.ts` uses the
// libsql *web* dialect, which only talks to a remote libsql server — it can't
// open a local file. This entry mirrors the Fabric runtime instead: it opens the
// local better-sqlite3 database (provisioned by `pikku db reset` in the e2e
// hooks) and injects the resulting kysely into `createSingletonServices` so
// better-auth (account/session tables) works fully offline and per-run isolated.
//
// Env:
// E2E_DB_FILE path to the provisioned sqlite file to open
// PORT http port (default 6099)
// BETTER_AUTH_SECRET better-auth signing secret (default e2e-test-secret)
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import Database from 'better-sqlite3'
import { Kysely, SqliteDialect, CamelCasePlugin } from 'kysely'
import { SerializePlugin } from '@pikku/kysely'
import { PikkuFastifyServer } from '@pikku/fastify'
import type { DB } from '@perauset/functions/.pikku/db/schema.js'
import { createConfig } from '@perauset/functions/src/config.js'
import { createSingletonServices } from '@perauset/functions/src/services.js'
import '@perauset/functions/.pikku/pikku-bootstrap.gen.js'
import '@perauset/functions/src/middleware.js'
import '@perauset/functions/src/wirings/auth.wiring.js'
import '@perauset/functions/src/wirings/http.wiring.js'
import '@perauset/functions/src/wirings/http-phase1b.wiring.js'
import '@perauset/functions/src/wirings/http-phase2.wiring.js'
import '@perauset/functions/src/lib/permissions.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectDir = resolve(__dirname, '../..')
function openDatabase(dbFile: string): Database.Database {
// The db file is provisioned by `pikku db reset` (migrations + sqlite-seed.sql)
// before this process starts — see the e2e BeforeAll hooks. Here we only open it.
const db = new Database(dbFile)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
return db
}
async function main(): Promise<void> {
try {
process.env.BETTER_AUTH_SECRET ??= 'e2e-test-secret'
const dbFile = process.env.E2E_DB_FILE ?? resolve(projectDir, '.pikku-runtime/e2e.db')
const db = openDatabase(dbFile)
// Mirror the Fabric runtime's kysely: SerializePlugin for JSON columns +
// CamelCasePlugin so camelCase model fields (and better-auth's userId etc.)
// map to the snake_case columns in db/sqlite/*.sql.
const kysely = new Kysely<DB>({
dialect: new SqliteDialect({ database: db }),
plugins: [new SerializePlugin(), new CamelCasePlugin()],
})
const config = await createConfig()
const singletonServices = await createSingletonServices(config, { kysely })
const appServer = new PikkuFastifyServer(config, singletonServices.logger)
appServer.enableExitOnSigInt()
await appServer.init({ exposeErrors: true })
await appServer.start()
} catch (e: any) {
console.error(e.stack ?? e.toString())
process.exit(1)
}
}
main()

29
backend/bin/start.ts Normal file
View File

@@ -0,0 +1,29 @@
import { PikkuFastifyServer } from "@pikku/fastify"
import { createConfig } from "@perauset/functions/src/config.js"
import { createSingletonServices } from "@perauset/functions/src/services.js"
import "@perauset/functions/.pikku/pikku-bootstrap.gen.js"
import "@perauset/functions/src/middleware.js"
import "@perauset/functions/src/wirings/auth.wiring.js"
import "@perauset/functions/src/wirings/http.wiring.js"
import "@perauset/functions/src/wirings/http-phase1b.wiring.js"
import "@perauset/functions/src/wirings/http-phase2.wiring.js"
import "@perauset/functions/src/lib/permissions.js"
async function main(): Promise<void> {
try {
const config = await createConfig()
const singletonServices = await createSingletonServices(config)
const appServer = new PikkuFastifyServer(config, singletonServices.logger)
appServer.enableExitOnSigInt()
await appServer.init({ exposeErrors: true })
await appServer.start()
} catch (e: any) {
console.error(e.toString())
process.exit(1)
}
}
main()

33
backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@perauset/server",
"version": "0.0.0",
"description": "Perauset backend server",
"license": "MIT",
"private": true,
"type": "module",
"main": "bin/start.ts",
"scripts": {
"tsc": "tsc",
"start": "tsx bin/start.ts",
"dev": "tsx watch --env-file=../.env bin/start.ts",
"dbmigrate": "tsx --env-file=../.env bin/db-migrate.ts"
},
"dependencies": {
"@perauset/functions": "workspace:*",
"@pikku/core": "^0.12.57",
"@pikku/fastify": "^0.12.4",
"@pikku/kysely": "^0.13.0",
"kysely": "^0.29.2",
"pg": "^8.16.0",
"postgres-migrations": "^5.3.0",
"tslib": "^2.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9"
},
"devDependencies": {
"@pikku/cli": "^0.12.76",
"@types/node": "^25",
"@types/pg": "^8",
"better-sqlite3": "^12.11.1"
}
}

16
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"files": [],
"include": ["src/*.ts", "src/**/*.ts", "bin/**/*.ts"],
"exclude": ["node_modules", "dist", "../packages/functions/.pikku"]
}

1516
bun.lock Normal file

File diff suppressed because it is too large Load Diff

167
db/annotations.ts Normal file
View File

@@ -0,0 +1,167 @@
import type { DbClassificationMap } from '/Users/yasser/git/pikku/perauset/.pikku/db/classification-map.gen.d.ts'
export const classifications = {
"account": {},
"audit_log": {
"occurred_at": { kind: 'date' },
},
"boat": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"is_active": { kind: 'bool' },
},
"boat_manifest_entry": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"boat_reservation_request": {
"created_at": { kind: 'date' },
"reviewed_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"boat_route": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"is_active": { kind: 'bool' },
},
"boat_trip": {
"created_at": { kind: 'date' },
"scheduled_arrival_at": { kind: 'date' },
"scheduled_departure_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"dietary_profile": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"has_nut_allergy": { kind: 'bool' },
"is_dairy_free": { kind: 'bool' },
"is_gluten_free": { kind: 'bool' },
"is_vegan": { kind: 'bool' },
"is_vegetarian": { kind: 'bool' },
},
"exchange_rate": {
"created_at": { kind: 'date' },
"fetched_at": { kind: 'date' },
},
"finance_link": {
"created_at": { kind: 'date' },
},
"finance_record": {
"created_at": { kind: 'date' },
"purchased_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"inventory_item": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"is_active": { kind: 'bool' },
},
"inventory_request": {
"created_at": { kind: 'date' },
"reviewed_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"inventory_transaction": {
"created_at": { kind: 'date' },
},
"meal_attendance": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"meal_service": {
"created_at": { kind: 'date' },
"service_date": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"notification": {
"created_at": { kind: 'date' },
"read_at": { kind: 'date' },
},
"retreat": {
"created_at": { kind: 'date' },
"default_check_in_at": { kind: 'date' },
"default_check_out_at": { kind: 'date' },
"end_at": { kind: 'date' },
"start_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"retreat_person": {
"arrival_override_at": { kind: 'date' },
"created_at": { kind: 'date' },
"departure_override_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"retreat_schedule_item": {
"created_at": { kind: 'date' },
"ends_at": { kind: 'date' },
"starts_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"room": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"is_active": { kind: 'bool' },
},
"room_allocation": {
"created_at": { kind: 'date' },
"ends_at": { kind: 'date' },
"starts_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"room_block": {
"created_at": { kind: 'date' },
"ends_at": { kind: 'date' },
"starts_at": { kind: 'date' },
},
"room_request": {
"created_at": { kind: 'date' },
"reviewed_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"session": {},
"stay": {
"checked_in_at": { kind: 'date' },
"checked_out_at": { kind: 'date' },
"created_at": { kind: 'date' },
"end_at": { kind: 'date' },
"start_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"stay_request": {
"created_at": { kind: 'date' },
"requested_end_at": { kind: 'date' },
"requested_start_at": { kind: 'date' },
"reviewed_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"task_assignment": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"task_instance": {
"created_at": { kind: 'date' },
"scheduled_end_at": { kind: 'date' },
"scheduled_start_at": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"task_template": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"is_active": { kind: 'bool' },
"is_claimable": { kind: 'bool' },
},
"user": {
"created_at": { kind: 'date' },
"updated_at": { kind: 'date' },
"ban_expires": { kind: 'date' },
"email_verified": { kind: 'bool' },
"banned": { kind: 'bool' },
"member_roles": { tsType: 'string[]' },
},
"user_profile": {
"created_at": { kind: 'date' },
"date_of_birth": { kind: 'date' },
"updated_at": { kind: 'date' },
},
"verification": {},
} satisfies DbClassificationMap

13
db/sqlite-seed.sql Normal file
View File

@@ -0,0 +1,13 @@
-- Perauset seed data — idempotent, never runs in production.
-- Applied by `pikku db seed` (and `pikku db reset`).
-- ─── Demo / e2e admin (better-auth credential) ────────────────────────
-- Backs the prefilled login (admin@perauset.org / test) and the e2e suites.
-- password is a better-auth scrypt hash (salt:hash, self-contained).
-- member_roles = app RBAC (full perms via the 'admin' member role); role = the
-- better-auth admin plugin's platform role (gates console access + impersonation).
INSERT OR IGNORE INTO user (id, email, name, display_name, email_verified, member_roles, role, created_at, updated_at) VALUES
('u_admin', 'admin@perauset.org', 'Admin', 'Admin', 1, '["admin"]', 'admin', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT OR IGNORE INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at) VALUES
('acct_u_admin', 'u_admin', 'credential', 'u_admin', '0b27de3eeeab546bf7bce4a94511417b:9f68ffc9cd63e21104cd033699ac9c695ddcb4cb982ea30def1b12751de1012aa12de46cd17c1e4c79276299dc8498e7d177ee59156ab7833d1d026e9d9647b6', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');

558
db/sqlite/0001-schema.sql Normal file
View File

@@ -0,0 +1,558 @@
-- ============================================================
-- Perauset schema — converted from Postgres to SQLite
-- UUIDs → TEXT, BOOLEAN → INTEGER (0/1), JSON columns → TEXT,
-- TIMESTAMPTZ/DATE → TEXT, enums → TEXT CHECK constraints,
-- arrays → TEXT (JSON), no schemas, no triggers, no PL/pgSQL
-- ============================================================
-- ============================================================
-- Users (replaced by better-auth 0002 — defined here as
-- a placeholder so FKs can be deferred until 0002 runs)
-- ============================================================
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
email TEXT NOT NULL UNIQUE,
name TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- ============================================================
-- User profile
-- ============================================================
CREATE TABLE user_profile (
profile_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
user_id TEXT NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE,
date_of_birth TEXT,
nationality TEXT,
notes TEXT,
emergency_contact_name TEXT,
emergency_contact_phone TEXT,
mobile_number TEXT,
whatsapp_number TEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ============================================================
-- Audit log (no triggers in SQLite — app writes rows directly)
-- ============================================================
CREATE TABLE audit_log (
audit_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
action TEXT NOT NULL,
user_id TEXT REFERENCES user(id),
changed_fields TEXT,
occurred_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_audit_log_table_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_occurred ON audit_log(occurred_at);
-- ============================================================
-- Stay requests
-- ============================================================
CREATE TABLE stay_request (
request_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
user_id TEXT NOT NULL REFERENCES user(id),
request_type TEXT NOT NULL DEFAULT 'guest'
CHECK (request_type IN ('guest','volunteer','staff','facilitator','day_visit')),
requested_start_at TEXT NOT NULL,
requested_end_at TEXT NOT NULL,
retreat_id TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted','under_review','approved','rejected','cancelled')),
reviewed_by_user_id TEXT REFERENCES user(id),
reviewed_at TEXT,
review_notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_stay_request_user ON stay_request(user_id);
CREATE INDEX idx_stay_request_status ON stay_request(status);
-- ============================================================
-- Stays
-- ============================================================
CREATE TABLE stay (
stay_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
user_id TEXT NOT NULL REFERENCES user(id),
stay_type TEXT NOT NULL DEFAULT 'guest'
CHECK (stay_type IN ('guest','volunteer','staff','facilitator','day_visitor')),
source_request_id TEXT REFERENCES stay_request(request_id),
retreat_id TEXT,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','checked_in','checked_out','cancelled')),
checked_in_at TEXT,
checked_out_at TEXT,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
updated_by_user_id TEXT REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_stay_user ON stay(user_id);
CREATE INDEX idx_stay_status ON stay(status);
CREATE INDEX idx_stay_dates ON stay(start_at, end_at);
-- ============================================================
-- Rooms
-- ============================================================
CREATE TABLE room (
room_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
room_type TEXT NOT NULL DEFAULT 'private'
CHECK (room_type IN ('private','shared','dorm','facilitator','staff')),
capacity INTEGER NOT NULL DEFAULT 1,
is_active INTEGER NOT NULL DEFAULT 1,
notes TEXT,
price_per_night REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE room_block (
block_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
room_id TEXT NOT NULL REFERENCES room(room_id),
block_type TEXT NOT NULL DEFAULT 'maintenance'
CHECK (block_type IN ('maintenance','retreat_reserved','admin_hold','other')),
retreat_id TEXT,
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
reason TEXT,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_room_block_room ON room_block(room_id);
CREATE INDEX idx_room_block_dates ON room_block(starts_at, ends_at);
CREATE TABLE room_request (
request_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
stay_id TEXT NOT NULL REFERENCES stay(stay_id),
requested_room_type TEXT
CHECK (requested_room_type IN ('private','shared','dorm','facilitator','staff')),
preference_notes TEXT,
status TEXT NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted','reviewed','satisfied','unsatisfied','cancelled')),
reviewed_by_user_id TEXT REFERENCES user(id),
reviewed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE room_allocation (
allocation_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
stay_id TEXT NOT NULL REFERENCES stay(stay_id),
room_id TEXT NOT NULL REFERENCES room(room_id),
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','active','completed','cancelled')),
allocation_reason TEXT,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
updated_by_user_id TEXT REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_room_allocation_room ON room_allocation(room_id);
CREATE INDEX idx_room_allocation_stay ON room_allocation(stay_id);
CREATE INDEX idx_room_allocation_dates ON room_allocation(starts_at, ends_at);
-- ============================================================
-- Boats
-- ============================================================
CREATE TABLE boat_route (
route_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
name TEXT NOT NULL,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE boat (
boat_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
name TEXT NOT NULL,
passenger_capacity INTEGER NOT NULL,
cargo_capacity_kg REAL,
is_active INTEGER NOT NULL DEFAULT 1,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE boat_trip (
trip_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
boat_id TEXT REFERENCES boat(boat_id),
route_id TEXT NOT NULL REFERENCES boat_route(route_id),
scheduled_departure_at TEXT NOT NULL,
scheduled_arrival_at TEXT,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','open','full','closed','cancelled','completed')),
notes TEXT,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
updated_by_user_id TEXT REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_boat_trip_status ON boat_trip(status);
CREATE INDEX idx_boat_trip_departure ON boat_trip(scheduled_departure_at);
CREATE TABLE boat_reservation_request (
request_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
trip_id TEXT NOT NULL REFERENCES boat_trip(trip_id),
user_id TEXT NOT NULL REFERENCES user(id),
stay_id TEXT REFERENCES stay(stay_id),
retreat_id TEXT,
requested_seats INTEGER NOT NULL DEFAULT 1,
cargo_notes TEXT,
special_requirements TEXT,
priority_reason TEXT,
status TEXT NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted','under_review','confirmed','waitlisted','declined','cancelled')),
reviewed_by_user_id TEXT REFERENCES user(id),
reviewed_at TEXT,
review_notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_boat_reservation_trip ON boat_reservation_request(trip_id);
CREATE INDEX idx_boat_reservation_user ON boat_reservation_request(user_id);
CREATE INDEX idx_boat_reservation_status ON boat_reservation_request(status);
CREATE TABLE boat_manifest_entry (
entry_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
trip_id TEXT NOT NULL REFERENCES boat_trip(trip_id),
reservation_request_id TEXT REFERENCES boat_reservation_request(request_id),
user_id TEXT NOT NULL REFERENCES user(id),
stay_id TEXT REFERENCES stay(stay_id),
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed','boarded','no_show','cancelled')),
confirmed_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_boat_manifest_trip ON boat_manifest_entry(trip_id);
-- ============================================================
-- Tasks
-- ============================================================
CREATE TABLE task_template (
template_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'operations'
CHECK (category IN ('operations','maintenance','kitchen','retreat','transport','housekeeping')),
is_claimable INTEGER NOT NULL DEFAULT 0,
default_location TEXT,
estimated_minutes INTEGER,
recurrence_cron TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE task_instance (
task_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
template_id TEXT REFERENCES task_template(template_id),
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'operations'
CHECK (category IN ('operations','maintenance','kitchen','retreat','transport','housekeeping')),
location TEXT,
assigned_role TEXT,
linked_retreat_id TEXT,
linked_stay_id TEXT REFERENCES stay(stay_id),
scheduled_start_at TEXT,
scheduled_end_at TEXT,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','open','assigned','in_progress','blocked','completed','cancelled')),
created_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_task_instance_status ON task_instance(status);
CREATE INDEX idx_task_instance_category ON task_instance(category);
CREATE INDEX idx_task_instance_template ON task_instance(template_id);
CREATE TABLE task_assignment (
assignment_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
task_instance_id TEXT NOT NULL REFERENCES task_instance(task_id),
assigned_user_id TEXT NOT NULL REFERENCES user(id),
assigned_by_user_id TEXT NOT NULL REFERENCES user(id),
status TEXT NOT NULL DEFAULT 'proposed'
CHECK (status IN ('proposed','accepted','declined','reassigned','completed')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_task_assignment_task ON task_assignment(task_instance_id);
CREATE INDEX idx_task_assignment_user ON task_assignment(assigned_user_id);
-- ============================================================
-- Notifications
-- ============================================================
CREATE TABLE notification (
notification_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
user_id TEXT NOT NULL REFERENCES user(id),
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
entity_type TEXT,
entity_id TEXT,
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_notification_user ON notification(user_id, read_at, created_at);
-- ============================================================
-- Retreats
-- ============================================================
CREATE TABLE retreat (
retreat_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','published','active','completed','cancelled')),
capacity INTEGER,
default_check_in_at TEXT,
default_check_out_at TEXT,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_retreat_status ON retreat(status);
CREATE INDEX idx_retreat_dates ON retreat(start_at, end_at);
CREATE TABLE retreat_person (
retreat_person_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
retreat_id TEXT NOT NULL REFERENCES retreat(retreat_id) ON DELETE CASCADE,
user_id TEXT REFERENCES user(id),
stay_id TEXT REFERENCES stay(stay_id),
full_name TEXT,
email TEXT,
phone TEXT,
role_type TEXT NOT NULL DEFAULT 'participant'
CHECK (role_type IN ('participant','facilitator','assistant_facilitator','organizer','support_staff')),
attendance_status TEXT NOT NULL DEFAULT 'registered'
CHECK (attendance_status IN ('invited','registered','confirmed','checked_in','cancelled')),
arrival_override_at TEXT,
departure_override_at TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(retreat_id, user_id)
);
CREATE INDEX idx_retreat_person_retreat ON retreat_person(retreat_id);
CREATE INDEX idx_retreat_person_user ON retreat_person(user_id);
CREATE TABLE retreat_schedule_item (
item_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
retreat_id TEXT NOT NULL REFERENCES retreat(retreat_id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
location TEXT,
lead_retreat_person_id TEXT REFERENCES retreat_person(retreat_person_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_retreat_schedule_retreat ON retreat_schedule_item(retreat_id);
-- ============================================================
-- Kitchen / Dietary
-- ============================================================
CREATE TABLE dietary_profile (
profile_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
user_id TEXT NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE,
is_vegan INTEGER NOT NULL DEFAULT 0,
is_vegetarian INTEGER NOT NULL DEFAULT 0,
is_gluten_free INTEGER NOT NULL DEFAULT 0,
is_dairy_free INTEGER NOT NULL DEFAULT 0,
has_nut_allergy INTEGER NOT NULL DEFAULT 0,
allergy_notes TEXT,
other_requirements TEXT,
dislikes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE meal_service (
service_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
service_type TEXT NOT NULL
CHECK (service_type IN ('breakfast','lunch','dinner','snack')),
service_date TEXT NOT NULL,
retreat_id TEXT REFERENCES retreat(retreat_id),
planned_headcount INTEGER,
menu_notes TEXT,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','ready','served','completed')),
created_by_user_id TEXT NOT NULL REFERENCES user(id),
updated_by_user_id TEXT REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_meal_service_date ON meal_service(service_date);
CREATE INDEX idx_meal_service_type ON meal_service(service_type);
CREATE INDEX idx_meal_service_retreat ON meal_service(retreat_id);
CREATE TABLE meal_attendance (
attendance_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
meal_service_id TEXT NOT NULL REFERENCES meal_service(service_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES user(id),
stay_id TEXT REFERENCES stay(stay_id),
source_type TEXT NOT NULL DEFAULT 'manual'
CHECK (source_type IN ('stay','retreat','manual')),
status TEXT NOT NULL DEFAULT 'expected'
CHECK (status IN ('expected','confirmed','cancelled','served')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(meal_service_id, user_id)
);
CREATE INDEX idx_meal_attendance_service ON meal_attendance(meal_service_id);
CREATE INDEX idx_meal_attendance_user ON meal_attendance(user_id);
-- ============================================================
-- Inventory
-- ============================================================
CREATE TABLE inventory_item (
item_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
name TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT 'unit',
current_quantity REAL NOT NULL DEFAULT 0,
minimum_quantity REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT 'operations',
subcategory TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_inventory_item_active ON inventory_item(is_active);
CREATE INDEX idx_inventory_item_category ON inventory_item(category);
CREATE TABLE inventory_request (
request_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
requested_by_user_id TEXT NOT NULL REFERENCES user(id),
item_id TEXT REFERENCES inventory_item(item_id),
item_name TEXT NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL,
purpose TEXT,
status TEXT NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted','approved','rejected','fulfilled','cancelled')),
reviewed_by_user_id TEXT REFERENCES user(id),
reviewed_at TEXT,
review_notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_inventory_request_status ON inventory_request(status);
CREATE INDEX idx_inventory_request_user ON inventory_request(requested_by_user_id);
CREATE TABLE inventory_transaction (
transaction_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
item_id TEXT NOT NULL REFERENCES inventory_item(item_id),
quantity_change REAL NOT NULL,
reason TEXT NOT NULL,
notes TEXT,
created_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_inventory_transaction_item ON inventory_transaction(item_id);
CREATE INDEX idx_inventory_transaction_created ON inventory_transaction(created_at);
-- ============================================================
-- Finance
-- ============================================================
CREATE TABLE finance_record (
finance_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
name TEXT NOT NULL,
description TEXT,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
record_type TEXT NOT NULL DEFAULT 'expense',
amount_egp REAL,
purchased_by_user_id TEXT REFERENCES user(id),
purchased_at TEXT NOT NULL DEFAULT (datetime('now')),
category TEXT NOT NULL DEFAULT 'operations',
created_by_user_id TEXT NOT NULL REFERENCES user(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_finance_record_category ON finance_record(category);
CREATE INDEX idx_finance_record_purchased_at ON finance_record(purchased_at);
CREATE INDEX idx_finance_record_purchased_by ON finance_record(purchased_by_user_id);
CREATE TABLE finance_link (
link_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
finance_id TEXT NOT NULL REFERENCES finance_record(finance_id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_finance_link_finance ON finance_link(finance_id);
CREATE INDEX idx_finance_link_entity ON finance_link(entity_type, entity_id);
-- ============================================================
-- Exchange rates
-- ============================================================
CREATE TABLE exchange_rate (
rate_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('AB89',1+(abs(random())%4),1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
base_currency TEXT NOT NULL DEFAULT 'EGP',
target_currency TEXT NOT NULL,
rate REAL NOT NULL,
fetched_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_exchange_rate_unique ON exchange_rate(base_currency, target_currency, fetched_at);
CREATE INDEX idx_exchange_rate_date ON exchange_rate(fetched_at);

View File

@@ -0,0 +1,66 @@
-- Drop the placeholder user table from 0001 and replace with better-auth's schema
DROP TABLE IF EXISTS user;
-- better-auth user table with app-specific additionalFields
CREATE TABLE user (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified INTEGER NOT NULL DEFAULT 0,
image TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
-- additionalFields
roles TEXT NOT NULL DEFAULT '[]',
display_name TEXT,
mobile_number TEXT,
whatsapp_number TEXT,
avatar_url TEXT
);
-- better-auth session table
CREATE TABLE session (
id TEXT PRIMARY KEY,
expires_at TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT,
user_agent TEXT,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX idx_session_token ON session(token);
CREATE INDEX idx_session_user ON session(user_id);
-- better-auth account table
CREATE TABLE account (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TEXT,
refresh_token_expires_at TEXT,
scope TEXT,
password TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_account_user ON account(user_id);
CREATE UNIQUE INDEX idx_account_provider ON account(provider_id, account_id);
-- better-auth verification table
CREATE TABLE verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_verification_identifier ON verification(identifier);

View File

@@ -0,0 +1,433 @@
-- Audit logging for SQLite (libsql/better-sqlite3).
--
-- Postgres used a generic trigger function + a per-request session variable
-- (set_config) to attribute audit rows. SQLite has neither, so we emulate it:
-- * audit_ctx holds the current user id for the active connection (set by the
-- app via setAuditContext() before each mutation).
-- * Per-table AFTER INSERT/UPDATE/DELETE triggers write audit_log rows,
-- reading the actor from audit_ctx.
--
-- NOTE: audit_ctx is connection-global. Attribution is correct when mutations
-- run serially per connection (the better-sqlite3 case); under heavy
-- interleaved concurrency the actor could be misattributed.
CREATE TABLE IF NOT EXISTS audit_ctx (
id INTEGER PRIMARY KEY CHECK (id = 1),
user_id TEXT
);
INSERT OR IGNORE INTO audit_ctx (id, user_id) VALUES (1, NULL);
CREATE TRIGGER audit_user_insert AFTER INSERT ON user
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'user', NEW.id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_user_update AFTER UPDATE ON user
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'user', NEW.id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_user_delete AFTER DELETE ON user
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'user', OLD.id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_stay_request_insert AFTER INSERT ON stay_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'stay_request', NEW.request_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_stay_request_update AFTER UPDATE ON stay_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'stay_request', NEW.request_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_stay_request_delete AFTER DELETE ON stay_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'stay_request', OLD.request_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_stay_insert AFTER INSERT ON stay
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'stay', NEW.stay_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_stay_update AFTER UPDATE ON stay
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'stay', NEW.stay_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_stay_delete AFTER DELETE ON stay
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'stay', OLD.stay_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_route_insert AFTER INSERT ON boat_route
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_route', NEW.route_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_route_update AFTER UPDATE ON boat_route
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_route', NEW.route_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_route_delete AFTER DELETE ON boat_route
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_route', OLD.route_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_insert AFTER INSERT ON boat
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat', NEW.boat_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_update AFTER UPDATE ON boat
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat', NEW.boat_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_delete AFTER DELETE ON boat
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat', OLD.boat_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_trip_insert AFTER INSERT ON boat_trip
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_trip', NEW.trip_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_trip_update AFTER UPDATE ON boat_trip
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_trip', NEW.trip_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_trip_delete AFTER DELETE ON boat_trip
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_trip', OLD.trip_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_reservation_request_insert AFTER INSERT ON boat_reservation_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_reservation_request', NEW.request_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_reservation_request_update AFTER UPDATE ON boat_reservation_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_reservation_request', NEW.request_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_reservation_request_delete AFTER DELETE ON boat_reservation_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_reservation_request', OLD.request_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_manifest_entry_insert AFTER INSERT ON boat_manifest_entry
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_manifest_entry', NEW.entry_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_manifest_entry_update AFTER UPDATE ON boat_manifest_entry
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_manifest_entry', NEW.entry_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_boat_manifest_entry_delete AFTER DELETE ON boat_manifest_entry
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'boat_manifest_entry', OLD.entry_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_insert AFTER INSERT ON room
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room', NEW.room_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_update AFTER UPDATE ON room
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room', NEW.room_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_delete AFTER DELETE ON room
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room', OLD.room_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_block_insert AFTER INSERT ON room_block
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_block', NEW.block_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_block_update AFTER UPDATE ON room_block
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_block', NEW.block_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_block_delete AFTER DELETE ON room_block
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_block', OLD.block_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_request_insert AFTER INSERT ON room_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_request', NEW.request_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_request_update AFTER UPDATE ON room_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_request', NEW.request_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_request_delete AFTER DELETE ON room_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_request', OLD.request_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_allocation_insert AFTER INSERT ON room_allocation
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_allocation', NEW.allocation_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_allocation_update AFTER UPDATE ON room_allocation
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_allocation', NEW.allocation_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_room_allocation_delete AFTER DELETE ON room_allocation
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'room_allocation', OLD.allocation_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_dietary_profile_insert AFTER INSERT ON dietary_profile
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'dietary_profile', NEW.profile_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_dietary_profile_update AFTER UPDATE ON dietary_profile
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'dietary_profile', NEW.profile_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_dietary_profile_delete AFTER DELETE ON dietary_profile
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'dietary_profile', OLD.profile_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_meal_service_insert AFTER INSERT ON meal_service
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'meal_service', NEW.service_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_meal_service_update AFTER UPDATE ON meal_service
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'meal_service', NEW.service_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_meal_service_delete AFTER DELETE ON meal_service
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'meal_service', OLD.service_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_meal_attendance_insert AFTER INSERT ON meal_attendance
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'meal_attendance', NEW.attendance_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_meal_attendance_update AFTER UPDATE ON meal_attendance
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'meal_attendance', NEW.attendance_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_meal_attendance_delete AFTER DELETE ON meal_attendance
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'meal_attendance', OLD.attendance_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_insert AFTER INSERT ON retreat
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat', NEW.retreat_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_update AFTER UPDATE ON retreat
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat', NEW.retreat_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_delete AFTER DELETE ON retreat
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat', OLD.retreat_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_person_insert AFTER INSERT ON retreat_person
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat_person', NEW.retreat_person_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_person_update AFTER UPDATE ON retreat_person
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat_person', NEW.retreat_person_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_person_delete AFTER DELETE ON retreat_person
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat_person', OLD.retreat_person_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_schedule_item_insert AFTER INSERT ON retreat_schedule_item
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat_schedule_item', NEW.item_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_schedule_item_update AFTER UPDATE ON retreat_schedule_item
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat_schedule_item', NEW.item_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_retreat_schedule_item_delete AFTER DELETE ON retreat_schedule_item
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'retreat_schedule_item', OLD.item_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_template_insert AFTER INSERT ON task_template
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_template', NEW.template_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_template_update AFTER UPDATE ON task_template
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_template', NEW.template_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_template_delete AFTER DELETE ON task_template
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_template', OLD.template_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_instance_insert AFTER INSERT ON task_instance
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_instance', NEW.task_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_instance_update AFTER UPDATE ON task_instance
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_instance', NEW.task_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_instance_delete AFTER DELETE ON task_instance
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_instance', OLD.task_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_assignment_insert AFTER INSERT ON task_assignment
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_assignment', NEW.assignment_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_assignment_update AFTER UPDATE ON task_assignment
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_assignment', NEW.assignment_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_task_assignment_delete AFTER DELETE ON task_assignment
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'task_assignment', OLD.assignment_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_inventory_item_insert AFTER INSERT ON inventory_item
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'inventory_item', NEW.item_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_inventory_item_update AFTER UPDATE ON inventory_item
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'inventory_item', NEW.item_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_inventory_item_delete AFTER DELETE ON inventory_item
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'inventory_item', OLD.item_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_inventory_request_insert AFTER INSERT ON inventory_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'inventory_request', NEW.request_id, 'INSERT', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_inventory_request_update AFTER UPDATE ON inventory_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'inventory_request', NEW.request_id, 'UPDATE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;
CREATE TRIGGER audit_inventory_request_delete AFTER DELETE ON inventory_request
BEGIN
INSERT INTO audit_log (audit_id, table_name, record_id, action, user_id, changed_fields)
VALUES (lower(hex(randomblob(16))), 'inventory_request', OLD.request_id, 'DELETE', (SELECT user_id FROM audit_ctx WHERE id = 1), NULL);
END;

View File

@@ -0,0 +1,29 @@
-- ============================================================
-- Fabric audit event sink — the table Pikku Fabric writes AuditEvent
-- records to. Distinct from the domain `audit_log` (row-change history
-- in 0001/0003): this is the framework-level event stream.
-- Columns mirror the AuditEvent shape (camelCase → snake_case for the
-- CamelCasePlugin kysely).
-- ============================================================
CREATE TABLE audit (
event_id TEXT PRIMARY KEY,
type TEXT NOT NULL,
source TEXT,
outcome TEXT,
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
function_id TEXT,
wire_type TEXT,
wire_id TEXT,
trace_id TEXT,
transaction_id TEXT,
query_id TEXT,
actor TEXT,
input TEXT,
metadata TEXT
);
CREATE INDEX idx_audit_type ON audit(type);
CREATE INDEX idx_audit_occurred ON audit(occurred_at);
CREATE INDEX idx_audit_function ON audit(function_id);
CREATE INDEX idx_audit_trace ON audit(trace_id);

View File

@@ -0,0 +1,13 @@
-- Rename the app RBAC field so it is never confused with the better-auth admin
-- plugin's `role`. `member_roles` holds a user's assigned community roles
-- (coordinator, facilitator, …); the admin plugin's `role` is platform-level.
ALTER TABLE user RENAME COLUMN roles TO member_roles;
-- better-auth admin plugin schema. `role` gates the admin/impersonation
-- endpoints (default 'user'); ban_* back the ban feature; session.impersonated_by
-- records the admin's id while a session is an impersonation.
ALTER TABLE user ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
ALTER TABLE user ADD COLUMN banned INTEGER NOT NULL DEFAULT 0;
ALTER TABLE user ADD COLUMN ban_reason TEXT;
ALTER TABLE user ADD COLUMN ban_expires TEXT;
ALTER TABLE session ADD COLUMN impersonated_by TEXT;

View File

@@ -0,0 +1,11 @@
-- =============================================================================
-- BETTER AUTH ACTOR PLUGIN — user-flow actors
-- Adds the `actor` boolean the Better Auth `actor()` plugin declares so its
-- schema is covered by an explicit migration and the db drift check passes.
-- Actor rows are synthetic users signed in by pikkuUserFlow via
-- POST /api/auth/sign-in/actor with the server-held USER_FLOW_ACTOR_SECRET;
-- the flag rides into the pikku core session so audits/analytics can address
-- synthetic traffic. A non-actor user can never be signed in this way.
-- =============================================================================
ALTER TABLE user ADD COLUMN actor integer NOT NULL DEFAULT 0;

13
db/sqlite/0007-fabric.sql Normal file
View File

@@ -0,0 +1,13 @@
-- =============================================================================
-- BETTER AUTH FABRIC PLUGIN — Fabric operator admin sessions
-- Adds the `fabric` boolean the Better Auth `fabric()` plugin declares so its
-- schema is covered by an explicit migration and the db drift check passes.
-- Fabric rows are synthetic operator users minted by POST /api/auth/sign-in/fabric
-- after verifying a short-lived RS256 token the Fabric control plane signed
-- (checked against FABRIC_AUTH_PUBLIC_KEY). They are created with role 'admin' so
-- the console Users tab can list/impersonate real end-users WITHOUT the operator
-- being one of them; these rows are filtered out of any end-user listing. A real
-- user can never be signed in this way.
-- =============================================================================
ALTER TABLE user ADD COLUMN fabric integer NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,852 @@
# Perauset Frontend Architecture
Single Next.js application with Mantine v8, server-side rendering, and Pikku RPC for data fetching. Role-based UI adapts the experience from a focused guest view to a full operations dashboard for coordinators and admins.
---
## Table of Contents
1. [Roles and Permissions Summary](#1-roles-and-permissions-summary)
2. [Navigation Structure](#2-navigation-structure)
3. [Route Map](#3-route-map)
4. [Page Specifications](#4-page-specifications)
5. [Shared Components](#5-shared-components)
6. [Data Fetching Strategy](#6-data-fetching-strategy)
7. [Mobile Considerations](#7-mobile-considerations)
8. [Authentication Flow](#8-authentication-flow)
---
## 1. Roles and Permissions Summary
The backend defines 8 roles. The frontend groups them into three tiers for UI purposes:
| Tier | Roles | What they see |
|------|-------|---------------|
| **Admin/Coordinator** | `admin`, `coordinator` | Full operations dashboard: all domains, review queues, audit log, user management |
| **Operational** | `facilitator`, `boat_coordinator`, `kitchen_lead`, `staff` | Domain-specific management views plus personal tools. Each role sees expanded UI for their domain. |
| **Community** | `volunteer`, `community_member`, `guest` | Personal dashboard: own stay, own requests, claimable tasks, meal schedule, dietary profile |
The frontend does NOT enforce permissions -- the backend does via tags. The frontend uses the session's `roles` array to determine which navigation items, pages, and action buttons to render. If a user lacks the permission for a page, the server call will fail with 403, and the UI shows a "not authorized" state.
---
## 2. Navigation Structure
### Layout: Responsive AppShell
- **Desktop** (>= 768px): Collapsible sidebar (240px open, 60px collapsed) + top bar with notifications and user menu
- **Mobile** (< 768px): Bottom tab bar (5 primary items) + hamburger drawer for secondary navigation
### Sidebar Sections (Desktop)
```
[Logo / App Name]
-- PERSONAL --
Dashboard (all authenticated users)
My Stay (all authenticated users)
My Requests (all authenticated users)
Notifications (all authenticated users)
-- OPERATIONS -- (staff+ roles only)
Stays & Rooms (stays.view_all OR rooms.view_all)
Boat Schedule (boats.view_all OR boats.manage)
Task Board (tasks.view_all)
Retreats (retreats.view OR retreats.manage)
Kitchen (kitchen.view OR kitchen.manage)
Inventory (inventory.manage OR inventory.request)
-- ADMIN -- (admin, coordinator only)
Users (users.view)
Audit Log (audit_log.view)
Settings (admin only)
[User Avatar / Menu]
Profile & Dietary
Sign Out
```
### Mobile Bottom Tab Bar
| Tab | Icon | Route | Visible to |
|-----|------|-------|------------|
| Home | house | `/` | All |
| Boats | ship | `/boats` | All (request); operational roles see more |
| Tasks | check-square | `/tasks` | All (claimable); staff+ see board |
| Meals | utensils | `/kitchen` | All |
| More | menu | drawer | All (opens sidebar content) |
---
## 3. Route Map
All routes are under the Next.js App Router. Auth-gated routes use a middleware check; role-gated routes render a `<RoleGate>` wrapper that redirects or shows an empty state.
### Public Routes
| Route | Purpose |
|-------|---------|
| `/login` | Credentials login form (Auth.js) |
| `/auth/error` | Auth error display |
### Authenticated Routes -- Personal
| Route | Page Title | Min. Role | API Calls |
|-------|-----------|-----------|-----------|
| `/` | Dashboard | any | `listStays(userId=me)`, `listNotifications(limit=5)`, `listTasks(status=open, limit=5)`, `listBoatTrips(from=today, limit=3)` |
| `/stay` | My Stay | any | `listStays(userId=me, status=checked_in)`, `listStayRequests(userId=me)` |
| `/stay/request` | Request a Stay | `stays.request` | `requestStay` (mutation), `listRetreats(status=published)` |
| `/requests` | My Requests | any | `listStayRequests(userId=me)`, `listBoatRequests(userId=me)`, `listInventoryRequests(userId=me)` (Note: some of these filters need to be added to the backend or the frontend filters client-side) |
| `/notifications` | Notifications | any | `listNotifications`, `markNotificationRead` |
| `/profile` | Profile & Dietary | any | `getUser(userId=me)`, `upsertDietaryProfile` |
### Authenticated Routes -- Boats
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/boats` | Boat Schedule | `boats.request` | `listBoatTrips(from=today)` |
| `/boats/trips/:tripId` | Trip Detail | `boats.request` | `listBoatTrips`, `listBoatRequests(tripId)` |
| `/boats/trips/:tripId/request` | Request Seat | `boats.request` | `requestBoatSeat` (mutation) |
| `/boats/manage` | Boat Management | `boats.manage` | `listBoatTrips`, `listBoatRequests` |
| `/boats/manage/trips/new` | Create Trip | `boats.manage` | `createBoatTrip` (mutation) |
| `/boats/manage/routes` | Route Management | `boats.manage` | `createBoatRoute` (mutation) |
| `/boats/manage/fleet` | Fleet (Boats) | `boats.manage` | `createBoat` (mutation) |
### Authenticated Routes -- Tasks
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/tasks` | Task Board | `tasks.claim` | `listTasks` |
| `/tasks/:taskId` | Task Detail | `tasks.claim` | `listTasks`, `claimTask`, `startTask`, `completeTask`, `markTaskBlocked` |
| `/tasks/manage` | Task Management | `tasks.manage` | `listTasks`, `createTaskInstance`, `assignTask`, `updateTask` |
| `/tasks/manage/templates` | Task Templates | `tasks.manage` | `createTaskTemplate` |
### Authenticated Routes -- Retreats
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/retreats` | Retreats List | `retreats.view` | `listRetreats` |
| `/retreats/:retreatId` | Retreat Detail | `retreats.view` | `listRetreats(id)`, schedule items, persons |
| `/retreats/manage` | Retreat Management | `retreats.manage` | `listRetreats`, `createRetreat`, `updateRetreat` |
| `/retreats/manage/:retreatId` | Edit Retreat | `retreats.manage` | `updateRetreat`, `addRetreatPerson`, `addRetreatScheduleItem`, `publishRetreat`, `cancelRetreat` |
### Authenticated Routes -- Kitchen
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/kitchen` | Meal Schedule | `kitchen.view` | `listMealServices(from=today)` |
| `/kitchen/dietary` | My Dietary Profile | `kitchen.update_dietary` | `upsertDietaryProfile` |
| `/kitchen/manage` | Kitchen Board | `kitchen.manage` | `listMealServices`, `createMealService` |
### Authenticated Routes -- Stays & Rooms (Operational)
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/stays` | All Stays | `stays.view_all` | `listStays`, `listStayRequests` |
| `/stays/requests` | Stay Request Queue | `stays.review` | `listStayRequests(status=submitted)`, `approveStayRequest`, `rejectStayRequest` |
| `/stays/:stayId` | Stay Detail | `stays.view_all` | `listStays(stayId)`, `checkInStay`, `checkOutStay`, `cancelStay` |
| `/rooms` | Room Overview | `rooms.view_all` | `listRooms`, `listRoomAllocations` |
| `/rooms/manage` | Room Management | `rooms.manage` | `createRoom`, `updateRoom`, `allocateRoom`, `releaseRoom` |
### Authenticated Routes -- Inventory
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/inventory` | Inventory List | `inventory.manage` | `listInventory` |
| `/inventory/request` | Request Supplies | `inventory.request` | `requestInventory` (mutation) |
| `/inventory/requests` | Request Queue | `inventory.manage` | list requests, `reviewInventoryRequest`, `fulfillInventoryRequest` |
### Authenticated Routes -- Admin
| Route | Page Title | Min. Permission | API Calls |
|-------|-----------|-----------------|-----------|
| `/admin/users` | User Directory | `users.view` | `listUsers` |
| `/admin/users/:userId` | User Detail | `users.view` | `getUser`, `updateUserProfile`, `setUserRoles` |
| `/admin/audit` | Audit Log | `audit_log.view` | `listAuditLog` |
---
## 4. Page Specifications
### 4.1 Dashboard (`/`)
The dashboard is role-adaptive. Every user sees it, but the content blocks differ.
**Layout**: Single-column stack on mobile, 2-column grid on desktop.
**Blocks (conditional on role)**:
| Block | Component | Visible to | Data |
|-------|-----------|------------|------|
| Welcome banner | `WelcomeBanner` | All | Session user name, current date |
| Active stay card | `ActiveStayCard` | All | `listStays(userId=me, status=checked_in)` -- shows room, dates, check-out date |
| Upcoming boats | `UpcomingBoatsList` | All | `listBoatTrips(from=today, limit=3)` -- next departures |
| My tasks | `MyTasksList` | `tasks.claim` | `listTasks(assignedTo=me, status!=completed)` |
| Claimable tasks | `ClaimableTasksList` | `tasks.claim` | `listTasks(status=open, limit=5)` |
| Unread notifications | `NotificationPreview` | All | `listNotifications(limit=5, unreadOnly=true)` |
| Stay requests pending | `PendingStayRequestsBadge` | `stays.review` | `listStayRequests(status=submitted)` -- count |
| Boat requests pending | `PendingBoatRequestsBadge` | `boats.manage` | `listBoatRequests(status=submitted)` -- count |
| Inventory requests pending | `PendingInventoryBadge` | `inventory.manage` | count of submitted inventory requests |
| Today's meals | `TodayMealsSummary` | `kitchen.view` | `listMealServices(date=today)` |
| Occupancy summary | `OccupancySummary` | `rooms.view_all` | `listStays(status=checked_in)` count vs. `listRooms` total capacity |
| Retreat in progress | `ActiveRetreatCard` | `retreats.view` | `listRetreats(status=active)` |
**Key interactions**:
- Each card links to its detail page
- Notification items can be marked read inline
- Pending-request badges are clickable shortcuts to the review queue
---
### 4.2 My Stay (`/stay`)
**Purpose**: Guest/community view of their current or upcoming stay.
**Components**:
| Component | Description |
|-----------|-------------|
| `StayStatusTimeline` | Visual timeline: Requested > Approved > Checked In > Checked Out. Highlights current step. |
| `StayDetailCard` | Dates, room allocation (if any), stay type, associated retreat |
| `RequestStayButton` | Mantine `Button` linking to `/stay/request`. Shown when no active stay exists. |
| `StayRequestList` | Table of the user's past and pending stay requests with status badges |
**Data flow**:
- SSR: `listStays(userId=session.userId)` + `listStayRequests(userId=session.userId)` (Note: backend may need a "my requests" variant or the frontend filters by userId client-side after fetching)
---
### 4.3 Request a Stay (`/stay/request`)
**Purpose**: Form to submit a stay request.
**Components**:
| Component | Description |
|-----------|-------------|
| `StayRequestForm` | Mantine form: `requestType` (select), date range picker (`requestedStartAt`, `requestedEndAt`), optional retreat selector (populated from `listRetreats(status=published)`), notes textarea |
| `RequestConfirmation` | Success state after submission showing request ID and "submitted" status |
**Data flow**:
- SSR: `listRetreats(status=published)` for the retreat dropdown
- Client mutation: `requestStay`
---
### 4.4 Stay Request Queue (`/stays/requests`)
**Purpose**: Coordinators review incoming stay requests.
**Components**:
| Component | Description |
|-----------|-------------|
| `StayRequestTable` | Mantine `DataTable` with columns: requester name, type, dates, retreat, status, submitted date. Filterable by status. |
| `StayRequestReviewDrawer` | Slide-out drawer on row click. Shows full details, user profile link. Approve/Reject buttons with optional review notes. |
| `StayRequestStatusFilter` | Segmented control: All / Submitted / Approved / Rejected |
**Data flow**:
- SSR: `listStayRequests(status=submitted)`
- Client mutations: `approveStayRequest`, `rejectStayRequest`
- After approval: prompt to `createStayFromRequest`
---
### 4.5 All Stays (`/stays`)
**Purpose**: Operational view of all active and historical stays.
**Components**:
| Component | Description |
|-----------|-------------|
| `StayTable` | Paginated table: user, dates, type, status, room, check-in/out times |
| `StayFilterBar` | Status filter (segmented), date range picker, user search |
| `StayActions` | Per-row action menu: Check In, Check Out, Cancel, View Detail |
| `StayDetailDrawer` | Drawer with full stay info + room allocation + linked request |
**Data flow**:
- SSR: `listStays(limit=50)`, `listRoomAllocations`
- Client mutations: `checkInStay`, `checkOutStay`, `cancelStay`
---
### 4.6 Room Overview (`/rooms`)
**Purpose**: Visual display of all rooms and their current allocation status.
**Components**:
| Component | Description |
|-----------|-------------|
| `RoomGrid` | Card grid showing each room: name, code, type, capacity, current occupant(s). Color-coded by status (vacant=green, occupied=amber, blocked=red, maintenance=gray). |
| `RoomAllocationTimeline` | Optional Gantt-style timeline view of room allocations over a date range. Uses Mantine `Timeline` or a custom canvas component. |
| `RoomFilterBar` | Filter by type (dorm, private, shared, etc.), status |
| `RoomDetailDrawer` | Drawer showing room details, allocation history, block history |
**Data flow**:
- SSR: `listRooms`, `listRoomAllocations(from=today)`
---
### 4.7 Room Management (`/rooms/manage`)
**Purpose**: Create rooms, allocate guests to rooms, manage blocks.
**Components**:
| Component | Description |
|-----------|-------------|
| `CreateRoomForm` | Modal form: name, code, type, capacity, notes |
| `EditRoomForm` | Same fields, pre-populated |
| `AllocateRoomForm` | Modal: select room, select stay, date range, reason |
| `ReleaseRoomButton` | Action on an active allocation |
| `RoomBlockForm` | Create a room block (maintenance, admin hold, retreat reserved) |
**Data flow**:
- Client mutations: `createRoom`, `updateRoom`, `allocateRoom`, `releaseRoom`
---
### 4.8 Boat Schedule (`/boats`)
**Purpose**: View upcoming boat trips and request seats.
**Components**:
| Component | Description |
|-----------|-------------|
| `BoatScheduleList` | Card list (mobile) or table (desktop) of upcoming trips. Each shows: route (origin > destination), departure time, boat name, available seats indicator, status badge. |
| `TripCard` | Expanded view on click: full details + "Request Seat" button if status is `open` |
| `BoatScheduleCalendar` | Optional calendar view for desktop showing trips by day |
| `DateNavigator` | Day/week selector to browse the schedule |
**Data flow**:
- SSR: `listBoatTrips(from=today, status=open)`
---
### 4.9 Trip Detail (`/boats/trips/:tripId`)
**Components**:
| Component | Description |
|-----------|-------------|
| `TripHeader` | Route, departure/arrival times, boat, status |
| `ManifestList` | List of confirmed passengers (visible to `boats.view_all`) |
| `RequestSeatButton` | Opens inline form or navigates to `/boats/trips/:tripId/request` |
| `RequestsList` | (boats.manage) Table of all seat requests for this trip with confirm/decline actions |
**Data flow**:
- SSR: trip detail from `listBoatTrips`, `listBoatRequests(tripId)`
- Client mutations: `requestBoatSeat`, `confirmBoatRequest`, `declineBoatRequest`
---
### 4.10 Boat Management (`/boats/manage`)
**Purpose**: Full boat operations for boat coordinators.
**Components**:
| Component | Description |
|-----------|-------------|
| `TripManagementTable` | All trips (not just open). Status filter. Actions: open, update, cancel, complete. |
| `CreateTripForm` | Modal: select route, select boat, departure datetime, arrival datetime, notes |
| `RequestReviewPanel` | Pending seat requests across all trips. Bulk confirm/decline. |
| `RouteManagementTab` | CRUD for boat routes |
| `FleetManagementTab` | CRUD for boats (name, capacity, cargo capacity) |
**Tabs within page**: Trips | Requests | Routes | Fleet
**Data flow**:
- SSR: `listBoatTrips`, `listBoatRequests`
- Client mutations: `createBoatTrip`, `openBoatTrip`, `updateBoatTrip`, `cancelBoatTrip`, `completeBoatTrip`, `confirmBoatRequest`, `declineBoatRequest`, `createBoatRoute`, `createBoat`
---
### 4.11 Task Board (`/tasks`)
**Purpose**: Kanban-style board of tasks. Community users see claimable tasks. Staff+ sees all tasks.
**Components**:
| Component | Description |
|-----------|-------------|
| `TaskKanban` | Columns: Open, Assigned, In Progress, Blocked, Completed. Each card shows title, category badge, location, scheduled time. Drag-and-drop on desktop (optional). |
| `TaskListView` | Alternative list view (default on mobile). Sortable by category, status, date. |
| `TaskCard` | Title, category pill, time, location. Click opens detail drawer. |
| `TaskDetailDrawer` | Full task info. Actions based on status: Claim (if open), Start (if assigned to me), Complete, Mark Blocked. |
| `TaskCategoryFilter` | Filter chips: housekeeping, kitchen, maintenance, operations, retreat, transport |
| `ViewToggle` | Board view / List view toggle |
**Data flow**:
- SSR: `listTasks`
- Client mutations: `claimTask`, `startTask`, `completeTask`, `markTaskBlocked`
---
### 4.12 Task Management (`/tasks/manage`)
**Purpose**: Coordinators create tasks, assign to people, manage templates.
**Components**:
| Component | Description |
|-----------|-------------|
| `CreateTaskForm` | Modal: title, description, category, location, scheduled start/end, optional template, linked retreat/stay |
| `AssignTaskDrawer` | Select a user, add notes. Triggers `assignTask`. |
| `TaskTemplateTable` | List of templates with title, category, recurrence, claimable flag. Create/edit actions. |
| `CreateTemplateForm` | Modal: title, description, category, estimated minutes, default location, recurrence cron, isClaimable toggle |
**Data flow**:
- Client mutations: `createTaskInstance`, `assignTask`, `updateTask`, `createTaskTemplate`
---
### 4.13 Retreats List (`/retreats`)
**Purpose**: Browse retreats. Community users see published/active retreats. Facilitators+ see all statuses.
**Components**:
| Component | Description |
|-----------|-------------|
| `RetreatCardGrid` | Card per retreat: name, dates, status badge, capacity indicator, description excerpt |
| `RetreatStatusFilter` | Tabs or segmented: All / Published / Active / Completed / Draft (draft only for `retreats.manage`) |
**Data flow**:
- SSR: `listRetreats`
---
### 4.14 Retreat Detail (`/retreats/:retreatId`)
**Components**:
| Component | Description |
|-----------|-------------|
| `RetreatHeader` | Name, dates, status, capacity bar |
| `RetreatSchedule` | Timeline/agenda view of schedule items (title, time, location, lead person) |
| `RetreatParticipantList` | Table of persons: name, role (organizer/facilitator/participant), attendance status |
| `RetreatDescription` | Full description text |
**Data flow**:
- SSR: retreat detail, schedule items, persons (these may need dedicated list endpoints or be embedded in a "get retreat" response -- if not available, consider adding `getRetreat` endpoint)
---
### 4.15 Retreat Management (`/retreats/manage/:retreatId`)
**Purpose**: Full CRUD for a retreat.
**Components**:
| Component | Description |
|-----------|-------------|
| `RetreatForm` | Edit name, description, dates, capacity, check-in/out defaults |
| `ScheduleEditor` | Add/edit/remove schedule items. Each item: title, start, end, location, lead person |
| `PersonManager` | Add persons (by user search or email/name for external). Set role type. Track attendance status. |
| `RetreatActions` | Publish, Cancel buttons with confirmation modals |
**Data flow**:
- Client mutations: `updateRetreat`, `addRetreatPerson`, `addRetreatScheduleItem`, `publishRetreat`, `cancelRetreat`
---
### 4.16 Meal Schedule (`/kitchen`)
**Purpose**: View upcoming meals for all community members.
**Components**:
| Component | Description |
|-----------|-------------|
| `MealDayView` | Grouped by day, showing breakfast/lunch/dinner/snack cards. Each card: meal type icon, time, headcount, menu notes, status badge. |
| `MealDatePicker` | Navigate between days |
| `DietaryProfileLink` | Banner linking to `/kitchen/dietary` if profile is incomplete |
**Data flow**:
- SSR: `listMealServices(date=today)`
---
### 4.17 Kitchen Board (`/kitchen/manage`)
**Purpose**: Kitchen lead manages meal services.
**Components**:
| Component | Description |
|-----------|-------------|
| `MealServiceTable` | Table with date, type, headcount, status, actions. Filterable by date range and meal type. |
| `CreateMealServiceForm` | Modal: date, type (breakfast/lunch/dinner/snack), planned headcount, menu notes, optional linked retreat |
| `MealStatusUpdater` | Quick status transitions: planned > ready > served > completed |
| `DietarySummary` | Aggregated dietary needs for a given date (count of vegetarian, vegan, gluten-free, etc. across expected attendees) |
**Data flow**:
- SSR: `listMealServices`
- Client mutations: `createMealService`
---
### 4.18 Dietary Profile (`/kitchen/dietary` or `/profile`)
**Components**:
| Component | Description |
|-----------|-------------|
| `DietaryProfileForm` | Toggle switches: isVegetarian, isVegan, isGlutenFree, isDairyFree, hasNutAllergy. Text fields: allergyNotes, dislikes, otherRequirements. |
**Data flow**:
- Client mutation: `upsertDietaryProfile`
---
### 4.19 Inventory (`/inventory`)
**Components**:
| Component | Description |
|-----------|-------------|
| `InventoryTable` | Table: name, unit, current qty, minimum qty, status indicator (low stock = qty < minimum). Sortable. |
| `LowStockAlert` | Banner at top if any items are below minimum |
| `RequestSuppliesButton` | Links to `/inventory/request` |
**Data flow**:
- SSR: `listInventory`
---
### 4.20 Inventory Request (`/inventory/request`)
**Components**:
| Component | Description |
|-----------|-------------|
| `InventoryRequestForm` | Item selector (from inventory list or free-text name), quantity, unit, purpose |
**Data flow**:
- SSR: `listInventory` (for item selector)
- Client mutation: `requestInventory`
---
### 4.21 Inventory Request Queue (`/inventory/requests`)
**Components**:
| Component | Description |
|-----------|-------------|
| `InventoryRequestTable` | Table: item name, qty, requester, status, date. Actions: review (approve/reject), fulfill. |
| `ReviewDrawer` | Review notes, approve or reject |
| `FulfillButton` | Marks a request as fulfilled |
**Data flow**:
- Client mutations: `reviewInventoryRequest`, `fulfillInventoryRequest`
---
### 4.22 User Directory (`/admin/users`)
**Components**:
| Component | Description |
|-----------|-------------|
| `UserTable` | Paginated table: name, email, roles (pills), status, joined date |
| `UserSearchBar` | Search by name or email |
| `UserStatusFilter` | Active / Inactive / Archived |
**Data flow**:
- SSR: `listUsers`
---
### 4.23 User Detail (`/admin/users/:userId`)
**Components**:
| Component | Description |
|-----------|-------------|
| `UserProfileCard` | Name, email, phone, status, date of birth, nationality, emergency contact |
| `RoleManager` | Multi-select of roles with save button. Uses `setUserRoles`. |
| `UserProfileEditForm` | Edit profile fields. Uses `updateUserProfile`. |
| `UserActivityTimeline` | Recent stays, tasks, requests (read-only, assembled from multiple API calls) |
**Data flow**:
- SSR: `getUser(userId)`
- Client mutations: `setUserRoles`, `updateUserProfile`
---
### 4.24 Audit Log (`/admin/audit`)
**Components**:
| Component | Description |
|-----------|-------------|
| `AuditLogTable` | Paginated table: timestamp, user, action, table, record ID, changed fields (JSON expandable) |
| `AuditLogFilters` | Filter by table name, user, action, date range |
**Data flow**:
- SSR: `listAuditLog`
---
### 4.25 Notifications (`/notifications`)
**Components**:
| Component | Description |
|-----------|-------------|
| `NotificationList` | Grouped by date. Each item: icon by type, title, body preview, timestamp, read/unread indicator. Click marks as read and navigates to linked entity. |
| `MarkAllReadButton` | Bulk action (may need backend endpoint or iterative calls) |
| `NotificationBell` | In top bar, shows unread count badge. Dropdown with last 5 notifications. |
**Data flow**:
- SSR: `listNotifications`
- Client mutation: `markNotificationRead`
---
## 5. Shared Components
### Layout Components
| Component | Description |
|-----------|-------------|
| `AppShell` | Mantine `AppShell` with sidebar, header, main content area. Responsive breakpoints. |
| `RoleGate` | Wrapper that checks `session.roles` against required permissions. Renders children or a "not authorized" message. |
| `SidebarNav` | Role-filtered navigation links. Active state via `usePathname()`. |
| `MobileTabBar` | Fixed bottom bar for mobile. 5 tabs with icons and labels. |
| `PageHeader` | Title, breadcrumbs, optional action buttons (top-right). |
| `NotificationBell` | Header icon with unread count, dropdown preview. |
### Data Display Components
| Component | Description |
|-----------|-------------|
| `StatusBadge` | Mantine `Badge` with color mapped to status string. Reused across stays, boats, tasks, retreats, inventory. |
| `DateRangeDisplay` | Formatted date range (e.g., "Mar 15 - Mar 22, 2026") |
| `UserAvatar` | Avatar with name tooltip. Links to user detail for admin roles. |
| `PaginatedTable` | Wrapper around Mantine `Table` with limit/offset pagination controls. |
| `EmptyState` | Illustration + message + optional CTA button for empty lists. |
| `LoadingState` | Skeleton loaders matching the expected content shape. |
| `ConfirmModal` | Reusable confirmation dialog for destructive actions (cancel stay, reject request, etc.) |
### Form Components
| Component | Description |
|-----------|-------------|
| `DateRangePicker` | Mantine `DatePickerInput` configured for range selection |
| `UserSelect` | Async searchable select that queries `listUsers` |
| `RoomSelect` | Select populated from `listRooms` |
| `RetreatSelect` | Select populated from `listRetreats` |
| `StatusFilter` | Segmented control or select for status enum values |
---
## 6. Data Fetching Strategy
### Server-Side (SSR)
Next.js App Router server components call Pikku RPC functions directly (or via a server-side HTTP client to `localhost:6002/api/...`). This provides:
- Fast initial page loads
- SEO for any public pages (currently none, but future-proofed)
- Session cookie forwarded from the incoming request
```
// Example: app/stays/page.tsx (server component)
async function StaysPage() {
const stays = await pikkuClient.listStays({ limit: 50 })
return <StayTable data={stays} />
}
```
### Client-Side (Mutations + Polling)
- All write operations (create, approve, reject, claim, etc.) are client-side via Pikku RPC
- After mutation success: invalidate relevant server data using `router.refresh()` or React Query / SWR cache invalidation
- Polling: notifications badge polls every 60 seconds via `listNotifications(limit=1, unreadOnly=true)` to update the count
- Optimistic updates for instant feedback on claim/complete task actions
### Recommended Data Layer
Use `@tanstack/react-query` for client-side cache management:
- Query keys follow the pattern: `['domain', 'action', params]` (e.g., `['stays', 'list', { status: 'checked_in' }]`)
- Mutations invalidate related query keys on success
- Server components pass initial data as props; client components hydrate from those props
---
## 7. Mobile Considerations
The island context means many users will be on phones, often with intermittent connectivity.
### Design Principles
1. **Touch-first**: All interactive targets minimum 44x44px. Use Mantine's `size="lg"` for buttons and inputs on mobile.
2. **Single-column layouts**: All pages stack to single column below 768px. No horizontal scrolling.
3. **Bottom-anchored actions**: Primary actions (Request Seat, Claim Task, Check In) use sticky bottom action bars on mobile, not top-right buttons.
4. **Offline tolerance**: Show last-fetched data with a "stale data" indicator when offline. Queue mutations and retry when connectivity returns (stretch goal -- implement in v2).
5. **Minimal data transfer**: Use pagination aggressively. Default `limit=20` on mobile (vs. 50 on desktop). Avoid loading images where text suffices.
### Mobile-Specific Components
| Component | Description |
|-----------|-------------|
| `BottomActionBar` | Sticky bottom bar with primary action button(s). Replaces top-right action buttons on mobile. |
| `SwipeableCard` | Swipe-right to claim a task, swipe-left to dismiss. Provides fast interaction on task board. |
| `PullToRefresh` | Pull-down gesture triggers `router.refresh()` on list pages. |
| `CompactTable` | Table that collapses to a card-list layout on mobile, showing only key fields with expandable detail. |
---
## 8. Authentication Flow
### Login (`/login`)
- Credentials form (email + password) using Auth.js
- On success: redirect to `/` (dashboard)
- Session stored as HTTP-only cookie
- Auth.js endpoints at `/auth/*` (handled by `auth.wiring.ts`)
### Session Management
- Middleware (`middleware.ts` in Next.js root) checks for valid session on all routes except `/login` and `/auth/*`
- Session object shape: `{ userId: string, roles: string[] }`
- Roles array used by `RoleGate` and sidebar filtering
- Session refresh handled automatically by Auth.js JWT rotation
### Logout
- POST to `/auth/signout` (Auth.js endpoint)
- Clear local state and redirect to `/login`
---
## Appendix: Complete API Endpoint Reference
For reference, here is every backend endpoint grouped by domain, with its HTTP method, route, and required permission tag.
### System
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| GET | `/health-check` | none (public) | `healthCheck` |
### Users
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| GET | `/users` | `users.view` | `listUsers` |
| GET | `/users/:userId` | `users.view` | `getUser` |
| PUT | `/users/:userId/profile` | `users.manage` | `updateUserProfile` |
| GET | `/roles` | authenticated | `listRoles` |
| PUT | `/users/:userId/roles` | `roles.assign` | `setUserRoles` |
### Stays
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| POST | `/stays/requests` | `stays.request` | `requestStay` |
| POST | `/stays/requests/:requestId/approve` | `stays.review` | `approveStayRequest` |
| POST | `/stays/requests/:requestId/reject` | `stays.review` | `rejectStayRequest` |
| GET | `/stays/requests` | `stays.view_all` | `listStayRequests` |
| POST | `/stays` | `stays.manage` | `createStayFromRequest` |
| POST | `/stays/:stayId/check-in` | `stays.manage` | `checkInStay` |
| POST | `/stays/:stayId/check-out` | `stays.manage` | `checkOutStay` |
| POST | `/stays/:stayId/cancel` | `stays.manage` | `cancelStay` |
| GET | `/stays` | `stays.view_all` | `listStays` |
### Rooms
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| POST | `/rooms` | `rooms.manage` | `createRoom` |
| PUT | `/rooms/:roomId` | `rooms.manage` | `updateRoom` |
| POST | `/rooms/allocations` | `rooms.manage` | `allocateRoom` |
| POST | `/rooms/allocations/:allocationId/release` | `rooms.manage` | `releaseRoom` |
| GET | `/rooms` | `rooms.view_all` | `listRooms` |
| GET | `/rooms/allocations` | `rooms.view_all` | `listRoomAllocations` |
### Boats
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| POST | `/boats/routes` | `boats.manage` | `createBoatRoute` |
| POST | `/boats` | `boats.manage` | `createBoat` |
| POST | `/boats/trips` | `boats.manage` | `createBoatTrip` |
| POST | `/boats/trips/:tripId/open` | `boats.manage` | `openBoatTrip` |
| POST | `/boats/trips/:tripId/request-seat` | `boats.request` | `requestBoatSeat` |
| POST | `/boats/requests/:requestId/confirm` | `boats.manage` | `confirmBoatRequest` |
| POST | `/boats/requests/:requestId/decline` | `boats.manage` | `declineBoatRequest` |
| PUT | `/boats/trips/:tripId` | `boats.manage` | `updateBoatTrip` |
| POST | `/boats/trips/:tripId/cancel` | `boats.manage` | `cancelBoatTrip` |
| POST | `/boats/trips/:tripId/complete` | `boats.manage` | `completeBoatTrip` |
| GET | `/boats/trips` | `boats.view_all` | `listBoatTrips` |
| GET | `/boats/requests` | `boats.view_all` | `listBoatRequests` |
### Tasks
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| POST | `/tasks/templates` | `tasks.manage` | `createTaskTemplate` |
| POST | `/tasks` | `tasks.manage` | `createTaskInstance` |
| POST | `/tasks/:taskId/assign` | `tasks.assign` | `assignTask` |
| POST | `/tasks/:taskId/claim` | `tasks.claim` | `claimTask` |
| POST | `/tasks/:taskId/start` | `tasks.manage` | `startTask` |
| PUT | `/tasks/:taskId` | `tasks.manage` | `updateTask` |
| POST | `/tasks/:taskId/complete` | `tasks.manage` | `completeTask` |
| POST | `/tasks/:taskId/block` | `tasks.manage` | `markTaskBlocked` |
| GET | `/tasks` | `tasks.view_all` | `listTasks` |
### Retreats
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| POST | `/retreats` | `retreats.manage` | `createRetreat` |
| PUT | `/retreats/:retreatId` | `retreats.manage` | `updateRetreat` |
| POST | `/retreats/:retreatId/persons` | `retreats.manage` | `addRetreatPerson` |
| POST | `/retreats/:retreatId/publish` | `retreats.manage` | `publishRetreat` |
| POST | `/retreats/:retreatId/cancel` | `retreats.manage` | `cancelRetreat` |
| POST | `/retreats/:retreatId/schedule` | `retreats.manage` | `addRetreatScheduleItem` |
| GET | `/retreats` | `retreats.view` | `listRetreats` |
### Kitchen
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| PUT | `/kitchen/dietary-profile` | `kitchen.update_dietary` | `upsertDietaryProfile` |
| POST | `/kitchen/meal-services` | `kitchen.manage` | `createMealService` |
| GET | `/kitchen/meal-services` | `kitchen.view` | `listMealServices` |
### Inventory
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| POST | `/inventory/requests` | `inventory.request` | `requestInventory` |
| POST | `/inventory/requests/:requestId/review` | `inventory.manage` | `reviewInventoryRequest` |
| POST | `/inventory/requests/:requestId/fulfill` | `inventory.manage` | `fulfillInventoryRequest` |
| GET | `/inventory` | `inventory.manage` | `listInventory` |
### Notifications
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| GET | `/notifications` | authenticated | `listNotifications` |
| POST | `/notifications/:notificationId/read` | authenticated | `markNotificationRead` |
### Audit
| Method | Route | Permission | Function |
|--------|-------|------------|----------|
| GET | `/audit-log` | `audit_log.view` | `listAuditLog` |
---
## Appendix: Backend Gaps Identified
During architecture design, several backend additions would improve the frontend experience. These are not blockers for v1 but should be prioritized for the first iteration.
| Gap | Impact | Suggested Endpoint |
|-----|--------|--------------------|
| No "get single entity" endpoints for stay, retreat, trip, task | Detail pages must filter list endpoints or add dedicated `getStay`, `getRetreat`, `getBoatTrip`, `getTask` | `GET /stays/:stayId`, `GET /retreats/:retreatId`, `GET /boats/trips/:tripId`, `GET /tasks/:taskId` |
| No "my requests" filter on stay requests | Guest cannot efficiently see only their own requests | Add `userId` filter param to `listStayRequests` |
| No "my boat requests" filter | Guest cannot see their own boat reservation requests | Add `userId` filter param to `listBoatRequests` |
| No "my inventory requests" view | Users who requested supplies can't track them | Add `listInventoryRequests` with `userId` filter |
| No "get retreat" with schedule + persons | Retreat detail page needs 3 separate calls or a compound endpoint | `GET /retreats/:retreatId` returning nested schedule + persons |
| No list endpoints for boat routes or boats (fleet) | Boat management UI needs to populate dropdowns | `GET /boats/routes`, `GET /boats` |
| No "mark all notifications read" | Common UX pattern, currently requires N calls | `POST /notifications/mark-all-read` |
| No unread notification count | Notification bell badge requires fetching full list | `GET /notifications/unread-count` |
| `listStayRequests` filtered to own user for non-admin | Guests with `stays.request` but not `stays.view_all` need to see their own requests | Either a separate `GET /stays/my-requests` or backend auto-filters by session userId when `stays.view_all` is absent |

2
e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.tmp/
reports/

23
e2e/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@perauset/e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "cucumber-js --config tests/cucumber.mjs",
"test:browser": "cucumber-js --config tests/cucumber-browser.mjs",
"tsc": "tsc --noEmit"
},
"devDependencies": {
"@cucumber/cucumber": "^11.0.0",
"@playwright/test": "^1.58.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25",
"@types/pg": "^8",
"better-sqlite3": "^12.11.1",
"pg": "^8.16.0",
"playwright": "^1.58.2",
"tsx": "^4.21.0",
"typescript": "^5.9"
}
}

View File

@@ -0,0 +1,7 @@
export default {
paths: ['tests/features/browser/*.feature'],
import: ['tests/support/browser-world.ts', 'tests/steps/browser/*.ts', 'tests/support/browser-hooks.ts'],
requireModule: ['tsx'],
format: ['progress', 'html:tests/reports/browser-report.html'],
// failFast: true,
}

6
e2e/tests/cucumber.mjs Normal file
View File

@@ -0,0 +1,6 @@
export default {
requireModule: ['tsx'],
require: ['tests/support/world.ts', 'tests/support/hooks.ts', 'tests/steps/*.ts'],
paths: ['tests/features/*.feature'],
format: ['progress-bar', 'html:tests/reports/cucumber-report.html'],
}

View File

@@ -0,0 +1,14 @@
Feature: Audit Log
Scenario: Audit log captures changes
Given I login as "admin@perauset.org"
When I update my profile with displayName "Audited Admin"
And I send a GET request to "/audit-log?tableName=user"
Then the response status should be 200
And the audit log should contain an "UPDATE" entry for table "user"
Scenario: Unpermissioned user cannot view audit log
Given a user "noaudit@test.org" exists with roles "guest"
And I login as "noaudit@test.org"
When I send a GET request to "/audit-log"
Then the response status should be 403

View File

@@ -0,0 +1,10 @@
Feature: Authentication
Scenario: Unauthenticated request to protected endpoint
When I send a GET request to "/users"
Then the response status should be 403
Scenario: Login and get session
Given I login as "admin@perauset.org"
When I send a GET request to "/api/auth/get-session"
Then the response status should be 200

View File

@@ -0,0 +1,122 @@
Feature: Boat Management
Scenario: Create route, boat, and trip as coordinator
Given I login as "admin@perauset.org"
When I create a boat route with name "Main Ferry" origin "Mainland" destination "Island"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Sea Spirit" capacity 20
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-06-15T08:00:00Z"
Then the response status should be 200
And the response body field "status" should be "planned"
And I store the response field "tripId" as "tripId"
Scenario: Open trip for requests
Given I login as "admin@perauset.org"
When I create a boat route with name "Express Route" origin "Port A" destination "Port B"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Wave Runner" capacity 15
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-07-01T09:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
And the response body field "status" should be "open"
Scenario: Request seat as community member
Given I login as "admin@perauset.org"
When I create a boat route with name "Shuttle" origin "Dock A" destination "Dock B"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Island Hopper" capacity 10
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-08-01T07:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request a seat on the boat trip
Then the response status should be 200
And the response body field "status" should be "submitted"
And I store the response field "requestId" as "boatRequestId"
Scenario: Confirm boat request as coordinator
Given I login as "admin@perauset.org"
When I create a boat route with name "Confirm Route" origin "Harbor" destination "Cove"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Coral Cruiser" capacity 12
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-09-01T10:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request a seat on the boat trip
Then the response status should be 200
And I store the response field "requestId" as "boatRequestId"
Given I login as "admin@perauset.org"
When I confirm the boat request
Then the response status should be 200
And the response body field "status" should be "confirmed"
Scenario: Cancel a trip
Given I login as "admin@perauset.org"
When I create a boat route with name "Cancel Route" origin "Pier" destination "Bay"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Sunset Sailor" capacity 8
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-10-01T06:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
When I cancel the boat trip
Then the response status should be 200
And the response body field "status" should be "cancelled"
Scenario: Unpermissioned user cannot create trips
Given I login as "admin@perauset.org"
When I create a boat route with name "Forbidden Route" origin "X" destination "Y"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a boat trip for the route without boat departing "2026-06-15T08:00:00Z"
Then the response status should be 403
Scenario: Unpermissioned user cannot confirm requests
Given I login as "admin@perauset.org"
When I create a boat route with name "Perm Route" origin "North" destination "South"
Then the response status should be 200
And I store the response field "routeId" as "routeId"
When I create a boat with name "Perm Boat" capacity 10
Then the response status should be 200
And I store the response field "boatId" as "boatId"
When I create a boat trip for the route and boat departing "2026-11-01T08:00:00Z"
Then the response status should be 200
And I store the response field "tripId" as "tripId"
When I open the boat trip
Then the response status should be 200
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request a seat on the boat trip
Then the response status should be 200
And I store the response field "requestId" as "boatRequestId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I confirm the boat request
Then the response status should be 403

View File

@@ -0,0 +1,14 @@
Feature: Finance UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Create finance record
When I navigate to "/finance"
And I click "Add Record"
And I fill in "Name" with "Kitchen Supplies"
And I fill in "Amount" with "150"
And I select "Expense" from "Type"
And I select "Kitchen" from "Category"
And I click "Create Record"
Then I should see "Kitchen Supplies"

View File

@@ -0,0 +1,16 @@
Feature: Inventory Management UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Add inventory item
When I navigate to "/inventory"
Then I should see heading "Inventory"
And I click "Add Item"
And I fill in "Item Name" with "Olive Oil"
And I fill in "Unit" with "liter"
And I fill in "Current Quantity" with "20"
And I fill in "Minimum Quantity" with "5"
And I select "Kitchen" from "Category"
And I click "Create Item"
Then I should see "Olive Oil"

View File

@@ -0,0 +1,15 @@
Feature: Login
Scenario: Login with valid credentials
Given I am on the login page
Then I should see "PerAuset"
And I should see "Sign in to your account"
When I fill in "Email" with "admin@perauset.org"
And I fill in "Password" with "test"
And I click "Sign in"
Then I should see heading "Dashboard"
And the sidebar should show "Dashboard"
Scenario: Login page pre-fills demo credentials
Given I am on the login page
Then the "Email" field should contain "admin@perauset.org"

View File

@@ -0,0 +1,63 @@
Feature: Navigation
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Dashboard loads
Then I should see heading "Dashboard"
And I should see "Stays"
Scenario: Navigate to Boats
When I click "Boats" in the sidebar
Then I should see heading "Boat Trips"
Scenario: Navigate to Kitchen
When I click "Kitchen" in the sidebar
Then I should see heading "Kitchen"
Scenario: Navigate to Inventory
When I click "Inventory" in the sidebar
Then I should see heading "Inventory"
Scenario: Navigate to Finance
When I click "Finance" in the sidebar
Then I should see heading "Finance"
Scenario: Navigate to Retreats
When I click "Retreats" in the sidebar
Then I should see heading "Retreats"
Scenario: Navigate to Rooms
When I click "Rooms" in the sidebar
Then I should see heading "Rooms"
Scenario: Navigate to Boat Management
When I click "Boat Management" in the sidebar
Then I should see heading "Fleet Management"
Scenario: Navigate to Users
When I click "Users" in the sidebar
Then I should see heading "Users & Roles"
Scenario: Navigate to Audit Log
When I click "Audit Log" in the sidebar
Then I should see heading "Audit Log"
Scenario: Navigate to My Stays
When I click "My Stays" in the sidebar
Then I should see heading "My Stays"
Scenario: Navigate to My Profile
When I click "My Profile" in the sidebar
Then I should see heading "My Profile"
# The Operations "Stays" and "Tasks" sidebar links share their labels with the
# personal "My Stays"/"My Tasks" links (i18n: nav.stays == nav.my_stays), so
# they are exercised via direct URL navigation instead of the ambiguous sidebar.
Scenario: Open the Stays operations page
When I navigate to "/stays"
Then I should see heading "Stays"
Scenario: Open the Tasks operations page
When I navigate to "/tasks"
Then I should see heading "Tasks"

View File

@@ -0,0 +1,15 @@
Feature: Room Management UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Create a room
When I navigate to "/rooms"
Then I should see heading "Rooms"
And I click "Create Room"
And I fill in "Room Code" with "R-101"
And I fill in "Room Name" with "Sunrise Room"
And I select "Private" from "Room Type"
And I fill in "Capacity" with "2"
And I click "Save Room"
Then I should see "Sunrise Room"

View File

@@ -0,0 +1,15 @@
Feature: Stay Management UI
Background:
Given I am logged in as "admin@perauset.org"
Scenario: Stay request drawer opens
When I navigate to "/stays"
Then I should see heading "Stays"
When I click "Request a Stay"
Then I should see "Request Type"
Scenario: View stay requests tab
When I navigate to "/stays"
And I click tab "Requests"
Then I should see "No stay requests found."

View File

@@ -0,0 +1,58 @@
Feature: Finance Management
Scenario: Create finance record as admin
Given I login as "admin@perauset.org"
When I create a finance record "Office Supplies" with amount 150 and type "expense"
Then the response status should be 200
And the response body field "name" should be "Office Supplies"
And the response body field "recordType" should be "expense"
And I store the response field "financeId" as "financeId"
Scenario: List finance records
Given I login as "admin@perauset.org"
When I create a finance record "Boat Fuel" with amount 200 and type "expense"
Then the response status should be 200
When I list finance records
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Update finance record
Given I login as "admin@perauset.org"
When I create a finance record "Garden Tools" with amount 75 and type "expense"
Then the response status should be 200
And I store the response field "financeId" as "financeId"
When I update the finance record with name "Garden Tools (Updated)"
Then the response status should be 200
And the response body field "name" should be "Garden Tools (Updated)"
Scenario: Link finance record to entity
Given I login as "admin@perauset.org"
When I create a finance record "Kitchen Equipment" with amount 500 and type "expense"
Then the response status should be 200
And I store the response field "financeId" as "financeId"
When I create an inventory item "Blender" with unit "pieces" quantity 2 and minimum 1
Then the response status should be 200
And I store the response field "itemId" as "itemId"
When I link the finance record to entity type "inventory_item"
Then the response status should be 200
And the response body field "linkId" should be truthy
Scenario: List links for finance record
Given I login as "admin@perauset.org"
When I create a finance record "Retreat Supplies" with amount 300 and type "expense"
Then the response status should be 200
And I store the response field "financeId" as "financeId"
When I create an inventory item "Yoga Mats" with unit "pieces" quantity 10 and minimum 2
Then the response status should be 200
And I store the response field "itemId" as "itemId"
When I link the finance record to entity type "inventory_item"
Then the response status should be 200
When I list finance links for the current finance record
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Unpermissioned user cannot create finance records
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a finance record "Forbidden Record" with amount 100 and type "expense"
Then the response status should be 403

View File

@@ -0,0 +1,6 @@
Feature: Health Check
The health check endpoint should return service status
Scenario: Health check returns ok
When I send a GET request to "/health-check"
Then the response status should be 200

View File

@@ -0,0 +1,77 @@
Feature: Inventory Management
Scenario: Create inventory item as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Rice" with unit "kg" quantity 100 and minimum 10
Then the response status should be 200
And the response body field "name" should be "Rice"
And the response body field "unit" should be "kg"
And the response body field "isActive" should be truthy
And I store the response field "itemId" as "itemId"
Scenario: Update inventory item as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Flour" with unit "kg" quantity 50 and minimum 5
Then the response status should be 200
And I store the response field "itemId" as "itemId"
When I update the inventory item with name "Whole Wheat Flour"
Then the response status should be 200
And the response body field "name" should be "Whole Wheat Flour"
Scenario: List inventory items
Given I login as "admin@perauset.org"
When I create an inventory item "Olive Oil" with unit "liters" quantity 20 and minimum 5
Then the response status should be 200
When I list inventory items
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Request inventory item as community member
Given I login as "admin@perauset.org"
When I create an inventory item "Sugar" with unit "kg" quantity 30 and minimum 5
Then the response status should be 200
And I store the response field "itemId" as "itemId"
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request inventory for item with quantity 5 unit "kg" and purpose "Baking"
Then the response status should be 200
And the response body field "status" should be "submitted"
And I store the response field "requestId" as "requestId"
Scenario: Review inventory request as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Salt" with unit "kg" quantity 20 and minimum 2
Then the response status should be 200
And I store the response field "itemId" as "itemId"
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request inventory for item with quantity 3 unit "kg" and purpose "Cooking"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I review the inventory request with status "approved"
Then the response status should be 200
And the response body field "status" should be "approved"
Scenario: Fulfill inventory request as admin
Given I login as "admin@perauset.org"
When I create an inventory item "Pepper" with unit "kg" quantity 15 and minimum 2
Then the response status should be 200
And I store the response field "itemId" as "itemId"
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I request inventory for item with quantity 2 unit "kg" and purpose "Seasoning"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I review the inventory request with status "approved"
Then the response status should be 200
When I fulfill the inventory request
Then the response status should be 200
And the response body field "status" should be "fulfilled"
Scenario: Unpermissioned user cannot create inventory items
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create an inventory item "Forbidden Item" with unit "kg" quantity 10 and minimum 1
Then the response status should be 403

View File

@@ -0,0 +1,29 @@
Feature: Kitchen Management
Scenario: Create meal service as admin
Given I login as "admin@perauset.org"
When I create a meal service of type "lunch" on "2026-07-01" with headcount 20
Then the response status should be 200
And the response body field "status" should be "planned"
And I store the response field "serviceId" as "serviceId"
Scenario: List meal services
Given I login as "admin@perauset.org"
When I create a meal service of type "breakfast" on "2026-07-02" with headcount 15
Then the response status should be 200
When I list meal services
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Upsert dietary profile
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I upsert my dietary profile as vegan with nut allergy
Then the response status should be 200
And the response body field "profileId" should be truthy
Scenario: Unpermissioned user cannot create meal services
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a meal service of type "dinner" on "2026-07-03" with headcount 10
Then the response status should be 403

View File

@@ -0,0 +1,14 @@
Feature: Notification Management
Scenario: List notifications
Given I login as "admin@perauset.org"
When I send a GET request to "/notifications"
Then the response status should be 200
And the response body should be a list with field "items"
Scenario: Mark notification as read
Given I login as "admin@perauset.org"
And a test notification exists for the current user
When I mark the notification as read
Then the response status should be 200
And the response body field "success" should be truthy

View File

@@ -0,0 +1,68 @@
Feature: Retreat Management
Scenario: Create retreat as admin
Given I login as "admin@perauset.org"
When I create a retreat "Summer Solstice" with slug "summer-solstice-2026" from "2026-06-20" to "2026-06-22"
Then the response status should be 200
And the response body field "status" should be "draft"
And I store the response field "retreatId" as "retreatId"
Scenario: List retreats
Given I login as "admin@perauset.org"
When I create a retreat "Autumn Equinox" with slug "autumn-equinox-2026" from "2026-09-22" to "2026-09-24"
Then the response status should be 200
When I list retreats
Then the response status should be 200
And the response body field "items" should be truthy
Scenario: Update retreat
Given I login as "admin@perauset.org"
When I create a retreat "Spring Retreat" with slug "spring-retreat-2026" from "2026-03-20" to "2026-03-22"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I update the retreat with name "Spring Renewal Retreat" and capacity 30
Then the response status should be 200
And the response body field "success" should be truthy
Scenario: Publish retreat
Given I login as "admin@perauset.org"
When I create a retreat "Winter Solstice" with slug "winter-solstice-2026" from "2026-12-20" to "2026-12-22"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I publish the retreat
Then the response status should be 200
And the response body field "status" should be "published"
Scenario: Add person to retreat
Given I login as "admin@perauset.org"
When I create a retreat "Meditation Retreat" with slug "meditation-2026" from "2026-08-01" to "2026-08-03"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I add a person "Jane Doe" with role "participant" to the retreat
Then the response status should be 200
And the response body field "attendanceStatus" should be "registered"
And I store the response field "retreatPersonId" as "retreatPersonId"
Scenario: Add schedule item to retreat
Given I login as "admin@perauset.org"
When I create a retreat "Yoga Retreat" with slug "yoga-retreat-2026" from "2026-10-01" to "2026-10-03"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I add a schedule item "Morning Yoga" from "2026-10-01T07:00:00Z" to "2026-10-01T08:30:00Z" at "Main Hall"
Then the response status should be 200
And the response body field "itemId" should be truthy
Scenario: Cancel retreat
Given I login as "admin@perauset.org"
When I create a retreat "Cancelled Event" with slug "cancelled-event-2026" from "2026-11-01" to "2026-11-03"
Then the response status should be 200
And I store the response field "retreatId" as "retreatId"
When I cancel the retreat
Then the response status should be 200
And the response body field "status" should be "cancelled"
Scenario: Unpermissioned user cannot create retreats
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a retreat "Forbidden Retreat" with slug "forbidden-2026" from "2026-07-01" to "2026-07-03"
Then the response status should be 403

View File

@@ -0,0 +1,20 @@
Feature: Role Management
Scenario: List all roles
Given I login as "admin@perauset.org"
When I send a GET request to "/roles"
Then the response status should be 200
And the response body should be a list with field "roles"
Scenario: Set user roles
Given I login as "admin@perauset.org"
And a user "vol@test.org" exists
When I set roles "volunteer" for user "vol@test.org"
Then the response status should be 200
And the response body field "memberRoles" should contain "volunteer"
Scenario: Unpermissioned user cannot set roles
Given a user "norole@test.org" exists with roles "guest"
And I login as "norole@test.org"
When I set roles "admin" for user "norole@test.org"
Then the response status should be 403

View File

@@ -0,0 +1,105 @@
Feature: Room Management
Scenario: Create a room as coordinator
Given I login as "admin@perauset.org"
When I create a room with code "R101" name "Room 101" type "private" capacity 2
Then the response status should be 200
And I store the response field "roomId" as "roomId"
Scenario: Allocate room to a stay
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-06-01" to "2026-06-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
When I create a room with code "R201" name "Room 201" type "shared" capacity 4
Then the response status should be 200
And I store the response field "roomId" as "roomId"
When I allocate the room to the stay from "2026-06-01" to "2026-06-10"
Then the response status should be 200
And I store the response field "allocationId" as "allocationId"
Scenario: Double allocation for overlapping dates fails
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-07-01" to "2026-07-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId1"
Given I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-07-05" to "2026-07-15"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId2"
When I create a room with code "R301" name "Room 301" type "dorm" capacity 6
Then the response status should be 200
And I store the response field "roomId" as "roomId"
When I allocate the room to stored stay "stayId1" from "2026-07-01" to "2026-07-10"
Then the response status should be 200
When I allocate the room to stored stay "stayId2" from "2026-07-05" to "2026-07-15"
Then the response status should be 409
Scenario: Release room allocation
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-08-01" to "2026-08-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
When I create a room with code "R401" name "Room 401" type "facilitator" capacity 1
Then the response status should be 200
And I store the response field "roomId" as "roomId"
When I allocate the room to the stay from "2026-08-01" to "2026-08-10"
Then the response status should be 200
And I store the response field "allocationId" as "allocationId"
Given the room allocation is active in the database
When I release the room allocation
Then the response status should be 200
And the response body field "status" should be "completed"
Scenario: Unpermissioned user cannot create rooms
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a room with code "R501" name "Room 501" type "dorm" capacity 4
Then the response status should be 403
Scenario: Unpermissioned user cannot allocate rooms
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-09-01" to "2026-09-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
When I create a room with code "R501" name "Room 501" type "dorm" capacity 4
Then the response status should be 200
And I store the response field "roomId" as "roomId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I allocate the room to the stay from "2026-09-01" to "2026-09-10"
Then the response status should be 403

View File

@@ -0,0 +1,102 @@
Feature: Stay Management
Scenario: Submit a stay request as community member
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-06-01" to "2026-06-15"
Then the response status should be 200
And the response body field "status" should be "submitted"
And I store the response field "requestId" as "requestId"
Scenario: Approve a stay request as coordinator
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-07-01" to "2026-07-15"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
And the response body field "status" should be "approved"
Scenario: Create stay from approved request
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "guest" from "2026-08-01" to "2026-08-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And the response body field "status" should be "pending"
And I store the response field "stayId" as "stayId"
Scenario: Check in and check out a stay
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-09-01" to "2026-09-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I create a stay from the approved request
Then the response status should be 200
And I store the response field "stayId" as "stayId"
Given the stay is confirmed in the database
When I check in the stay
Then the response status should be 200
And the response body field "status" should be "checked_in"
When I check out the stay
Then the response status should be 200
And the response body field "status" should be "checked_out"
Scenario: Reject a stay request
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "day_visit" from "2026-10-01" to "2026-10-01"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I reject the stay request with reason "Not available"
Then the response status should be 200
And the response body field "status" should be "rejected"
Scenario: Invalid transition - reject an already approved request
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-11-01" to "2026-11-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
When I reject the stay request with reason "Changed mind"
Then the response status should be 409
Scenario: Unpermissioned user cannot approve requests
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2026-12-01" to "2026-12-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I approve the stay request
Then the response status should be 403
Scenario: Unpermissioned user cannot create stays
Given a user "member@test.org" exists with roles "community_member"
And I login as "member@test.org"
When I submit a stay request with type "volunteer" from "2027-01-01" to "2027-01-10"
Then the response status should be 200
And I store the response field "requestId" as "requestId"
Given I login as "admin@perauset.org"
When I approve the stay request
Then the response status should be 200
Given a user "guest@test.org" exists with roles "guest"
And I login as "guest@test.org"
When I create a stay from the approved request
Then the response status should be 403

Some files were not shown because too many files have changed in this diff Show More