chore: perauset customer project
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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
49
.gitignore
vendored
Normal 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
42
CLAUDE.md
Normal 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
260
PLATFORM-REVIEW.md
Normal 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.
|
||||||
1249
ai/memory-bank/tasks/perauset-implementation-plan.md
Normal file
1249
ai/memory-bank/tasks/perauset-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1117
ai/memory-bank/ux-architecture.md
Normal file
1117
ai/memory-bank/ux-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/app/messages/de.json
Normal file
27
apps/app/messages/de.json
Normal 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
612
apps/app/messages/en.json
Normal 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
40
apps/app/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/app/project.inlang/settings.json
Normal file
11
apps/app/project.inlang/settings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
305
apps/app/src/components/ai-chat.tsx
Normal file
305
apps/app/src/components/ai-chat.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
apps/app/src/components/create-template-form.tsx
Normal file
205
apps/app/src/components/create-template-form.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
apps/app/src/components/locale-switcher.tsx
Normal file
30
apps/app/src/components/locale-switcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
282
apps/app/src/components/notification-bell.tsx
Normal file
282
apps/app/src/components/notification-bell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
apps/app/src/components/notification-row.tsx
Normal file
140
apps/app/src/components/notification-row.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
419
apps/app/src/components/stays-tabs.tsx
Normal file
419
apps/app/src/components/stays-tabs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
208
apps/app/src/components/task-actions.tsx
Normal file
208
apps/app/src/components/task-actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
apps/app/src/components/user-select.tsx
Normal file
65
apps/app/src/components/user-select.tsx
Normal 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
101
apps/app/src/i18n/config.ts
Normal 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
|
||||||
|
}
|
||||||
12
apps/app/src/i18n/ident.ts
Normal file
12
apps/app/src/i18n/ident.ts
Normal 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()
|
||||||
54
apps/app/src/i18n/messages.ts
Normal file
54
apps/app/src/i18n/messages.ts
Normal 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
|
||||||
|
}
|
||||||
18
apps/app/src/lib/auth-context.tsx
Normal file
18
apps/app/src/lib/auth-context.tsx
Normal 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
20
apps/app/src/lib/auth.ts
Normal 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
21
apps/app/src/lib/env.ts
Normal 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`
|
||||||
|
}
|
||||||
21
apps/app/src/lib/permissions.ts
Normal file
21
apps/app/src/lib/permissions.ts
Normal 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)
|
||||||
|
}
|
||||||
78
apps/app/src/pages/__root.tsx
Normal file
78
apps/app/src/pages/__root.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
apps/app/src/pages/_authenticated.audit-log.tsx
Normal file
128
apps/app/src/pages/_authenticated.audit-log.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
394
apps/app/src/pages/_authenticated.boats.index.tsx
Normal file
394
apps/app/src/pages/_authenticated.boats.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
450
apps/app/src/pages/_authenticated.boats.manage.tsx
Normal file
450
apps/app/src/pages/_authenticated.boats.manage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
apps/app/src/pages/_authenticated.boats.request.tsx
Normal file
128
apps/app/src/pages/_authenticated.boats.request.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
306
apps/app/src/pages/_authenticated.boats.trips.$tripId.tsx
Normal file
306
apps/app/src/pages/_authenticated.boats.trips.$tripId.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/app/src/pages/_authenticated.boats.tsx
Normal file
5
apps/app/src/pages/_authenticated.boats.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/boats')({
|
||||||
|
component: Outlet,
|
||||||
|
})
|
||||||
899
apps/app/src/pages/_authenticated.finance.tsx
Normal file
899
apps/app/src/pages/_authenticated.finance.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
apps/app/src/pages/_authenticated.index.tsx
Normal file
36
apps/app/src/pages/_authenticated.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
703
apps/app/src/pages/_authenticated.inventory.tsx
Normal file
703
apps/app/src/pages/_authenticated.inventory.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
apps/app/src/pages/_authenticated.kitchen.dietary.tsx
Normal file
163
apps/app/src/pages/_authenticated.kitchen.dietary.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
apps/app/src/pages/_authenticated.kitchen.index.tsx
Normal file
265
apps/app/src/pages/_authenticated.kitchen.index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/app/src/pages/_authenticated.kitchen.tsx
Normal file
5
apps/app/src/pages/_authenticated.kitchen.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/kitchen')({
|
||||||
|
component: Outlet,
|
||||||
|
})
|
||||||
274
apps/app/src/pages/_authenticated.my.profile.tsx
Normal file
274
apps/app/src/pages/_authenticated.my.profile.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
apps/app/src/pages/_authenticated.my.stays.tsx
Normal file
237
apps/app/src/pages/_authenticated.my.stays.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
apps/app/src/pages/_authenticated.my.tasks.tsx
Normal file
220
apps/app/src/pages/_authenticated.my.tasks.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
apps/app/src/pages/_authenticated.notifications.tsx
Normal file
91
apps/app/src/pages/_authenticated.notifications.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
apps/app/src/pages/_authenticated.profile.tsx
Normal file
224
apps/app/src/pages/_authenticated.profile.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
896
apps/app/src/pages/_authenticated.retreats.$retreatId.tsx
Normal file
896
apps/app/src/pages/_authenticated.retreats.$retreatId.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
314
apps/app/src/pages/_authenticated.retreats.index.tsx
Normal file
314
apps/app/src/pages/_authenticated.retreats.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/app/src/pages/_authenticated.retreats.tsx
Normal file
5
apps/app/src/pages/_authenticated.retreats.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/retreats')({
|
||||||
|
component: Outlet,
|
||||||
|
})
|
||||||
115
apps/app/src/pages/_authenticated.rooms.allocations.tsx
Normal file
115
apps/app/src/pages/_authenticated.rooms.allocations.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
314
apps/app/src/pages/_authenticated.rooms.index.tsx
Normal file
314
apps/app/src/pages/_authenticated.rooms.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/app/src/pages/_authenticated.rooms.tsx
Normal file
5
apps/app/src/pages/_authenticated.rooms.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/rooms')({
|
||||||
|
component: Outlet,
|
||||||
|
})
|
||||||
320
apps/app/src/pages/_authenticated.stays.tsx
Normal file
320
apps/app/src/pages/_authenticated.stays.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
577
apps/app/src/pages/_authenticated.tasks.index.tsx
Normal file
577
apps/app/src/pages/_authenticated.tasks.index.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
180
apps/app/src/pages/_authenticated.tasks.templates.tsx
Normal file
180
apps/app/src/pages/_authenticated.tasks.templates.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/app/src/pages/_authenticated.tasks.tsx
Normal file
5
apps/app/src/pages/_authenticated.tasks.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/tasks')({
|
||||||
|
component: Outlet,
|
||||||
|
})
|
||||||
331
apps/app/src/pages/_authenticated.tsx
Normal file
331
apps/app/src/pages/_authenticated.tsx
Normal 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',
|
||||||
|
}
|
||||||
184
apps/app/src/pages/_authenticated.users.$userId.tsx
Normal file
184
apps/app/src/pages/_authenticated.users.$userId.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
apps/app/src/pages/_authenticated.users.index.tsx
Normal file
263
apps/app/src/pages/_authenticated.users.index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/app/src/pages/_authenticated.users.tsx
Normal file
5
apps/app/src/pages/_authenticated.users.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/users')({
|
||||||
|
component: Outlet,
|
||||||
|
})
|
||||||
96
apps/app/src/pages/login.tsx
Normal file
96
apps/app/src/pages/login.tsx
Normal 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
16
apps/app/src/router.tsx
Normal 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
25
apps/app/tsconfig.json
Normal 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
44
apps/app/vite.config.ts
Normal 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
54
backend/bin/db-migrate.ts
Normal 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
73
backend/bin/start-e2e.ts
Normal 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
29
backend/bin/start.ts
Normal 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
33
backend/package.json
Normal 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
16
backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
167
db/annotations.ts
Normal file
167
db/annotations.ts
Normal 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
13
db/sqlite-seed.sql
Normal 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
558
db/sqlite/0001-schema.sql
Normal 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);
|
||||||
66
db/sqlite/0002-better-auth.sql
Normal file
66
db/sqlite/0002-better-auth.sql
Normal 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);
|
||||||
433
db/sqlite/0003-audit-triggers.sql
Normal file
433
db/sqlite/0003-audit-triggers.sql
Normal 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;
|
||||||
|
|
||||||
29
db/sqlite/0004-fabric-audit.sql
Normal file
29
db/sqlite/0004-fabric-audit.sql
Normal 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);
|
||||||
13
db/sqlite/0005-member-roles-and-admin.sql
Normal file
13
db/sqlite/0005-member-roles-and-admin.sql
Normal 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;
|
||||||
11
db/sqlite/0006-user-actor.sql
Normal file
11
db/sqlite/0006-user-actor.sql
Normal 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
13
db/sqlite/0007-fabric.sql
Normal 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;
|
||||||
852
docs/frontend-architecture.md
Normal file
852
docs/frontend-architecture.md
Normal 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
2
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.tmp/
|
||||||
|
reports/
|
||||||
23
e2e/package.json
Normal file
23
e2e/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
e2e/tests/cucumber-browser.mjs
Normal file
7
e2e/tests/cucumber-browser.mjs
Normal 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
6
e2e/tests/cucumber.mjs
Normal 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'],
|
||||||
|
}
|
||||||
14
e2e/tests/features/audit-log.feature
Normal file
14
e2e/tests/features/audit-log.feature
Normal 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
|
||||||
10
e2e/tests/features/auth.feature
Normal file
10
e2e/tests/features/auth.feature
Normal 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
|
||||||
122
e2e/tests/features/boats.feature
Normal file
122
e2e/tests/features/boats.feature
Normal 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
|
||||||
14
e2e/tests/features/browser/finance.feature
Normal file
14
e2e/tests/features/browser/finance.feature
Normal 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"
|
||||||
16
e2e/tests/features/browser/inventory.feature
Normal file
16
e2e/tests/features/browser/inventory.feature
Normal 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"
|
||||||
15
e2e/tests/features/browser/login.feature
Normal file
15
e2e/tests/features/browser/login.feature
Normal 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"
|
||||||
63
e2e/tests/features/browser/navigation.feature
Normal file
63
e2e/tests/features/browser/navigation.feature
Normal 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"
|
||||||
15
e2e/tests/features/browser/rooms.feature
Normal file
15
e2e/tests/features/browser/rooms.feature
Normal 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"
|
||||||
15
e2e/tests/features/browser/stays.feature
Normal file
15
e2e/tests/features/browser/stays.feature
Normal 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."
|
||||||
58
e2e/tests/features/finance.feature
Normal file
58
e2e/tests/features/finance.feature
Normal 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
|
||||||
6
e2e/tests/features/health-check.feature
Normal file
6
e2e/tests/features/health-check.feature
Normal 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
|
||||||
77
e2e/tests/features/inventory.feature
Normal file
77
e2e/tests/features/inventory.feature
Normal 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
|
||||||
29
e2e/tests/features/kitchen.feature
Normal file
29
e2e/tests/features/kitchen.feature
Normal 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
|
||||||
14
e2e/tests/features/notifications.feature
Normal file
14
e2e/tests/features/notifications.feature
Normal 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
|
||||||
68
e2e/tests/features/retreats.feature
Normal file
68
e2e/tests/features/retreats.feature
Normal 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
|
||||||
20
e2e/tests/features/roles.feature
Normal file
20
e2e/tests/features/roles.feature
Normal 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
|
||||||
105
e2e/tests/features/rooms.feature
Normal file
105
e2e/tests/features/rooms.feature
Normal 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
|
||||||
102
e2e/tests/features/stays.feature
Normal file
102
e2e/tests/features/stays.feature
Normal 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
Reference in New Issue
Block a user