chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:21:55 +02:00
commit 52cd52e41a
293 changed files with 28412 additions and 0 deletions

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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