chore: perauset customer project
This commit is contained in:
27
apps/app/messages/de.json
Normal file
27
apps/app/messages/de.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"brand": "PerAuset",
|
||||
"common_email": "E-Mail",
|
||||
"common_password": "Passwort",
|
||||
"login_subtitle": "Melden Sie sich bei Ihrem Konto an",
|
||||
"login_cta": "Anmelden",
|
||||
"login_invalid_credentials": "Ungültige E-Mail oder Passwort",
|
||||
"login_error": "Anmeldung fehlgeschlagen",
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_stays": "Meine Aufenthalte",
|
||||
"nav_tasks": "Meine Aufgaben",
|
||||
"nav_kitchen": "Küche",
|
||||
"nav_inventory": "Inventar",
|
||||
"nav_retreats": "Retreats",
|
||||
"nav_finance": "Finanzen",
|
||||
"nav_rooms": "Zimmer",
|
||||
"nav_boats": "Boote",
|
||||
"nav_users": "Benutzer",
|
||||
"nav_audit_log": "Audit-Protokoll",
|
||||
"nav_sign_out": "Abmelden",
|
||||
"dashboard_title": "Übersicht",
|
||||
"dashboard_stays": "Aufenthalte",
|
||||
"dashboard_tasks": "Aufgaben",
|
||||
"dashboard_boats": "Boote",
|
||||
"dashboard_retreats": "Retreats"
|
||||
}
|
||||
612
apps/app/messages/en.json
Normal file
612
apps/app/messages/en.json
Normal file
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"ai_chat_greeting": "How can I help you today?",
|
||||
"ai_chat_input_placeholder": "Ask me anything about the village...",
|
||||
"ai_chat_subtitle": "Ask me anything about the village -- stays, tasks, boats, retreats, and more.",
|
||||
"ai_chat_title": "PerAuset Assistant",
|
||||
"audit_action": "Action",
|
||||
"audit_changed_fields": "Changed Fields",
|
||||
"audit_description": "View a record of all changes made in the system.",
|
||||
"audit_record_id": "Record ID",
|
||||
"audit_table": "Table",
|
||||
"audit_timestamp": "Timestamp",
|
||||
"audit_title": "Audit Log",
|
||||
"audit_user": "User",
|
||||
"boats_accessibility_needs": "Any accessibility or special needs...",
|
||||
"boats_actions": "Actions",
|
||||
"boats_active": "Active",
|
||||
"boats_add_boat": "Add Boat",
|
||||
"boats_add_route": "Add Route",
|
||||
"boats_additional_information": "Any additional information...",
|
||||
"boats_arrival": "Arrival",
|
||||
"boats_assigned": "Assigned",
|
||||
"boats_boat": "Boat",
|
||||
"boats_boat_name": "Boat Name",
|
||||
"boats_boat_name_placeholder": "e.g. Nile Star",
|
||||
"boats_boat_notes_placeholder": "Additional details about the boat...",
|
||||
"boats_boats": "Boats",
|
||||
"boats_cancel_trip": "Cancel Trip",
|
||||
"boats_cargo": "Cargo",
|
||||
"boats_cargo_capacity": "Cargo Capacity (kg)",
|
||||
"boats_cargo_capacity_placeholder": "e.g. 500",
|
||||
"boats_cargo_kg": "Cargo (kg)",
|
||||
"boats_cargo_notes": "Cargo Notes",
|
||||
"boats_complete_trip": "Complete Trip",
|
||||
"boats_confirm": "Confirm",
|
||||
"boats_create_trip": "Create Trip",
|
||||
"boats_decline": "Decline",
|
||||
"boats_departure": "Departure",
|
||||
"boats_describe_cargo": "Describe any cargo you need to bring...",
|
||||
"boats_destination": "Destination",
|
||||
"boats_destination_placeholder": "e.g. Aswan",
|
||||
"boats_edit_boat": "Edit Boat",
|
||||
"boats_edit_route": "Edit Route",
|
||||
"boats_fleet_description": "Manage boats and routes.",
|
||||
"boats_fleet_management": "Fleet Management",
|
||||
"boats_inactive": "Inactive",
|
||||
"boats_manifest": "Manifest",
|
||||
"boats_name": "Name",
|
||||
"boats_no_boats": "No boats found. Add one to get started.",
|
||||
"boats_no_boats_available": "No boats available",
|
||||
"boats_no_confirmed_passengers": "No confirmed passengers yet.",
|
||||
"boats_no_matching_trips": "No matching trips",
|
||||
"boats_no_open_trips": "No open trips available",
|
||||
"boats_no_routes": "No routes found. Add one to get started.",
|
||||
"boats_no_routes_available": "No routes available",
|
||||
"boats_no_trips": "No boat trips found.",
|
||||
"boats_notes": "Notes",
|
||||
"boats_number_of_seats": "Number of Seats",
|
||||
"boats_open_for_requests": "Open for Requests",
|
||||
"boats_origin": "Origin",
|
||||
"boats_origin_placeholder": "e.g. Luxor",
|
||||
"boats_passenger_capacity": "Passenger Capacity",
|
||||
"boats_passengers": "Passengers",
|
||||
"boats_print_manifest": "Print Manifest",
|
||||
"boats_request_seat": "Request Seat",
|
||||
"boats_request_seat_description": "Request a seat on an upcoming boat trip.",
|
||||
"boats_request_seat_title": "Request Boat Seat",
|
||||
"boats_requested": "Requested",
|
||||
"boats_route": "Route",
|
||||
"boats_route_name": "Route Name",
|
||||
"boats_route_name_placeholder": "e.g. Luxor Express",
|
||||
"boats_routes": "Routes",
|
||||
"boats_save_boat": "Save Boat",
|
||||
"boats_save_route": "Save Route",
|
||||
"boats_scheduled_arrival": "Scheduled Arrival",
|
||||
"boats_scheduled_departure": "Scheduled Departure",
|
||||
"boats_seat_requests": "Seat Requests",
|
||||
"boats_seats": "Seats",
|
||||
"boats_select_boat_optional": "Select a boat (optional)",
|
||||
"boats_select_route": "Select a route",
|
||||
"boats_select_trip": "Select a trip",
|
||||
"boats_special_requirements": "Special Requirements",
|
||||
"boats_status": "Status",
|
||||
"boats_trip": "Trip",
|
||||
"boats_trip_details": "Trip Details",
|
||||
"boats_trip_not_found": "Trip Not Found",
|
||||
"boats_trip_not_found_message": "This boat trip could not be found.",
|
||||
"boats_trips_description": "View upcoming and past boat trips.",
|
||||
"boats_trips_title": "Boat Trips",
|
||||
"boats_unknown_boat": "Unknown Boat",
|
||||
"boats_user": "User",
|
||||
"brand": "PerAuset",
|
||||
"common_cancel": "Cancel",
|
||||
"common_close": "Close",
|
||||
"common_email": "Email",
|
||||
"common_error": "Error",
|
||||
"common_password": "Password",
|
||||
"common_save_changes": "Save Changes",
|
||||
"common_success": "Success",
|
||||
"common_validation": "Validation",
|
||||
"dashboard_boats": "Boats",
|
||||
"dashboard_retreats": "Retreats",
|
||||
"dashboard_stays": "Stays",
|
||||
"dashboard_tasks": "Tasks",
|
||||
"dashboard_title": "Dashboard",
|
||||
"finance_actions": "Actions",
|
||||
"finance_add_link": "Add New Link",
|
||||
"finance_add_record": "Add Record",
|
||||
"finance_amount": "Amount",
|
||||
"finance_base": "Base",
|
||||
"finance_by_category": "By Category",
|
||||
"finance_category": "Category",
|
||||
"finance_create_record": "Create Record",
|
||||
"finance_currency": "Currency",
|
||||
"finance_date": "Date",
|
||||
"finance_description": "Description",
|
||||
"finance_edit_record": "Edit Finance Record",
|
||||
"finance_empty": "No finance records found.",
|
||||
"finance_entity_type": "Entity Type",
|
||||
"finance_exchange_rates": "Exchange Rates",
|
||||
"finance_existing_links": "Existing Links",
|
||||
"finance_last_updated": "Last updated",
|
||||
"finance_link_entity": "Link Entity",
|
||||
"finance_name": "Name",
|
||||
"finance_net": "Net",
|
||||
"finance_no_links": "No linked entities yet.",
|
||||
"finance_notes": "Notes",
|
||||
"finance_reference": "Reference",
|
||||
"finance_reference_id": "Reference ID",
|
||||
"finance_refresh_rates": "Refresh Rates",
|
||||
"finance_save_changes": "Save Changes",
|
||||
"finance_subtitle": "Track expenses, income, and reimbursements.",
|
||||
"finance_title": "Finance",
|
||||
"finance_total_expenses": "Total Expenses",
|
||||
"finance_total_income": "Total Income",
|
||||
"finance_type": "Type",
|
||||
"inventory_add_item": "Add Item",
|
||||
"inventory_add_item_title": "Add Inventory Item",
|
||||
"inventory_alert_threshold": "Alert threshold",
|
||||
"inventory_cancel": "Cancel",
|
||||
"inventory_cat_equipment": "Equipment",
|
||||
"inventory_cat_garden": "Garden",
|
||||
"inventory_cat_kitchen": "Kitchen",
|
||||
"inventory_cat_other": "Other",
|
||||
"inventory_cat_rooms": "Rooms",
|
||||
"inventory_category": "Category",
|
||||
"inventory_col_actions": "Actions",
|
||||
"inventory_col_change": "Change",
|
||||
"inventory_col_date": "Date",
|
||||
"inventory_col_item": "Item",
|
||||
"inventory_col_notes": "Notes",
|
||||
"inventory_col_quantity": "Quantity",
|
||||
"inventory_col_reason": "Reason",
|
||||
"inventory_col_status": "Status",
|
||||
"inventory_col_unit": "Unit",
|
||||
"inventory_create_item": "Create Item",
|
||||
"inventory_current_quantity": "Current Quantity",
|
||||
"inventory_description": "Track supplies and request items.",
|
||||
"inventory_edit_item": "Edit Item",
|
||||
"inventory_edit_item_title": "Edit Inventory Item",
|
||||
"inventory_error_create": "Failed to create item.",
|
||||
"inventory_error_request": "Failed to submit request.",
|
||||
"inventory_error_stocktake": "Failed to submit stocktake.",
|
||||
"inventory_error_title": "Error",
|
||||
"inventory_error_update": "Failed to update item.",
|
||||
"inventory_field_name": "Item Name",
|
||||
"inventory_field_unit": "Unit",
|
||||
"inventory_history_title": "History: {name}",
|
||||
"inventory_in_stock": "In Stock",
|
||||
"inventory_item_created_message": "Inventory item has been added.",
|
||||
"inventory_item_created_title": "Item created",
|
||||
"inventory_item_updated_message": "Inventory item has been updated.",
|
||||
"inventory_item_updated_title": "Item updated",
|
||||
"inventory_low_stock": "Low Stock",
|
||||
"inventory_minimum_quantity": "Minimum Quantity",
|
||||
"inventory_missing_fields_message": "Name and unit are required.",
|
||||
"inventory_missing_fields_title": "Missing fields",
|
||||
"inventory_name_placeholder": "e.g. Rice, Soap, Candles",
|
||||
"inventory_no_history": "No transaction history for this item.",
|
||||
"inventory_no_items": "No inventory items found.",
|
||||
"inventory_request_item": "Request Item",
|
||||
"inventory_request_item_title": "Request Inventory Item",
|
||||
"inventory_request_name_placeholder": "e.g., Olive oil, Cleaning supplies",
|
||||
"inventory_request_purpose": "Purpose",
|
||||
"inventory_request_purpose_placeholder": "What is this for?",
|
||||
"inventory_request_quantity": "Quantity",
|
||||
"inventory_request_quantity_placeholder": "Amount needed",
|
||||
"inventory_request_submitted_message": "Your inventory request has been submitted.",
|
||||
"inventory_request_submitted_title": "Request submitted",
|
||||
"inventory_request_unit_placeholder": "e.g., liters, kg, boxes",
|
||||
"inventory_save_changes": "Save Changes",
|
||||
"inventory_stocktake": "Stocktake",
|
||||
"inventory_stocktake_current": "Current: {quantity} {unit}",
|
||||
"inventory_stocktake_intro": "Enter the actual counted quantity for each item. Only changed items will be updated.",
|
||||
"inventory_stocktake_submitted_message": "{count} item(s) adjusted.",
|
||||
"inventory_stocktake_submitted_title": "Stocktake submitted",
|
||||
"inventory_stocktake_title": "Stocktake",
|
||||
"inventory_submit_request": "Submit Request",
|
||||
"inventory_submit_stocktake": "Submit Stocktake",
|
||||
"inventory_tab_all": "All ({count})",
|
||||
"inventory_tab_equipment": "Equipment ({count})",
|
||||
"inventory_tab_garden": "Garden ({count})",
|
||||
"inventory_tab_kitchen": "Kitchen ({count})",
|
||||
"inventory_tab_other": "Other ({count})",
|
||||
"inventory_tab_rooms": "Rooms ({count})",
|
||||
"inventory_title": "Inventory",
|
||||
"inventory_transaction_history": "Transaction History",
|
||||
"inventory_unit_placeholder": "e.g. kg, liters, pieces",
|
||||
"kitchen_cancel": "Cancel",
|
||||
"kitchen_create_meal": "Create Meal",
|
||||
"kitchen_create_meal_service": "Create Meal Service",
|
||||
"kitchen_description": "Meal services and dietary management.",
|
||||
"kitchen_dietary_allergy_notes": "Allergy Notes",
|
||||
"kitchen_dietary_allergy_notes_placeholder": "Describe any allergies in detail...",
|
||||
"kitchen_dietary_cancel": "Cancel",
|
||||
"kitchen_dietary_dairy_free": "Dairy-free",
|
||||
"kitchen_dietary_description": "Update your dietary preferences and allergies so the kitchen can accommodate your needs.",
|
||||
"kitchen_dietary_dislikes": "Dislikes",
|
||||
"kitchen_dietary_dislikes_placeholder": "Foods you prefer to avoid...",
|
||||
"kitchen_dietary_error": "Failed to save dietary profile",
|
||||
"kitchen_dietary_gluten_free": "Gluten-free",
|
||||
"kitchen_dietary_nut_allergy": "Nut allergy",
|
||||
"kitchen_dietary_other_requirements": "Other Requirements",
|
||||
"kitchen_dietary_other_requirements_placeholder": "Any other dietary requirements...",
|
||||
"kitchen_dietary_preferences": "Dietary Preferences",
|
||||
"kitchen_dietary_save": "Save Profile",
|
||||
"kitchen_dietary_success": "Dietary profile saved successfully.",
|
||||
"kitchen_dietary_title": "Dietary Profile",
|
||||
"kitchen_dietary_vegan": "Vegan",
|
||||
"kitchen_dietary_vegetarian": "Vegetarian",
|
||||
"kitchen_error_create_meal": "Failed to create meal service.",
|
||||
"kitchen_error_title": "Error",
|
||||
"kitchen_expected_headcount": "Expected headcount: {count}",
|
||||
"kitchen_meal_created_message": "The meal service has been created.",
|
||||
"kitchen_meal_created_title": "Meal created",
|
||||
"kitchen_meal_type_breakfast": "Breakfast",
|
||||
"kitchen_meal_type_dinner": "Dinner",
|
||||
"kitchen_meal_type_lunch": "Lunch",
|
||||
"kitchen_meal_type_snack": "Snack",
|
||||
"kitchen_menu_notes": "Menu Notes",
|
||||
"kitchen_menu_notes_placeholder": "Menu description or notes",
|
||||
"kitchen_no_meals": "No meal services scheduled.",
|
||||
"kitchen_planned_headcount": "Planned Headcount",
|
||||
"kitchen_planned_headcount_placeholder": "Expected number of diners",
|
||||
"kitchen_select_meal_type": "Select meal type",
|
||||
"kitchen_service_date": "Service Date",
|
||||
"kitchen_service_type": "Service Type",
|
||||
"kitchen_status_cancelled": "Cancelled",
|
||||
"kitchen_status_planned": "Planned",
|
||||
"kitchen_status_preparing": "Preparing",
|
||||
"kitchen_status_served": "Served",
|
||||
"kitchen_title": "Kitchen",
|
||||
"login_cta": "Sign in",
|
||||
"login_error": "Sign-in failed",
|
||||
"login_invalid_credentials": "Invalid email or password",
|
||||
"login_subtitle": "Sign in to your account",
|
||||
"my_active_stays": "Active Stays",
|
||||
"my_dietary_profile": "Dietary Profile",
|
||||
"my_dietary_profile_description": "Manage your dietary preferences, allergies, and meal requirements.",
|
||||
"my_display_name": "Display Name",
|
||||
"my_display_name_placeholder": "How should we call you?",
|
||||
"my_edit_profile": "Edit Profile",
|
||||
"my_field_email": "Email",
|
||||
"my_field_name": "Name",
|
||||
"my_field_roles": "Roles",
|
||||
"my_field_status": "Status",
|
||||
"my_first_name": "First Name",
|
||||
"my_first_name_placeholder": "First name",
|
||||
"my_last_name": "Last Name",
|
||||
"my_last_name_placeholder": "Last name",
|
||||
"my_mobile_number": "Mobile Number",
|
||||
"my_no_active_stays": "You don't have any active stays at the moment.",
|
||||
"my_no_active_tasks": "No active tasks right now. Enjoy the calm!",
|
||||
"my_no_requests": "You haven't submitted any stay requests yet.",
|
||||
"my_phone": "Phone",
|
||||
"my_phone_placeholder": "+1 (555) 000-0000",
|
||||
"my_profile_description": "View and update your personal information.",
|
||||
"my_profile_title": "My Profile",
|
||||
"my_requests": "My Requests",
|
||||
"my_save_profile": "Save Profile",
|
||||
"my_sign_in_to_view": "Please sign in to view your profile.",
|
||||
"my_stays_description": "Your stay requests and active stays.",
|
||||
"my_stays_title": "My Stays",
|
||||
"my_tasks_assigned": "Assigned to Me",
|
||||
"my_tasks_blocked": "Blocked",
|
||||
"my_tasks_description": "Tasks assigned to you or available to claim.",
|
||||
"my_tasks_in_progress": "In Progress",
|
||||
"my_tasks_open": "Open (Claimable)",
|
||||
"my_tasks_title": "My Tasks",
|
||||
"my_update_failed": "Failed to update profile",
|
||||
"my_update_success": "Profile updated successfully.",
|
||||
"my_whatsapp_number": "WhatsApp Number",
|
||||
"nav_audit_log": "Audit Log",
|
||||
"nav_boat_management": "Boat Management",
|
||||
"nav_boats": "Boats",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_finance": "Finance",
|
||||
"nav_inventory": "Inventory",
|
||||
"nav_kitchen": "Kitchen",
|
||||
"nav_management": "Management",
|
||||
"nav_more": "More",
|
||||
"nav_my_profile": "My Profile",
|
||||
"nav_my_stays": "My Stays",
|
||||
"nav_my_tasks": "My Tasks",
|
||||
"nav_operations": "Operations",
|
||||
"nav_personal": "Personal",
|
||||
"nav_profile": "Profile",
|
||||
"nav_retreats": "Retreats",
|
||||
"nav_rooms": "Rooms",
|
||||
"nav_sign_out": "Sign out",
|
||||
"nav_stays": "My Stays",
|
||||
"nav_tasks": "My Tasks",
|
||||
"nav_users": "Users",
|
||||
"notifications_all_caught_up": "You are all caught up.",
|
||||
"notifications_empty": "No notifications",
|
||||
"notifications_title": "Notifications",
|
||||
"notifications_unread_count": "{count} unread",
|
||||
"notifications_view_all": "View all notifications",
|
||||
"profile_description": "View and update your personal information.",
|
||||
"profile_dietary_profile": "Dietary Profile",
|
||||
"profile_dietary_profile_description": "Manage your dietary preferences and allergies.",
|
||||
"profile_display_name": "Display Name",
|
||||
"profile_display_name_placeholder": "How should we call you?",
|
||||
"profile_edit_dietary_profile": "Edit Dietary Profile",
|
||||
"profile_edit_profile": "Edit Profile",
|
||||
"profile_field_email": "Email",
|
||||
"profile_field_roles": "Roles",
|
||||
"profile_field_status": "Status",
|
||||
"profile_first_name": "First Name",
|
||||
"profile_first_name_placeholder": "First name",
|
||||
"profile_last_name": "Last Name",
|
||||
"profile_last_name_placeholder": "Last name",
|
||||
"profile_phone": "Phone",
|
||||
"profile_phone_placeholder": "+1 (555) 000-0000",
|
||||
"profile_save_profile": "Save Profile",
|
||||
"profile_sign_in_to_view": "Please sign in to view your profile.",
|
||||
"profile_title": "My Profile",
|
||||
"profile_update_failed": "Failed to update profile",
|
||||
"profile_update_success": "Profile updated successfully.",
|
||||
"retreats_add_meal": "Add Meal",
|
||||
"retreats_add_meal_service": "Add Meal Service",
|
||||
"retreats_add_person": "Add Person",
|
||||
"retreats_add_person_title": "Add Person to Retreat",
|
||||
"retreats_add_schedule_item": "Add Schedule Item",
|
||||
"retreats_back": "Back to Retreats",
|
||||
"retreats_capacity": "Capacity",
|
||||
"retreats_create": "Create Retreat",
|
||||
"retreats_description": "Description",
|
||||
"retreats_empty": "No retreats found. Create one to get started.",
|
||||
"retreats_end_date": "End Date",
|
||||
"retreats_ends_at": "Ends At",
|
||||
"retreats_headcount": "Headcount",
|
||||
"retreats_lead_facilitator": "Lead Facilitator",
|
||||
"retreats_location": "Location",
|
||||
"retreats_menu_notes": "Menu Notes",
|
||||
"retreats_name": "Retreat Name",
|
||||
"retreats_no_meals": "No meal services planned for this retreat yet.",
|
||||
"retreats_no_people": "No people added to this retreat yet.",
|
||||
"retreats_no_schedule": "No schedule items yet.",
|
||||
"retreats_not_found_body": "The retreat you are looking for does not exist or you do not have access.",
|
||||
"retreats_not_found_title": "Retreat Not Found",
|
||||
"retreats_notify_body": "Body",
|
||||
"retreats_notify_participants": "Notify Participants",
|
||||
"retreats_notify_subject": "Title",
|
||||
"retreats_notify_title": "Notify Retreat Participants",
|
||||
"retreats_person_name": "Name",
|
||||
"retreats_planned_headcount": "Planned Headcount",
|
||||
"retreats_role": "Role",
|
||||
"retreats_schedule_title": "Title",
|
||||
"retreats_send_notification": "Send Notification",
|
||||
"retreats_service_date": "Service Date",
|
||||
"retreats_service_type": "Service Type",
|
||||
"retreats_slug": "Slug",
|
||||
"retreats_start_date": "Start Date",
|
||||
"retreats_starts_at": "Starts At",
|
||||
"retreats_status": "Status",
|
||||
"retreats_subtitle": "Manage and view scheduled retreats.",
|
||||
"retreats_tab_meals": "Meals",
|
||||
"retreats_tab_overview": "Overview",
|
||||
"retreats_tab_people": "People",
|
||||
"retreats_tab_schedule": "Schedule",
|
||||
"retreats_title": "Retreats",
|
||||
"rooms_active": "Active",
|
||||
"rooms_allocations_all_description": "All room allocations across the village.",
|
||||
"rooms_allocations_room_description": "Allocations for this room.",
|
||||
"rooms_allocations_title": "Room Allocations",
|
||||
"rooms_availability": "Availability",
|
||||
"rooms_availability_end": "Available To",
|
||||
"rooms_availability_start": "Available From",
|
||||
"rooms_available": "Available",
|
||||
"rooms_capacity": "Capacity",
|
||||
"rooms_create_room": "Create Room",
|
||||
"rooms_dates": "Dates",
|
||||
"rooms_description": "View and manage room inventory.",
|
||||
"rooms_edit_room": "Edit Room",
|
||||
"rooms_full": "Full",
|
||||
"rooms_inactive": "Inactive",
|
||||
"rooms_no_rooms": "No rooms found.",
|
||||
"rooms_notes": "Notes",
|
||||
"rooms_notes_placeholder": "Additional details...",
|
||||
"rooms_price_night": "Price / Night",
|
||||
"rooms_price_per_night": "Price per Night (EUR)",
|
||||
"rooms_price_placeholder": "e.g. 25.00",
|
||||
"rooms_reason": "Reason",
|
||||
"rooms_room": "Room",
|
||||
"rooms_room_code": "Room Code",
|
||||
"rooms_room_code_placeholder": "e.g. R101",
|
||||
"rooms_room_name": "Room Name",
|
||||
"rooms_room_name_placeholder": "e.g. Sunrise Room",
|
||||
"rooms_room_type": "Room Type",
|
||||
"rooms_save_room": "Save Room",
|
||||
"rooms_select_type": "Select type",
|
||||
"rooms_showing_availability": "Showing availability",
|
||||
"rooms_status": "Status",
|
||||
"rooms_title": "Rooms",
|
||||
"rooms_type": "Type",
|
||||
"rooms_view_allocations": "View Allocations",
|
||||
"stays_actions": "Actions",
|
||||
"stays_active_stays": "Active Stays",
|
||||
"stays_approve": "Approve",
|
||||
"stays_approve_failed": "Failed to approve request.",
|
||||
"stays_cancel_failed": "Failed to cancel stay.",
|
||||
"stays_cancel_stay": "Cancel Stay",
|
||||
"stays_check_in": "Check In",
|
||||
"stays_check_in_date": "Check-in Date",
|
||||
"stays_check_in_failed": "Failed to check in.",
|
||||
"stays_check_out": "Check Out",
|
||||
"stays_check_out_failed": "Failed to check out.",
|
||||
"stays_checked_in": "Checked In",
|
||||
"stays_checked_in_message": "Guest has been checked in.",
|
||||
"stays_checked_out": "Checked out",
|
||||
"stays_checked_out_message": "Guest has been checked out.",
|
||||
"stays_create_failed": "Something went wrong creating the stay.",
|
||||
"stays_create_stay": "Create Stay",
|
||||
"stays_create_stay_hint": "This will create a stay request, auto-approve it, and generate the stay record in one step. The stay will be ready for check-in immediately.",
|
||||
"stays_dates": "Dates",
|
||||
"stays_description": "Manage stays, requests, and check-ins.",
|
||||
"stays_end_date": "End Date",
|
||||
"stays_expected_checkout": "Expected Checkout",
|
||||
"stays_guest": "Guest",
|
||||
"stays_missing_fields": "Missing fields",
|
||||
"stays_missing_fields_message": "Please fill in all required fields.",
|
||||
"stays_no_active_stays": "No active stays found.",
|
||||
"stays_no_checked_in": "No guests currently checked in.",
|
||||
"stays_no_requests": "No stay requests found.",
|
||||
"stays_notes": "Notes",
|
||||
"stays_notes_placeholder": "Any additional information...",
|
||||
"stays_reject": "Reject",
|
||||
"stays_reject_failed": "Failed to reject request.",
|
||||
"stays_request_approved": "Request approved",
|
||||
"stays_request_approved_message": "The stay request has been approved.",
|
||||
"stays_request_rejected": "Request rejected",
|
||||
"stays_request_rejected_message": "The stay request has been rejected.",
|
||||
"stays_request_stay": "Request a Stay",
|
||||
"stays_request_submitted": "Request submitted",
|
||||
"stays_request_submitted_message": "Your stay request has been submitted for review.",
|
||||
"stays_request_type": "Request Type",
|
||||
"stays_requested_dates": "Requested Dates",
|
||||
"stays_requests": "Requests",
|
||||
"stays_select_type": "Select type",
|
||||
"stays_something_went_wrong": "Something went wrong.",
|
||||
"stays_start_date": "Start Date",
|
||||
"stays_status": "Status",
|
||||
"stays_stay_cancelled": "Stay cancelled",
|
||||
"stays_stay_cancelled_message": "The stay has been cancelled.",
|
||||
"stays_stay_created": "Stay created",
|
||||
"stays_stay_created_message": "The stay has been created and is ready for check-in.",
|
||||
"stays_stay_type": "Stay Type",
|
||||
"stays_submit_request": "Submit Request",
|
||||
"stays_title": "Stays",
|
||||
"stays_type": "Type",
|
||||
"stays_type_day_visit": "Day Visit",
|
||||
"stays_type_facilitator": "Facilitator",
|
||||
"stays_type_guest": "Guest",
|
||||
"stays_type_staff": "Staff",
|
||||
"stays_type_volunteer": "Volunteer",
|
||||
"stays_user": "User",
|
||||
"tasks_assign_role": "Assign Role",
|
||||
"tasks_assign_to_role": "Assign Task to Role",
|
||||
"tasks_assign_to_role_optional": "Assign to Role (optional)",
|
||||
"tasks_block": "Block",
|
||||
"tasks_block_title": "Mark Task Blocked",
|
||||
"tasks_blocked": "Task marked as blocked",
|
||||
"tasks_cancel": "Cancel",
|
||||
"tasks_category": "Category",
|
||||
"tasks_category_housekeeping": "Housekeeping",
|
||||
"tasks_category_kitchen": "Kitchen",
|
||||
"tasks_category_maintenance": "Maintenance",
|
||||
"tasks_category_operations": "Operations",
|
||||
"tasks_category_retreat": "Retreat",
|
||||
"tasks_category_transport": "Transport",
|
||||
"tasks_claim": "Claim",
|
||||
"tasks_claimed": "Task claimed",
|
||||
"tasks_complete": "Complete",
|
||||
"tasks_complete_title": "Complete Task",
|
||||
"tasks_completed": "Task completed",
|
||||
"tasks_create_new_task": "Create New Task",
|
||||
"tasks_create_task": "Create Task",
|
||||
"tasks_created_message": "\"{title}\" has been created successfully.",
|
||||
"tasks_created_title": "Task created",
|
||||
"tasks_description": "View and manage village tasks across all categories.",
|
||||
"tasks_description_placeholder": "Optional details about the task",
|
||||
"tasks_error_assign_role": "Failed to assign role.",
|
||||
"tasks_error_generate": "Failed to generate recurring tasks.",
|
||||
"tasks_error_generic": "Something went wrong",
|
||||
"tasks_error_title": "Error",
|
||||
"tasks_field_description": "Description",
|
||||
"tasks_field_title": "Title",
|
||||
"tasks_generate_recurring": "Generate Recurring Tasks",
|
||||
"tasks_generated_message": "Generated {count} recurring task(s).",
|
||||
"tasks_generated_title": "Tasks generated",
|
||||
"tasks_location": "Location",
|
||||
"tasks_location_placeholder": "e.g. Main building, Kitchen",
|
||||
"tasks_mark_blocked": "Mark Blocked",
|
||||
"tasks_new_task": "New Task",
|
||||
"tasks_no_status_tasks": "No {status} tasks",
|
||||
"tasks_no_tasks": "No tasks",
|
||||
"tasks_notes_optional": "Notes (optional)",
|
||||
"tasks_notes_placeholder": "Any completion notes?",
|
||||
"tasks_reason": "Reason",
|
||||
"tasks_reason_placeholder": "Why is this task blocked?",
|
||||
"tasks_role": "Role",
|
||||
"tasks_role_admin": "Admin",
|
||||
"tasks_role_assigned_message": "Task has been assigned to the selected role.",
|
||||
"tasks_role_assigned_title": "Role assigned",
|
||||
"tasks_role_boat_coordinator": "Boat Coordinator",
|
||||
"tasks_role_community_member": "Community Member",
|
||||
"tasks_role_coordinator": "Coordinator",
|
||||
"tasks_role_facilitator": "Facilitator",
|
||||
"tasks_role_kitchen_lead": "Kitchen Lead",
|
||||
"tasks_role_staff": "Staff",
|
||||
"tasks_role_volunteer": "Volunteer",
|
||||
"tasks_scheduled_end": "Scheduled End",
|
||||
"tasks_scheduled_start": "Scheduled Start",
|
||||
"tasks_select_category": "Select category",
|
||||
"tasks_select_role": "Select a role",
|
||||
"tasks_start": "Start",
|
||||
"tasks_started": "Task started",
|
||||
"tasks_status_assigned": "Assigned",
|
||||
"tasks_status_blocked": "Blocked",
|
||||
"tasks_status_cancelled": "Cancelled",
|
||||
"tasks_status_completed": "Completed",
|
||||
"tasks_status_in_progress": "In Progress",
|
||||
"tasks_status_open": "Open",
|
||||
"tasks_status_planned": "Planned",
|
||||
"tasks_templates": "Templates",
|
||||
"tasks_templates_back": "Back to Tasks",
|
||||
"tasks_templates_claimable": "Claimable",
|
||||
"tasks_templates_col_category": "Category",
|
||||
"tasks_templates_col_claimable": "Claimable",
|
||||
"tasks_templates_col_duration": "Est. Duration",
|
||||
"tasks_templates_col_recurrence": "Recurrence",
|
||||
"tasks_templates_col_title": "Title",
|
||||
"tasks_templates_description": "Reusable task definitions for recurring village operations.",
|
||||
"tasks_templates_empty": "No templates yet. Create one above to get started.",
|
||||
"tasks_templates_error": "Failed to load templates",
|
||||
"tasks_templates_minutes": "{minutes} min",
|
||||
"tasks_templates_no": "No",
|
||||
"tasks_templates_not_claimable": "Not claimable",
|
||||
"tasks_templates_one_time": "One-time",
|
||||
"tasks_templates_recurring": "Recurring",
|
||||
"tasks_templates_title": "Task Templates",
|
||||
"tasks_templates_yes": "Yes",
|
||||
"tasks_title": "Tasks",
|
||||
"tasks_title_placeholder": "e.g. Clean guest rooms",
|
||||
"tasks_validation_message": "Title and category are required.",
|
||||
"tasks_validation_title": "Validation",
|
||||
"templates_create": "Create Template",
|
||||
"templates_created": "Template created",
|
||||
"templates_field_category": "Category",
|
||||
"templates_field_category_placeholder": "Select category",
|
||||
"templates_field_claimable": "Claimable by volunteers",
|
||||
"templates_field_default_location": "Default Location",
|
||||
"templates_field_default_location_placeholder": "e.g. Main kitchen",
|
||||
"templates_field_description": "Description",
|
||||
"templates_field_description_placeholder": "What does this task involve?",
|
||||
"templates_field_estimated_minutes": "Estimated Duration (minutes)",
|
||||
"templates_field_estimated_minutes_placeholder": "e.g. 30",
|
||||
"templates_field_recurrence": "Recurrence (cron)",
|
||||
"templates_field_recurrence_placeholder": "e.g. 0 8 * * 1,3,5",
|
||||
"templates_field_title": "Title",
|
||||
"templates_field_title_placeholder": "e.g. Daily kitchen clean-up",
|
||||
"templates_hide_form": "Hide Form",
|
||||
"templates_new": "New Template",
|
||||
"templates_recurrence_hint": "Leave blank for one-time tasks",
|
||||
"templates_title_category_required": "Title and category are required.",
|
||||
"user_select_label": "User",
|
||||
"users_back": "Back to Users",
|
||||
"users_display_name": "Display Name",
|
||||
"users_edit_roles": "Edit Roles",
|
||||
"users_first_name": "First Name",
|
||||
"users_invite": "Invite User",
|
||||
"users_last_name": "Last Name",
|
||||
"users_name": "Name",
|
||||
"users_no_roles": "No roles",
|
||||
"users_not_found_body": "The user you are looking for does not exist or you do not have access.",
|
||||
"users_not_found_title": "User Not Found",
|
||||
"users_registered_count": "{count} users registered.",
|
||||
"users_role_management": "Role Management",
|
||||
"users_roles": "Roles",
|
||||
"users_roles_updated": "Roles updated successfully.",
|
||||
"users_save_roles": "Save Roles",
|
||||
"users_send_invitation": "Send Invitation",
|
||||
"users_status": "Status",
|
||||
"users_title": "Users & Roles",
|
||||
"profile_name": "Name",
|
||||
"profile_name_placeholder": "Full name",
|
||||
"profile_mobile_number": "Mobile number",
|
||||
"profile_mobile_number_placeholder": "Mobile number",
|
||||
"profile_whatsapp_number": "WhatsApp number",
|
||||
"profile_whatsapp_number_placeholder": "WhatsApp number"
|
||||
}
|
||||
40
apps/app/package.json
Normal file
40
apps/app/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@perauset/app",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 6001",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 6001",
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.2.1",
|
||||
"@mantine/hooks": "^9.2.1",
|
||||
"@mantine/notifications": "^9.2.1",
|
||||
"@perauset/components": "workspace:*",
|
||||
"@perauset/functions": "workspace:*",
|
||||
"@perauset/functions-sdk": "workspace:*",
|
||||
"@perauset/mantine-theme": "workspace:*",
|
||||
"@pikku/fetch": "^0.12.6",
|
||||
"@pikku/mantine": "^0.12.6",
|
||||
"@pikku/react": "^0.12.5",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-router": "^1.100.0",
|
||||
"@tanstack/react-start": "^1.100.0",
|
||||
"better-auth": "^1.6.19",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.20.0",
|
||||
"@tanstack/router-generator": "^1.100.0",
|
||||
"@tanstack/router-plugin": "^1.100.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4",
|
||||
"typescript": "^5.9"
|
||||
}
|
||||
}
|
||||
11
apps/app/project.inlang/settings.json
Normal file
11
apps/app/project.inlang/settings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "en",
|
||||
"locales": ["en", "de"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
305
apps/app/src/components/ai-chat.tsx
Normal file
305
apps/app/src/components/ai-chat.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Drawer,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
Paper,
|
||||
Avatar,
|
||||
Loader,
|
||||
Box,
|
||||
} from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { Send, Bot } from 'lucide-react'
|
||||
import { useSessionUser } from '@/lib/auth-context'
|
||||
import { usePikkuRPC } from '@pikku/react'
|
||||
import type { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface AIChatDrawerProps {
|
||||
opened: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AIChatDrawer({ opened, onClose }: AIChatDrawerProps) {
|
||||
const rpc = usePikkuRPC<PikkuRPC>()
|
||||
const userName = useSessionUser()?.name ?? ''
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [threadId, setThreadId] = useState<string | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const userInitial = userName?.[0]?.toUpperCase() ?? 'U'
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (viewportRef.current) {
|
||||
viewportRef.current.scrollTo({
|
||||
top: viewportRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}, [messages, loading])
|
||||
|
||||
// Auto-focus input when drawer opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 200)
|
||||
}
|
||||
}, [opened])
|
||||
|
||||
async function sendMessage(message: string) {
|
||||
if (!message.trim() || loading) return
|
||||
|
||||
const userMessage = message.trim()
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
setMessages((prev) => [...prev, { role: 'user', content: userMessage }])
|
||||
|
||||
try {
|
||||
const currentThreadId = threadId || crypto.randomUUID()
|
||||
if (!threadId) setThreadId(currentThreadId)
|
||||
|
||||
// TODO: confirm agent RPC — runs the `perauset-router` agent to completion.
|
||||
const res = await rpc.agent.run('perauset-router' as any, {
|
||||
message: userMessage,
|
||||
threadId: currentThreadId,
|
||||
resourceId: currentThreadId,
|
||||
})
|
||||
|
||||
const result = res?.result as
|
||||
| string
|
||||
| { text?: string; message?: string }
|
||||
| undefined
|
||||
const assistantMessage =
|
||||
typeof result === 'string'
|
||||
? result
|
||||
: result?.text || result?.message || JSON.stringify(result)
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: assistantMessage },
|
||||
])
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Request failed'
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage(input)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size={480}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="md"
|
||||
radius="xl"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<Bot size={18} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
fw={700}
|
||||
size="lg"
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#2A7B88' }}
|
||||
>
|
||||
{m.ai_chat_title()}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
content: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Messages area */}
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} px="md" pt="md">
|
||||
{messages.length === 0 && (
|
||||
<Stack align="center" justify="center" gap="sm" py="xl" mt="xl">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size={64}
|
||||
radius="xl"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<Bot size={36} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
style={{ fontFamily: '"Cormorant", serif' }}
|
||||
>
|
||||
{m.ai_chat_greeting()}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={300}>
|
||||
{m.ai_chat_subtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="md" pb="md">
|
||||
{messages.map((msg, i) => (
|
||||
<Group
|
||||
key={i}
|
||||
justify={msg.role === 'user' ? 'flex-end' : 'flex-start'}
|
||||
align="flex-end"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<Avatar
|
||||
size="sm"
|
||||
color="teal"
|
||||
radius="xl"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Bot size={16} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Paper
|
||||
px="sm"
|
||||
py="xs"
|
||||
radius="lg"
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
backgroundColor: msg.role === 'user' ? '#2A7B88' : '#F5F0E8',
|
||||
color: msg.role === 'user' ? '#ffffff' : '#3D2B1F',
|
||||
borderBottomRightRadius: msg.role === 'user' ? 4 : undefined,
|
||||
borderBottomLeftRadius:
|
||||
msg.role === 'assistant' ? 4 : undefined,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{asI18n(msg.content)}
|
||||
</Text>
|
||||
</Paper>
|
||||
{msg.role === 'user' && (
|
||||
<Avatar
|
||||
size="sm"
|
||||
color="teal"
|
||||
radius="xl"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{userInitial}
|
||||
</Avatar>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{/* Typing indicator */}
|
||||
{loading && (
|
||||
<Group justify="flex-start" align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Avatar size="sm" color="teal" radius="xl" style={{ flexShrink: 0 }}>
|
||||
<Bot size={16} />
|
||||
</Avatar>
|
||||
<Paper
|
||||
px="sm"
|
||||
py="xs"
|
||||
radius="lg"
|
||||
style={{
|
||||
backgroundColor: '#F5F0E8',
|
||||
borderBottomLeftRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Loader size="xs" type="dots" color="teal" />
|
||||
</Group>
|
||||
</Paper>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input bar */}
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
borderTop: '1px solid rgba(61, 43, 31, 0.08)',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={m.ai_chat_input_placeholder()}
|
||||
disabled={loading}
|
||||
radius="xl"
|
||||
size="md"
|
||||
style={{ flex: 1 }}
|
||||
styles={{
|
||||
input: {
|
||||
border: '1px solid rgba(61, 43, 31, 0.15)',
|
||||
'&:focus': {
|
||||
borderColor: '#2A7B88',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="teal"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || loading}
|
||||
aria-label={asI18n('Send message')}
|
||||
>
|
||||
<Send size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
205
apps/app/src/components/create-template-form.tsx
Normal file
205
apps/app/src/components/create-template-form.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Stack,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Group,
|
||||
Button,
|
||||
Switch,
|
||||
NumberInput,
|
||||
Collapse,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
|
||||
type Category =
|
||||
| 'housekeeping'
|
||||
| 'kitchen'
|
||||
| 'maintenance'
|
||||
| 'operations'
|
||||
| 'retreat'
|
||||
| 'transport'
|
||||
|
||||
const CATEGORIES: { value: Category; label: string }[] = [
|
||||
{ value: 'housekeeping', label: 'Housekeeping' },
|
||||
{ value: 'kitchen', label: 'Kitchen' },
|
||||
{ value: 'maintenance', label: 'Maintenance' },
|
||||
{ value: 'operations', label: 'Operations' },
|
||||
{ value: 'retreat', label: 'Retreat' },
|
||||
{ value: 'transport', label: 'Transport' },
|
||||
]
|
||||
|
||||
export function CreateTemplateForm() {
|
||||
const queryClient = useQueryClient()
|
||||
const [opened, { toggle }] = useDisclosure(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [category, setCategory] = useState<Category | null>(null)
|
||||
const [isClaimable, setIsClaimable] = useState(false)
|
||||
const [defaultLocation, setDefaultLocation] = useState('')
|
||||
const [estimatedMinutes, setEstimatedMinutes] = useState<number | string>('')
|
||||
const [recurrenceCron, setRecurrenceCron] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setCategory(null)
|
||||
setIsClaimable(false)
|
||||
setDefaultLocation('')
|
||||
setEstimatedMinutes('')
|
||||
setRecurrenceCron('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createTaskTemplate', {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: m.templates_created(),
|
||||
message: `"${title}" template has been created.`,
|
||||
color: 'teal',
|
||||
})
|
||||
resetForm()
|
||||
toggle()
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) =>
|
||||
typeof q.queryKey[0] === 'string' &&
|
||||
(q.queryKey[0] as string).toLowerCase().includes('template'),
|
||||
})
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ?? 'Something went wrong',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!title.trim() || !category) {
|
||||
notifications.show({
|
||||
title: m.common_validation(),
|
||||
message: m.templates_title_category_required(),
|
||||
color: 'orange',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const body = {
|
||||
title: title.trim(),
|
||||
category,
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
...(isClaimable ? { isClaimable: true } : {}),
|
||||
...(defaultLocation.trim() ? { defaultLocation: defaultLocation.trim() } : {}),
|
||||
...(typeof estimatedMinutes === 'number' && estimatedMinutes > 0
|
||||
? { estimatedMinutes }
|
||||
: {}),
|
||||
...(recurrenceCron.trim() ? { recurrenceCron: recurrenceCron.trim() } : {}),
|
||||
}
|
||||
|
||||
mutation.mutate(body)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="light" leftSection={<Plus size={16} />} onClick={toggle}>
|
||||
{opened ? m.templates_hide_form() : m.templates_create()}
|
||||
</Button>
|
||||
|
||||
<Collapse expanded={opened}>
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
maxWidth: 640,
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
order={4}
|
||||
mb="md"
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
|
||||
>
|
||||
{m.templates_new()}
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.templates_field_title()}
|
||||
placeholder={m.templates_field_title_placeholder()}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.templates_field_description()}
|
||||
placeholder={m.templates_field_description_placeholder()}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={m.templates_field_category()}
|
||||
placeholder={m.templates_field_category_placeholder()}
|
||||
data={CATEGORIES}
|
||||
value={category}
|
||||
onChange={(v) => setCategory(v as Category | null)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={m.templates_field_claimable()}
|
||||
checked={isClaimable}
|
||||
onChange={(e) => setIsClaimable(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.templates_field_default_location()}
|
||||
placeholder={m.templates_field_default_location_placeholder()}
|
||||
value={defaultLocation}
|
||||
onChange={(e) => setDefaultLocation(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={m.templates_field_estimated_minutes()}
|
||||
placeholder={m.templates_field_estimated_minutes_placeholder()}
|
||||
min={1}
|
||||
value={estimatedMinutes}
|
||||
onChange={setEstimatedMinutes}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.templates_field_recurrence()}
|
||||
placeholder={m.templates_field_recurrence_placeholder()}
|
||||
description={m.templates_recurrence_hint()}
|
||||
value={recurrenceCron}
|
||||
onChange={(e) => setRecurrenceCron(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={toggle}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" loading={mutation.isPending}>
|
||||
{m.templates_create()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
apps/app/src/components/locale-switcher.tsx
Normal file
30
apps/app/src/components/locale-switcher.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ActionIcon, Tooltip } from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { Languages } from 'lucide-react'
|
||||
import { useLocale, supportedLocales, type Locale } from '@/i18n/config'
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const { locale, setLocale } = useLocale()
|
||||
|
||||
const toggleLocale = () => {
|
||||
// Toggle between the supported locales (currently `en` and `de`).
|
||||
const idx = supportedLocales.indexOf(locale)
|
||||
const next = supportedLocales[(idx + 1) % supportedLocales.length] as Locale
|
||||
setLocale(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={asI18n(locale.toUpperCase())}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
onClick={toggleLocale}
|
||||
aria-label={asI18n('Switch language')}
|
||||
>
|
||||
<Languages size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
282
apps/app/src/components/notification-bell.tsx
Normal file
282
apps/app/src/components/notification-bell.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
ActionIcon,
|
||||
Indicator,
|
||||
Popover,
|
||||
Drawer,
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
Box,
|
||||
UnstyledButton,
|
||||
Anchor,
|
||||
Divider,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { Bell, BellOff } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
|
||||
interface NotificationItem {
|
||||
notificationId: string
|
||||
type: string
|
||||
title: string
|
||||
body: string | null
|
||||
entityType: string | null
|
||||
entityId: string | null
|
||||
readAt: string | Date | null
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
function entityLink(
|
||||
entityType: string | null,
|
||||
entityId: string | null,
|
||||
): string | null {
|
||||
if (!entityType || !entityId) return null
|
||||
switch (entityType) {
|
||||
case 'task':
|
||||
return '/tasks'
|
||||
case 'stay':
|
||||
return '/stays'
|
||||
case 'boat_trip':
|
||||
return '/boats'
|
||||
case 'retreat':
|
||||
return '/retreats'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(val: string | Date): string {
|
||||
const d = typeof val === 'string' ? new Date(val) : val
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
const diffDays = Math.floor(diffHr / 24)
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function NotificationEntry({
|
||||
n,
|
||||
onRead,
|
||||
}: {
|
||||
n: NotificationItem
|
||||
onRead: (id: string, link: string | null) => void
|
||||
}) {
|
||||
const isRead = !!n.readAt
|
||||
const link = entityLink(n.entityType, n.entityId)
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => onRead(n.notificationId, link)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isRead ? 'transparent' : '#2A7B88',
|
||||
flexShrink: 0,
|
||||
marginTop: 6,
|
||||
}}
|
||||
/>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={isRead ? 400 : 600} lineClamp={1}>
|
||||
{asI18n(n.title)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{asI18n(formatTimestamp(n.createdAt))}
|
||||
</Text>
|
||||
</Group>
|
||||
{n.body && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
|
||||
{asI18n(n.body)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||
const [popoverOpened, { toggle: togglePopover, close: closePopover }] =
|
||||
useDisclosure()
|
||||
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
|
||||
useDisclosure()
|
||||
|
||||
const { data } = usePikkuQuery(
|
||||
'listNotifications',
|
||||
{ limit: 5, offset: 0 },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const items = (data?.items ?? []) as NotificationItem[]
|
||||
const unreadCount = items.filter((n) => !n.readAt).length
|
||||
|
||||
const markReadMutation = usePikkuMutation('markNotificationRead', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleRead = useCallback(
|
||||
(notificationId: string, link: string | null) => {
|
||||
// Optimistic update
|
||||
queryClient.setQueryData<{ items?: NotificationItem[] }>(
|
||||
['listNotifications', { limit: 5, offset: 0 }],
|
||||
(old) => {
|
||||
if (!old?.items) return old
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((n) =>
|
||||
n.notificationId === notificationId
|
||||
? { ...n, readAt: new Date().toISOString() }
|
||||
: n,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
markReadMutation.mutate({ notificationId })
|
||||
|
||||
if (link) {
|
||||
closePopover()
|
||||
closeDrawer()
|
||||
navigate({ to: link })
|
||||
}
|
||||
},
|
||||
[queryClient, markReadMutation, closePopover, closeDrawer, navigate],
|
||||
)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isMobile) {
|
||||
toggleDrawer()
|
||||
} else {
|
||||
togglePopover()
|
||||
}
|
||||
}
|
||||
|
||||
const bellButton = (
|
||||
<Indicator
|
||||
size={16}
|
||||
label={unreadCount > 0 ? asI18n(String(unreadCount)) : undefined}
|
||||
disabled={unreadCount === 0}
|
||||
color="red"
|
||||
offset={4}
|
||||
processing={unreadCount > 0}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={handleToggle}
|
||||
aria-label={asI18n('Notifications')}
|
||||
>
|
||||
<Bell size={20} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" px="xs" py="xs">
|
||||
<Text fw={600} size="sm" style={{ fontFamily: '"Cormorant", serif' }}>
|
||||
{m.notifications_title()}
|
||||
</Text>
|
||||
{unreadCount > 0 && (
|
||||
<Text size="xs" c="teal" fw={500}>
|
||||
{m.notifications_unread_count({ count: unreadCount })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Divider />
|
||||
{items.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py="lg">
|
||||
<BellOff size={28} color="#aaa" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.notifications_empty()}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={2} p={4}>
|
||||
{items.map((n) => (
|
||||
<NotificationEntry key={n.notificationId} n={n} onRead={handleRead} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Divider />
|
||||
<Box py="xs" px="xs" style={{ textAlign: 'center' }}>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
closePopover()
|
||||
closeDrawer()
|
||||
navigate({ to: '/notifications' })
|
||||
}}
|
||||
>
|
||||
{m.notifications_view_all()}
|
||||
</Anchor>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{bellButton}
|
||||
<Drawer
|
||||
opened={drawerOpened}
|
||||
onClose={closeDrawer}
|
||||
position="bottom"
|
||||
size="60%"
|
||||
title={undefined}
|
||||
styles={{
|
||||
content: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
onChange={(o) => {
|
||||
if (!o) closePopover()
|
||||
}}
|
||||
width={360}
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
radius="md"
|
||||
>
|
||||
<Popover.Target>{bellButton}</Popover.Target>
|
||||
<Popover.Dropdown p={0}>{content}</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
140
apps/app/src/components/notification-row.tsx
Normal file
140
apps/app/src/components/notification-row.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Group, Text, Box, UnstyledButton } from '@pikku/mantine/core'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { Bell, Check } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
|
||||
interface NotificationItem {
|
||||
notificationId: string
|
||||
type: string
|
||||
title: string
|
||||
body: string | null
|
||||
entityType: string | null
|
||||
entityId: string | null
|
||||
readAt: string | Date | null
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
function entityLink(
|
||||
entityType: string | null,
|
||||
entityId: string | null,
|
||||
): string | null {
|
||||
if (!entityType || !entityId) return null
|
||||
switch (entityType) {
|
||||
case 'task':
|
||||
return '/tasks'
|
||||
case 'stay':
|
||||
return '/stays'
|
||||
case 'boat_trip':
|
||||
return '/boats'
|
||||
case 'retreat':
|
||||
return '/retreats'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(val: string | Date): string {
|
||||
const d = typeof val === 'string' ? new Date(val) : val
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
const diffDays = Math.floor(diffHr / 24)
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function NotificationRow({
|
||||
notification,
|
||||
}: {
|
||||
notification: NotificationItem
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const isRead = !!notification.readAt
|
||||
|
||||
const markReadMutation = usePikkuMutation('markNotificationRead', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isRead) {
|
||||
markReadMutation.mutate({ notificationId: notification.notificationId })
|
||||
}
|
||||
|
||||
const link = entityLink(notification.entityType, notification.entityId)
|
||||
if (link) {
|
||||
navigate({ to: link })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid rgba(61, 43, 31, 0.06)',
|
||||
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isRead
|
||||
? 'rgba(61, 43, 31, 0.06)'
|
||||
: 'rgba(42, 123, 136, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{isRead ? (
|
||||
<Check size={16} color="#aaa" />
|
||||
) : (
|
||||
<Bell size={16} color="#2A7B88" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={isRead ? 400 : 600}
|
||||
lineClamp={1}
|
||||
style={{ color: '#3D2B1F' }}
|
||||
>
|
||||
{asI18n(notification.title)}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{asI18n(formatTimestamp(notification.createdAt))}
|
||||
</Text>
|
||||
</Group>
|
||||
{notification.body && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2} mt={2}>
|
||||
{asI18n(notification.body)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)
|
||||
}
|
||||
419
apps/app/src/components/stays-tabs.tsx
Normal file
419
apps/app/src/components/stays-tabs.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useState } from 'react'
|
||||
import { Tabs, Text, Group, ActionIcon, Tooltip } from '@pikku/mantine/core'
|
||||
import { Check, X, LogIn, LogOut, Ban } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
import { StatusBadge } from '@perauset/components'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
interface Stay {
|
||||
stayId: string
|
||||
userId: string
|
||||
userDisplayName?: string | null
|
||||
stayType: string
|
||||
startAt: string | Date
|
||||
endAt: string | Date
|
||||
status: string
|
||||
}
|
||||
|
||||
interface StayRequest {
|
||||
requestId: string
|
||||
userId: string
|
||||
userDisplayName?: string | null
|
||||
requestType: string
|
||||
requestedStartAt: string | Date
|
||||
requestedEndAt: string | Date
|
||||
status: string
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | Date): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return String(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
function formatType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function StayRequestActions({ requestId }: { requestId: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const approveMutation = usePikkuMutation('approveStayRequest', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
notifications.show({
|
||||
title: m.stays_request_approved(),
|
||||
message: m.stays_request_approved_message(),
|
||||
color: 'green',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ? asI18n(err.message) : m.stays_approve_failed(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const rejectMutation = usePikkuMutation('rejectStayRequest', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
notifications.show({
|
||||
title: m.stays_request_rejected(),
|
||||
message: m.stays_request_rejected_message(),
|
||||
color: 'orange',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ? asI18n(err.message) : m.stays_reject_failed(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
onClick={() => approveMutation.mutate({ requestId })}
|
||||
loading={approveMutation.isPending}
|
||||
aria-label={m.stays_approve()}
|
||||
>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => rejectMutation.mutate({ requestId, reviewNotes: '' })}
|
||||
loading={rejectMutation.isPending}
|
||||
aria-label={m.stays_reject()}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function StayActions({ stayId, status }: { stayId: string; status: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const checkInMutation = usePikkuMutation('checkInStay', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
notifications.show({
|
||||
title: m.stays_checked_in(),
|
||||
message: m.stays_checked_in_message(),
|
||||
color: 'green',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ? asI18n(err.message) : m.stays_check_in_failed(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const checkOutMutation = usePikkuMutation('checkOutStay', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
notifications.show({
|
||||
title: m.stays_checked_out(),
|
||||
message: m.stays_checked_out_message(),
|
||||
color: 'green',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ? asI18n(err.message) : m.stays_check_out_failed(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const cancelMutation = usePikkuMutation('cancelStay', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
notifications.show({
|
||||
title: m.stays_stay_cancelled(),
|
||||
message: m.stays_stay_cancelled_message(),
|
||||
color: 'orange',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ? asI18n(err.message) : m.stays_cancel_failed(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const canCheckIn = status === 'approved' || status === 'confirmed'
|
||||
const canCheckOut = status === 'checked_in'
|
||||
const canCancel = status === 'approved' || status === 'confirmed' || status === 'checked_in'
|
||||
|
||||
if (!canCheckIn && !canCheckOut && !canCancel) return null
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{canCheckIn && (
|
||||
<Tooltip label={m.stays_check_in()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
onClick={() => checkInMutation.mutate({ stayId })}
|
||||
loading={checkInMutation.isPending}
|
||||
>
|
||||
<LogIn size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCheckOut && (
|
||||
<Tooltip label={m.stays_check_out()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => checkOutMutation.mutate({ stayId })}
|
||||
loading={checkOutMutation.isPending}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Tooltip label={m.stays_cancel_stay()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => cancelMutation.mutate({ stayId })}
|
||||
loading={cancelMutation.isPending}
|
||||
>
|
||||
<Ban size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
export function StaysTabs({
|
||||
stays,
|
||||
stayRequests,
|
||||
}: {
|
||||
stays: Stay[]
|
||||
stayRequests: StayRequest[]
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (['active', 'checked_in', 'requests'].includes(hash)) return hash
|
||||
}
|
||||
return 'active'
|
||||
})
|
||||
|
||||
const canReview = usePermission('stays.review')
|
||||
const canManage = usePermission('stays.manage')
|
||||
|
||||
const stayColumns: Column<Stay>[] = [
|
||||
{
|
||||
key: 'userId',
|
||||
label: m.stays_user(),
|
||||
render: (row) => (
|
||||
<Text size="sm" truncate>
|
||||
{asI18n(row.userDisplayName || 'Guest')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'stayType',
|
||||
label: m.stays_type(),
|
||||
render: (row) => <Text size="sm">{asI18n(formatType(row.stayType))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
label: m.stays_dates(),
|
||||
render: (row) => (
|
||||
<Text size="sm">
|
||||
{asI18n(`${formatDate(row.startAt)} - ${formatDate(row.endAt)}`)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: m.stays_status(),
|
||||
render: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
label: m.stays_actions(),
|
||||
render: (row: Stay) => (
|
||||
<StayActions stayId={row.stayId} status={row.status} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const requestColumns: Column<StayRequest>[] = [
|
||||
{
|
||||
key: 'userId',
|
||||
label: m.stays_user(),
|
||||
render: (row) => (
|
||||
<Text size="sm" truncate>
|
||||
{asI18n(row.userDisplayName || 'Guest')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requestType',
|
||||
label: m.stays_type(),
|
||||
render: (row) => <Text size="sm">{asI18n(formatType(row.requestType))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
label: m.stays_requested_dates(),
|
||||
render: (row) => (
|
||||
<Text size="sm">
|
||||
{asI18n(
|
||||
`${formatDate(row.requestedStartAt)} - ${formatDate(row.requestedEndAt)}`,
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: m.stays_status(),
|
||||
render: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
...(canReview
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
label: m.stays_actions(),
|
||||
render: (row: StayRequest) =>
|
||||
row.status === 'submitted' || row.status === 'under_review' ? (
|
||||
<StayRequestActions requestId={row.requestId} />
|
||||
) : null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const checkedInStays = stays.filter((s) => s.status === 'checked_in')
|
||||
|
||||
const checkedInColumns: Column<Stay>[] = [
|
||||
{
|
||||
key: 'userId',
|
||||
label: m.stays_guest(),
|
||||
render: (row) => (
|
||||
<Text size="sm" truncate>
|
||||
{asI18n(row.userDisplayName || 'Guest')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'stayType',
|
||||
label: m.stays_type(),
|
||||
render: (row) => <Text size="sm">{asI18n(formatType(row.stayType))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'startAt',
|
||||
label: m.stays_check_in_date(),
|
||||
render: (row) => <Text size="sm">{asI18n(formatDate(row.startAt))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'endAt',
|
||||
label: m.stays_expected_checkout(),
|
||||
render: (row) => <Text size="sm">{asI18n(formatDate(row.endAt))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: m.stays_status(),
|
||||
render: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
label: m.stays_actions(),
|
||||
render: (row: Stay) => (
|
||||
<StayActions stayId={row.stayId} status={row.status} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(val) => {
|
||||
setActiveTab(val || 'active')
|
||||
window.history.replaceState(null, '', `#${val || 'active'}`)
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="active">{m.stays_active_stays()}</Tabs.Tab>
|
||||
<Tabs.Tab value="checked_in">
|
||||
{asI18n(`${m.stays_checked_in()} (${checkedInStays.length})`)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="requests">{m.stays_requests()}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="active" pt="md">
|
||||
<DataTable
|
||||
data={stays}
|
||||
columns={stayColumns}
|
||||
rowKey={(row) => row.stayId}
|
||||
emptyMessage={m.stays_no_active_stays()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="checked_in" pt="md">
|
||||
<DataTable
|
||||
data={checkedInStays}
|
||||
columns={checkedInColumns}
|
||||
rowKey={(row) => row.stayId}
|
||||
emptyMessage={m.stays_no_checked_in()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="requests" pt="md">
|
||||
<DataTable
|
||||
data={stayRequests}
|
||||
columns={requestColumns}
|
||||
rowKey={(row) => row.requestId}
|
||||
emptyMessage={m.stays_no_requests()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
208
apps/app/src/components/task-actions.tsx
Normal file
208
apps/app/src/components/task-actions.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Group, Modal, Textarea } from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
|
||||
interface TaskActionsProps {
|
||||
taskId: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export function TaskActions({ taskId, status }: TaskActionsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [blockOpened, { open: openBlock, close: closeBlock }] = useDisclosure()
|
||||
const [blockReason, setBlockReason] = useState('')
|
||||
const [completeOpened, { open: openComplete, close: closeComplete }] =
|
||||
useDisclosure()
|
||||
const [completeNotes, setCompleteNotes] = useState('')
|
||||
|
||||
// Tasks appear under several listing queries (listTasks, listMyTasks, …);
|
||||
// invalidate any task-related query after a mutation.
|
||||
const invalidateTasks = () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) =>
|
||||
typeof q.queryKey[0] === 'string' &&
|
||||
(q.queryKey[0] as string).toLowerCase().includes('task'),
|
||||
})
|
||||
}
|
||||
|
||||
const onError = (err: Error) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ?? 'Something went wrong',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
|
||||
const claimMutation = usePikkuMutation('claimTask', {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: m.common_success(),
|
||||
message: m.tasks_claimed(),
|
||||
color: 'teal',
|
||||
})
|
||||
invalidateTasks()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const startMutation = usePikkuMutation('startTask', {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: m.common_success(),
|
||||
message: m.tasks_started(),
|
||||
color: 'teal',
|
||||
})
|
||||
invalidateTasks()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const completeMutation = usePikkuMutation('completeTask', {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: m.common_success(),
|
||||
message: m.tasks_completed(),
|
||||
color: 'teal',
|
||||
})
|
||||
closeComplete()
|
||||
setCompleteNotes('')
|
||||
invalidateTasks()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const blockMutation = usePikkuMutation('markTaskBlocked', {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: m.common_success(),
|
||||
message: m.tasks_blocked(),
|
||||
color: 'teal',
|
||||
})
|
||||
closeBlock()
|
||||
setBlockReason('')
|
||||
invalidateTasks()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{status === 'open' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="teal"
|
||||
loading={claimMutation.isPending}
|
||||
onClick={() => claimMutation.mutate({ taskId })}
|
||||
>
|
||||
{m.tasks_claim()}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'assigned' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
loading={startMutation.isPending}
|
||||
onClick={() => startMutation.mutate({ taskId })}
|
||||
>
|
||||
{m.tasks_start()}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'in_progress' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="green"
|
||||
loading={completeMutation.isPending}
|
||||
onClick={openComplete}
|
||||
>
|
||||
{m.tasks_complete()}
|
||||
</Button>
|
||||
)}
|
||||
{(status === 'open' ||
|
||||
status === 'assigned' ||
|
||||
status === 'in_progress') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
loading={blockMutation.isPending}
|
||||
onClick={openBlock}
|
||||
>
|
||||
{m.tasks_block()}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={blockOpened}
|
||||
onClose={closeBlock}
|
||||
title={m.tasks_block_title()}
|
||||
centered
|
||||
>
|
||||
<Textarea
|
||||
label={m.tasks_reason()}
|
||||
placeholder={m.tasks_reason_placeholder()}
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
mb="md"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeBlock}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={blockMutation.isPending}
|
||||
onClick={() => {
|
||||
if (blockReason.trim())
|
||||
blockMutation.mutate({ taskId, reason: blockReason })
|
||||
}}
|
||||
>
|
||||
{m.tasks_mark_blocked()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={completeOpened}
|
||||
onClose={closeComplete}
|
||||
title={m.tasks_complete_title()}
|
||||
centered
|
||||
>
|
||||
<Textarea
|
||||
label={m.tasks_notes_optional()}
|
||||
placeholder={m.tasks_notes_placeholder()}
|
||||
value={completeNotes}
|
||||
onChange={(e) => setCompleteNotes(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
mb="md"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeComplete}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
loading={completeMutation.isPending}
|
||||
onClick={() =>
|
||||
completeMutation.mutate({
|
||||
taskId,
|
||||
...(completeNotes ? { notes: completeNotes } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{m.tasks_complete()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
65
apps/app/src/components/user-select.tsx
Normal file
65
apps/app/src/components/user-select.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Select, Group, Avatar, Text } from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
|
||||
interface UserSelectProps {
|
||||
label?: string
|
||||
placeholder?: string
|
||||
value: string | null
|
||||
onChange: (value: string | null) => void
|
||||
required?: boolean
|
||||
searchable?: boolean
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
export function UserSelect({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
searchable = true,
|
||||
clearable,
|
||||
}: UserSelectProps) {
|
||||
const { data } = usePikkuQuery('listUsers', { limit: 200, offset: 0 })
|
||||
|
||||
const users = data?.items ?? []
|
||||
|
||||
const options = users.map((u) => ({
|
||||
value: u.userId,
|
||||
label: u.displayName ?? u.name ?? u.email,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label ? asI18n(label) : m.user_select_label()}
|
||||
placeholder={placeholder ? asI18n(placeholder) : undefined}
|
||||
data={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
searchable={searchable}
|
||||
clearable={clearable}
|
||||
renderOption={({ option }) => {
|
||||
const user = users.find((u) => u.userId === option.value)
|
||||
if (!user) return <Text size="sm">{asI18n(option.label)}</Text>
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar src={user.avatarUrl} size="sm" radius="xl" color="teal">
|
||||
{(user.displayName?.[0] || user.name?.[0] || user.email[0]).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(option.label)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(user.email)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
101
apps/app/src/i18n/config.ts
Normal file
101
apps/app/src/i18n/config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
|
||||
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
|
||||
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
|
||||
// message functions (`m`).
|
||||
//
|
||||
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
|
||||
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
|
||||
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { locales, baseLocale, overwriteGetLocale, getLocale } from '../paraglide/runtime.js'
|
||||
|
||||
export const supportedLocales = locales
|
||||
export const defaultLocale = baseLocale
|
||||
export type Locale = (typeof locales)[number]
|
||||
// `getLocale()` reads the active locale (via the overwrite below) — used by
|
||||
// pages that format dates/values for the current language.
|
||||
export { getLocale }
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'app-locale'
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
||||
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
||||
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
|
||||
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
||||
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
const normalizeLanguage = (value: string | null | undefined): Locale | undefined => {
|
||||
const short = value?.slice(0, 2).toLowerCase()
|
||||
return supportedLocales.includes(short as Locale) ? (short as Locale) : undefined
|
||||
}
|
||||
|
||||
// Initial locale: persisted choice → browser → <html lang> → base. (Ported from
|
||||
// the old i18next init; germantax has no in-app switcher, locale is read once.)
|
||||
export function detectInitialLocale(): Locale {
|
||||
if (typeof window === 'undefined') return defaultLocale
|
||||
return (
|
||||
normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)) ??
|
||||
normalizeLanguage(window.navigator.language) ??
|
||||
normalizeLanguage(window.document.documentElement.lang) ??
|
||||
defaultLocale
|
||||
)
|
||||
}
|
||||
|
||||
// ── reactive locale store ────────────────────────────────────────────────────
|
||||
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
|
||||
// it to a tiny external store: `getLocale` reads `activeLocale`; components
|
||||
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
|
||||
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
|
||||
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
|
||||
let activeLocale: Locale = defaultLocale
|
||||
const listeners = new Set<() => void>()
|
||||
overwriteGetLocale(() => activeLocale)
|
||||
|
||||
export function setActiveLocale(next: Locale): void {
|
||||
if (next === activeLocale) return
|
||||
activeLocale = next
|
||||
// Persist + reflect on <html lang> (ported from the old i18next languageChanged
|
||||
// handler), so a reload restores the choice.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next)
|
||||
window.document.documentElement.lang = next
|
||||
}
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
// Resolve the initial locale once at startup (import side-effect, matching the
|
||||
// old i18n.ts init). Importing this module from the app entry runs it.
|
||||
activeLocale = detectInitialLocale()
|
||||
|
||||
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
|
||||
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
|
||||
// used to live; it also returns the active locale + direction for components
|
||||
// that need them (e.g. the language switcher).
|
||||
export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } {
|
||||
const locale = useSyncExternalStore<Locale>(subscribe, () => activeLocale, () => activeLocale)
|
||||
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
|
||||
}
|
||||
|
||||
// ── i18n-debug masking ───────────────────────────────────────────────────────
|
||||
// When enabled, every *translated* string is masked to block glyphs (█) so any
|
||||
// readable text left on screen is text that never went through a message (a
|
||||
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
|
||||
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
|
||||
export function isI18nDebug(): boolean {
|
||||
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
||||
if (typeof window === 'undefined') return false
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
||||
return window.localStorage?.getItem('i18n-debug') === '1'
|
||||
}
|
||||
|
||||
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
|
||||
export function maskI18n(s: string): string {
|
||||
return isI18nDebug() ? s.replace(/\S/g, '█') : s
|
||||
}
|
||||
12
apps/app/src/i18n/ident.ts
Normal file
12
apps/app/src/i18n/ident.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Single source of truth for turning an i18next dotted key (`landing.cards.x`)
|
||||
// into a Paraglide snake_case message identifier (`landing_cards_x`). Used by the
|
||||
// runtime resolver `mKey` for the unavoidable dynamic cases.
|
||||
export const identOf = (dotPath: string): string =>
|
||||
dotPath
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
||||
.replace(/[.\-\s]+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_]/g, '')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.toLowerCase()
|
||||
54
apps/app/src/i18n/messages.ts
Normal file
54
apps/app/src/i18n/messages.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Typed message functions for the app. `m.landing_title()` returns a branded
|
||||
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
|
||||
// `t('landing.title')` used to — no call-site boilerplate.
|
||||
//
|
||||
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
|
||||
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
|
||||
// per-message tree-shaking — fine here: locale files are KB and the app bundled
|
||||
// all of en.json under i18next anyway.
|
||||
import { m as _m } from '../paraglide/messages.js'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { identOf } from './ident.js'
|
||||
import { maskI18n } from './config.js'
|
||||
|
||||
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
|
||||
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
|
||||
|
||||
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
|
||||
const _wrapped: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(_raw)) {
|
||||
const fn = _raw[key]
|
||||
_wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn
|
||||
}
|
||||
|
||||
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
|
||||
export const m = _wrapped as unknown as Branded<typeof _m>
|
||||
|
||||
/**
|
||||
* Runtime key resolver for computed keys — `mKey(`landing.cards.${k}.title`)` or
|
||||
* `mKey(MAP[k])`. Flattens the dotted key to its snake_case token and calls the
|
||||
* message. Returns the raw key (and warns) on a miss, mirroring i18next's
|
||||
* graceful degradation. Prefer static `m.token(...)` wherever the key is known.
|
||||
*/
|
||||
export const mKey = (key: string, args?: Record<string, unknown>): I18nString => {
|
||||
const fn = _raw[identOf(key)]
|
||||
if (!fn) {
|
||||
if (typeof console !== 'undefined') console.warn(`[i18n] missing message for key "${key}"`)
|
||||
return maskI18n(key) as unknown as I18nString
|
||||
}
|
||||
return maskI18n(fn(args)) as unknown as I18nString
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an i18next `returnObjects` array (stored as indexed keys `prefix.0`,
|
||||
* `prefix.1`, …) back into a list. Returns messages until the first gap.
|
||||
*/
|
||||
export const mList = (keyPrefix: string, args?: Record<string, unknown>): I18nString[] => {
|
||||
const out: I18nString[] = []
|
||||
for (let i = 0; ; i++) {
|
||||
const fn = _raw[identOf(`${keyPrefix}.${i}`)]
|
||||
if (!fn) break
|
||||
out.push(maskI18n(fn(args)) as unknown as I18nString)
|
||||
}
|
||||
return out
|
||||
}
|
||||
18
apps/app/src/lib/auth-context.tsx
Normal file
18
apps/app/src/lib/auth-context.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export interface SessionUser {
|
||||
id: string
|
||||
email?: string | null
|
||||
name?: string | null
|
||||
displayName?: string | null
|
||||
memberRoles?: string | string[] | null
|
||||
avatarUrl?: string | null
|
||||
mobileNumber?: string | null
|
||||
whatsappNumber?: string | null
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<SessionUser | null>(null)
|
||||
|
||||
export function useSessionUser(): SessionUser | null {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
20
apps/app/src/lib/auth.ts
Normal file
20
apps/app/src/lib/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { apiUrl } from './env'
|
||||
|
||||
// Better Auth mounts at /api/auth (default basePath); the client appends the
|
||||
// endpoint verbatim to baseURL, so the base must include /auth — otherwise it
|
||||
// emits /api/sign-in/email and 404s on both sandbox and deploy.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: `${apiUrl()}/auth`,
|
||||
})
|
||||
|
||||
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
|
||||
|
||||
export async function signIn(email: string, password: string): Promise<void> {
|
||||
const { error } = await authClient.signIn.email({ email, password })
|
||||
if (error) throw new Error(INVALID_CREDENTIALS)
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
await authClient.signOut()
|
||||
}
|
||||
21
apps/app/src/lib/env.ts
Normal file
21
apps/app/src/lib/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Endpoints come from env, never hardcoded.
|
||||
//
|
||||
// In local dev VITE_API_URL is set at build time and Vite replaces the
|
||||
// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is
|
||||
// a runtime Worker binding — invisible to Vite at build time — so it's undefined
|
||||
// in the bundle. We fall back to the current origin + /api, which is correct
|
||||
// because the dispatcher routes every /api/* path to the API on the same host.
|
||||
export function apiUrl(): string {
|
||||
if (typeof window !== 'undefined' && (window as any).__E2E_API_URL) {
|
||||
return (window as any).__E2E_API_URL as string
|
||||
}
|
||||
if (import.meta.env.SSR) {
|
||||
// SSR context: the auth client is only consumed in the browser. Return the
|
||||
// build-time var when available or a placeholder so SSR never throws.
|
||||
return import.meta.env.VITE_API_URL ?? '/__api'
|
||||
}
|
||||
// Client: build-time var (local dev) or derive from the current origin.
|
||||
// Better Auth's client requires an absolute base URL, so never return a bare
|
||||
// relative '/api' here — '/api/auth' throws "Invalid base URL".
|
||||
return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`
|
||||
}
|
||||
21
apps/app/src/lib/permissions.ts
Normal file
21
apps/app/src/lib/permissions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ROLE_PERMISSIONS, hasPermission } from '@perauset/functions/src/lib/roles.js'
|
||||
import { useSessionUser } from './auth-context'
|
||||
|
||||
export { ROLE_PERMISSIONS, hasPermission }
|
||||
|
||||
export function useRoles(): string[] {
|
||||
const raw = useSessionUser()?.memberRoles
|
||||
if (raw == null) return []
|
||||
if (Array.isArray(raw)) return raw
|
||||
try {
|
||||
return JSON.parse(raw || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function usePermission(permission: string): boolean {
|
||||
const roles = useRoles()
|
||||
if (roles.length === 0) return false
|
||||
return hasPermission(roles, permission)
|
||||
}
|
||||
78
apps/app/src/pages/__root.tsx
Normal file
78
apps/app/src/pages/__root.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
} from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MantineProvider } from '@pikku/mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import { PikkuProvider, createPikku } from '@pikku/react'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
import { PikkuFetch } from '@perauset/functions-sdk/pikku/pikku-fetch.gen'
|
||||
import { PikkuRPC } from '@perauset/functions-sdk/pikku/pikku-rpc.gen'
|
||||
import { useLocale } from '../i18n/config'
|
||||
import { theme } from '@perauset/mantine-theme'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__E2E_API_URL?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'PerAuset' },
|
||||
],
|
||||
}),
|
||||
component: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument() {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
|
||||
mutations: { retry: 0 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const runtimeApiUrl =
|
||||
typeof window !== 'undefined' ? window.__E2E_API_URL : undefined
|
||||
const [pikku] = useState(() =>
|
||||
createPikku(PikkuFetch, PikkuRPC, {
|
||||
transformDate: true,
|
||||
serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
|
||||
// Reactive locale from the Paraglide store (persists to localStorage +
|
||||
// reflects <html lang> itself; initial detection runs on import).
|
||||
const { locale, dir } = useLocale()
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir}>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider theme={theme}>
|
||||
<Notifications />
|
||||
<PikkuProvider pikku={pikku}>
|
||||
<Outlet />
|
||||
</PikkuProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
128
apps/app/src/pages/_authenticated.audit-log.tsx
Normal file
128
apps/app/src/pages/_authenticated.audit-log.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Stack, Badge, Code, Loader, Center, Text } from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/audit-log')({
|
||||
component: AuditLogPage,
|
||||
})
|
||||
|
||||
const actionColor: Record<string, string> = {
|
||||
INSERT: 'green',
|
||||
UPDATE: 'blue',
|
||||
DELETE: 'red',
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
return new Date(ts).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
auditId: string
|
||||
occurredAt: string
|
||||
action: string
|
||||
tableName: string
|
||||
recordId: string
|
||||
userId: string | null
|
||||
changedFields: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function AuditLogPage() {
|
||||
useLocale()
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listAuditLog', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const entries = (data?.items ?? []) as AuditEntry[]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: Column<AuditEntry>[] = [
|
||||
{
|
||||
key: 'occurredAt',
|
||||
label: m.audit_timestamp(),
|
||||
render: (entry) => <Text size="sm">{asI18n(formatTimestamp(entry.occurredAt))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: m.audit_action(),
|
||||
render: (entry) => (
|
||||
<Badge color={actionColor[entry.action] ?? 'gray'} variant="light" size="sm">
|
||||
{asI18n(entry.action)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tableName',
|
||||
label: m.audit_table(),
|
||||
render: (entry) => (
|
||||
<Text size="sm" ff="monospace">
|
||||
{asI18n(entry.tableName)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'recordId',
|
||||
label: m.audit_record_id(),
|
||||
render: (entry) => (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{asI18n(entry.recordId)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'userId',
|
||||
label: m.audit_user(),
|
||||
render: (entry) => (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{asI18n(entry.userId ?? '--')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'changedFields',
|
||||
label: m.audit_changed_fields(),
|
||||
render: (entry) =>
|
||||
entry.changedFields ? (
|
||||
<Code block style={{ fontSize: 11, maxWidth: 300 }}>
|
||||
{JSON.stringify(entry.changedFields, null, 2)}
|
||||
</Code>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n('--')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader title={m.audit_title()} description={m.audit_description()} />
|
||||
|
||||
<DataTable
|
||||
data={entries}
|
||||
columns={columns}
|
||||
rowKey={(entry) => entry.auditId}
|
||||
emptyMessage="No audit log entries found."
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
394
apps/app/src/pages/_authenticated.boats.index.tsx
Normal file
394
apps/app/src/pages/_authenticated.boats.index.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Stack,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Drawer,
|
||||
Textarea,
|
||||
Button,
|
||||
Select,
|
||||
TextInput,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Sailboat, Play, Ban, Check, Plus } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { StatusBadge } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/boats/')({
|
||||
component: BoatsPage,
|
||||
})
|
||||
|
||||
type BoatTrip = {
|
||||
tripId: string
|
||||
boatId: string | null
|
||||
routeId: string
|
||||
status: string
|
||||
scheduledDepartureAt: string | Date
|
||||
scheduledArrivalAt: string | Date | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | Date): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
function BoatActions({ onCreateTrip }: { onCreateTrip?: () => void }) {
|
||||
const canManage = usePermission('boats.manage')
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<Button component={Link} to="/boats/request" variant="light" color="teal" size="sm">
|
||||
{m.boats_request_seat()}
|
||||
</Button>
|
||||
{canManage && onCreateTrip && (
|
||||
<Button onClick={onCreateTrip} leftSection={<Plus size={16} />} color="teal" size="sm">
|
||||
{m.boats_create_trip()}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function TripActions({ trip }: { trip: BoatTrip }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['listBoatTrips'] })
|
||||
|
||||
const openMutation = usePikkuMutation('openBoatTrip', {
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
notifications.show({ title: 'Trip opened', message: 'Boat trip is now open for seat requests.', color: 'green' })
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to open trip.', color: 'red' }),
|
||||
})
|
||||
|
||||
const cancelMutation = usePikkuMutation('cancelBoatTrip', {
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
notifications.show({ title: 'Trip cancelled', message: 'Boat trip has been cancelled.', color: 'orange' })
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to cancel trip.', color: 'red' }),
|
||||
})
|
||||
|
||||
const completeMutation = usePikkuMutation('completeBoatTrip', {
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
notifications.show({ title: 'Trip completed', message: 'Boat trip has been marked as completed.', color: 'green' })
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to complete trip.', color: 'red' }),
|
||||
})
|
||||
|
||||
const canOpen = trip.status === 'planned'
|
||||
const canComplete = trip.status === 'open' || trip.status === 'in_progress'
|
||||
const canCancel = trip.status === 'planned' || trip.status === 'open'
|
||||
|
||||
if (!canOpen && !canComplete && !canCancel) return null
|
||||
|
||||
return (
|
||||
<Group gap="xs" onClick={(e) => e.preventDefault()}>
|
||||
{canOpen && (
|
||||
<Tooltip label={m.boats_open_for_requests()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
onClick={() => openMutation.mutate({ tripId: trip.tripId })}
|
||||
loading={openMutation.isPending}
|
||||
>
|
||||
<Play size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canComplete && (
|
||||
<Tooltip label={m.boats_complete_trip()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
onClick={() => completeMutation.mutate({ tripId: trip.tripId })}
|
||||
loading={completeMutation.isPending}
|
||||
>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Tooltip label={m.boats_cancel_trip()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => cancelMutation.mutate({ tripId: trip.tripId })}
|
||||
loading={cancelMutation.isPending}
|
||||
>
|
||||
<Ban size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTripDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
routeOptions,
|
||||
boatOptions,
|
||||
}: {
|
||||
opened: boolean
|
||||
onClose: () => void
|
||||
routeOptions: Array<{ value: string; label: string }>
|
||||
boatOptions: Array<{ value: string; label: string }>
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [routeId, setRouteId] = useState<string | null>(null)
|
||||
const [boatId, setBoatId] = useState<string | null>(null)
|
||||
const [scheduledDepartureAt, setScheduledDepartureAt] = useState('')
|
||||
const [scheduledArrivalAt, setScheduledArrivalAt] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setRouteId(null)
|
||||
setBoatId(null)
|
||||
setScheduledDepartureAt('')
|
||||
setScheduledArrivalAt('')
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createBoatTrip', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listBoatTrips'] })
|
||||
notifications.show({ title: 'Trip created', message: 'Boat trip has been created successfully.', color: 'green' })
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create boat trip.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!routeId || !scheduledDepartureAt) {
|
||||
notifications.show({ title: 'Missing fields', message: 'Route and departure time are required.', color: 'red' })
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
routeId,
|
||||
boatId: boatId || undefined,
|
||||
scheduledDepartureAt: new Date(scheduledDepartureAt).toISOString(),
|
||||
scheduledArrivalAt: scheduledArrivalAt ? new Date(scheduledArrivalAt).toISOString() : undefined,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_create_trip()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={m.boats_route()}
|
||||
placeholder={m.boats_select_route()}
|
||||
data={routeOptions}
|
||||
value={routeId}
|
||||
onChange={setRouteId}
|
||||
searchable
|
||||
required
|
||||
nothingFoundMessage={m.boats_no_routes_available()}
|
||||
/>
|
||||
<Select
|
||||
label={m.boats_boat()}
|
||||
placeholder={m.boats_select_boat_optional()}
|
||||
data={boatOptions}
|
||||
value={boatId}
|
||||
onChange={setBoatId}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage={m.boats_no_boats_available()}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.boats_scheduled_departure()}
|
||||
type="datetime-local"
|
||||
value={scheduledDepartureAt}
|
||||
onChange={(e) => setScheduledDepartureAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={m.boats_scheduled_arrival()}
|
||||
type="datetime-local"
|
||||
value={scheduledArrivalAt}
|
||||
onChange={(e) => setScheduledArrivalAt(e.currentTarget.value)}
|
||||
min={scheduledDepartureAt || undefined}
|
||||
/>
|
||||
<Textarea
|
||||
label={m.boats_notes()}
|
||||
placeholder={m.boats_additional_information()}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.boats_create_trip()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function BoatsPage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('boats.manage')
|
||||
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
|
||||
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
|
||||
const { data: boatsData } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
|
||||
|
||||
const trips = data?.items ?? []
|
||||
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
|
||||
const boatMap = new Map((boatsData?.items ?? []).map((b) => [b.boatId, b]))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.boats_trips_title()}
|
||||
description={m.boats_trips_description()}
|
||||
action={<BoatActions onCreateTrip={() => setCreateDrawerOpen(true)} />}
|
||||
/>
|
||||
|
||||
{trips.length === 0 ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{ backgroundColor: '#ffffff', boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)' }}
|
||||
>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.boats_no_trips()}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{trips.map((trip) => {
|
||||
const route = routeMap.get(trip.routeId)
|
||||
const boat = trip.boatId ? boatMap.get(trip.boatId) : null
|
||||
const routeLabel = route ? `${route.name} (${route.origin} - ${route.destination})` : 'Route'
|
||||
const boatLabel = boat ? boat.name : null
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={trip.tripId}
|
||||
component={Link}
|
||||
to="/boats/trips/$tripId"
|
||||
params={{ tripId: trip.tripId } as never}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
className="card-hover"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<Group gap="sm">
|
||||
<Sailboat size={20} color="#2A7B88" />
|
||||
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{asI18n(routeLabel)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<StatusBadge status={trip.status} />
|
||||
{canManage && <TripActions trip={trip} />}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="lg" mt="md">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.boats_departure()}
|
||||
</Text>
|
||||
<Text size="sm">{asI18n(formatDateTime(trip.scheduledDepartureAt))}</Text>
|
||||
</div>
|
||||
{trip.scheduledArrivalAt && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.boats_arrival()}
|
||||
</Text>
|
||||
<Text size="sm">{asI18n(formatDateTime(trip.scheduledArrivalAt))}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{boatLabel && (
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
{asI18n(`${m.boats_boat()}: ${boatLabel}`)}
|
||||
</Text>
|
||||
)}
|
||||
{trip.boatId && !boatLabel && (
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
{asI18n(`${m.boats_boat()}: ${m.boats_assigned()}`)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{trip.notes && (
|
||||
<Text size="xs" c="dimmed" mt="xs" lineClamp={2}>
|
||||
{asI18n(trip.notes)}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<CreateTripDrawer
|
||||
opened={createDrawerOpen}
|
||||
onClose={() => setCreateDrawerOpen(false)}
|
||||
routeOptions={(routesData?.items ?? []).map((r) => ({
|
||||
value: r.routeId,
|
||||
label: `${r.name} (${r.origin} - ${r.destination})`,
|
||||
}))}
|
||||
boatOptions={(boatsData?.items ?? []).map((b) => ({
|
||||
value: b.boatId,
|
||||
label: `${b.name} (${b.passengerCapacity} seats)`,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
450
apps/app/src/pages/_authenticated.boats.manage.tsx
Normal file
450
apps/app/src/pages/_authenticated.boats.manage.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Stack,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Drawer,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Tabs,
|
||||
Switch,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Pencil, Plus, Sailboat, Route as RouteIcon } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/boats/manage')({
|
||||
component: BoatManagePage,
|
||||
})
|
||||
|
||||
type Boat = {
|
||||
boatId: string
|
||||
name: string
|
||||
passengerCapacity: number
|
||||
cargoCapacityKg: number | null
|
||||
isActive: boolean
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
type BoatRoute = {
|
||||
routeId: string
|
||||
name: string
|
||||
origin: string
|
||||
destination: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}
|
||||
|
||||
function CreateBoatDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [passengerCapacity, setPassengerCapacity] = useState<number>(1)
|
||||
const [cargoCapacityKg, setCargoCapacityKg] = useState<number | undefined>(undefined)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setPassengerCapacity(1)
|
||||
setCargoCapacityKg(undefined)
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createBoat', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listBoats'] })
|
||||
notifications.show({ title: 'Boat created', message: 'New boat has been added to the fleet.', color: 'green' })
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create boat.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name) {
|
||||
notifications.show({ title: 'Missing fields', message: 'Name is required.', color: 'red' })
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
name,
|
||||
passengerCapacity,
|
||||
cargoCapacityKg: cargoCapacityKg || undefined,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_add_boat()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={m.boats_boat_name()} placeholder={m.boats_boat_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
||||
<NumberInput label={m.boats_passenger_capacity()} value={passengerCapacity} onChange={(val) => setPassengerCapacity(Number(val) || 1)} min={1} />
|
||||
<NumberInput label={m.boats_cargo_capacity()} placeholder={m.boats_cargo_capacity_placeholder()} value={cargoCapacityKg} onChange={(val) => setCargoCapacityKg(val ? Number(val) : undefined)} min={0} suffix={asI18n(' kg')} />
|
||||
<Textarea label={m.boats_notes()} placeholder={m.boats_boat_notes_placeholder()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>{m.boats_save_boat()}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function EditBoatDrawer({ boat, onClose }: { boat: Boat | null; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(boat?.name ?? '')
|
||||
const [passengerCapacity, setPassengerCapacity] = useState<number>(boat?.passengerCapacity ?? 1)
|
||||
const [cargoCapacityKg, setCargoCapacityKg] = useState<number | undefined>(boat?.cargoCapacityKg ?? undefined)
|
||||
const [isActive, setIsActive] = useState(boat?.isActive ?? true)
|
||||
const [notes, setNotes] = useState(boat?.notes ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (boat) {
|
||||
setName(boat.name)
|
||||
setPassengerCapacity(boat.passengerCapacity)
|
||||
setCargoCapacityKg(boat.cargoCapacityKg ?? undefined)
|
||||
setIsActive(boat.isActive)
|
||||
setNotes(boat.notes ?? '')
|
||||
}
|
||||
}, [boat])
|
||||
|
||||
const mutation = usePikkuMutation('updateBoat', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listBoats'] })
|
||||
notifications.show({ title: 'Boat updated', message: 'Boat details have been saved.', color: 'green' })
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update boat.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!boat) return
|
||||
mutation.mutate({
|
||||
boatId: boat.boatId,
|
||||
name,
|
||||
passengerCapacity,
|
||||
cargoCapacityKg: cargoCapacityKg || undefined,
|
||||
isActive,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (!boat) return null
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={!!boat} onClose={onClose} title={m.boats_edit_boat()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={m.boats_boat_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
||||
<NumberInput label={m.boats_passenger_capacity()} value={passengerCapacity} onChange={(val) => setPassengerCapacity(Number(val) || 1)} min={1} />
|
||||
<NumberInput label={m.boats_cargo_capacity()} value={cargoCapacityKg} onChange={(val) => setCargoCapacityKg(val ? Number(val) : undefined)} min={0} suffix={asI18n(' kg')} />
|
||||
<Switch label={m.boats_active()} checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} color="teal" />
|
||||
<Textarea label={m.boats_notes()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRouteDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [origin, setOrigin] = useState('')
|
||||
const [destination, setDestination] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setOrigin('')
|
||||
setDestination('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createBoatRoute', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listBoatRoutes'] })
|
||||
notifications.show({ title: 'Route created', message: 'New route has been added.', color: 'green' })
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create route.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name || !origin || !destination) {
|
||||
notifications.show({ title: 'Missing fields', message: 'Name, origin, and destination are required.', color: 'red' })
|
||||
return
|
||||
}
|
||||
mutation.mutate({ name, origin, destination })
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.boats_add_route()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={m.boats_route_name()} placeholder={m.boats_route_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
||||
<TextInput label={m.boats_origin()} placeholder={m.boats_origin_placeholder()} value={origin} onChange={(e) => setOrigin(e.currentTarget.value)} required />
|
||||
<TextInput label={m.boats_destination()} placeholder={m.boats_destination_placeholder()} value={destination} onChange={(e) => setDestination(e.currentTarget.value)} required />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>{m.boats_save_route()}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function EditRouteDrawer({ route, onClose }: { route: BoatRoute | null; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(route?.name ?? '')
|
||||
const [origin, setOrigin] = useState(route?.origin ?? '')
|
||||
const [destination, setDestination] = useState(route?.destination ?? '')
|
||||
const [isActive, setIsActive] = useState(route?.isActive ?? true)
|
||||
|
||||
useEffect(() => {
|
||||
if (route) {
|
||||
setName(route.name)
|
||||
setOrigin(route.origin)
|
||||
setDestination(route.destination)
|
||||
setIsActive(route.isActive)
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const mutation = usePikkuMutation('updateBoatRoute', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listBoatRoutes'] })
|
||||
notifications.show({ title: 'Route updated', message: 'Route details have been saved.', color: 'green' })
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update route.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!route) return
|
||||
mutation.mutate({ routeId: route.routeId, name, origin, destination, isActive })
|
||||
}
|
||||
|
||||
if (!route) return null
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={!!route} onClose={onClose} title={m.boats_edit_route()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={m.boats_route_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
||||
<TextInput label={m.boats_origin()} value={origin} onChange={(e) => setOrigin(e.currentTarget.value)} required />
|
||||
<TextInput label={m.boats_destination()} value={destination} onChange={(e) => setDestination(e.currentTarget.value)} required />
|
||||
<Switch label={m.boats_active()} checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} color="teal" />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function BoatManagePage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('boats.manage')
|
||||
const [activeTab, setActiveTab] = useState<string | null>('boats')
|
||||
const [createBoatOpen, setCreateBoatOpen] = useState(false)
|
||||
const [editingBoat, setEditingBoat] = useState<Boat | null>(null)
|
||||
const [createRouteOpen, setCreateRouteOpen] = useState(false)
|
||||
const [editingRoute, setEditingRoute] = useState<BoatRoute | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (hash === 'boats' || hash === 'routes') {
|
||||
setActiveTab(hash)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
setActiveTab(value)
|
||||
if (value) {
|
||||
window.history.replaceState(null, '', `#${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { data: boatsData, isLoading: boatsLoading } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
|
||||
const { data: routesData, isLoading: routesLoading } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
|
||||
|
||||
const boats = boatsData?.items ?? []
|
||||
const routes = routesData?.items ?? []
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.boats_fleet_management()}
|
||||
description={m.boats_fleet_description()}
|
||||
action={
|
||||
canManage ? (
|
||||
<Group gap="sm">
|
||||
{activeTab === 'boats' && (
|
||||
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setCreateBoatOpen(true)}>
|
||||
{m.boats_add_boat()}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'routes' && (
|
||||
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setCreateRouteOpen(true)}>
|
||||
{m.boats_add_route()}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="boats" leftSection={<Sailboat size={16} />}>
|
||||
{m.boats_boats()}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="routes" leftSection={<RouteIcon size={16} />}>
|
||||
{m.boats_routes()}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="boats" pt="md">
|
||||
{boatsLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
) : boats.length === 0 ? (
|
||||
<Card padding="xl" radius="md" style={cardStyle}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.boats_no_boats()}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
|
||||
{boats.map((boat) => (
|
||||
<Card key={boat.boatId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{asI18n(boat.name)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={boat.isActive ? 'green' : 'gray'} variant="light" size="sm">
|
||||
{boat.isActive ? m.boats_active() : m.boats_inactive()}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<Tooltip label={m.boats_edit_boat()}>
|
||||
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingBoat(boat)}>
|
||||
<Pencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="lg" mt="md">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_passengers()}</Text>
|
||||
<Text size="sm">{boat.passengerCapacity}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_cargo_kg()}</Text>
|
||||
<Text size="sm">{asI18n(boat.cargoCapacityKg != null ? String(boat.cargoCapacityKg) : '---')}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{boat.notes && (
|
||||
<Text size="xs" c="dimmed" mt="sm" lineClamp={2}>
|
||||
{asI18n(boat.notes)}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="routes" pt="md">
|
||||
{routesLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
) : routes.length === 0 ? (
|
||||
<Card padding="xl" radius="md" style={cardStyle}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.boats_no_routes()}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
|
||||
{routes.map((route) => (
|
||||
<Card key={route.routeId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{asI18n(route.name)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={route.isActive ? 'green' : 'gray'} variant="light" size="sm">
|
||||
{route.isActive ? m.boats_active() : m.boats_inactive()}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<Tooltip label={m.boats_edit_route()}>
|
||||
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingRoute(route)}>
|
||||
<Pencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="lg" mt="md">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_origin()}</Text>
|
||||
<Text size="sm">{asI18n(route.origin)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">{asI18n(' -> ')}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_destination()}</Text>
|
||||
<Text size="sm">{asI18n(route.destination)}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<CreateBoatDrawer opened={createBoatOpen} onClose={() => setCreateBoatOpen(false)} />
|
||||
<EditBoatDrawer boat={editingBoat} onClose={() => setEditingBoat(null)} />
|
||||
<CreateRouteDrawer opened={createRouteOpen} onClose={() => setCreateRouteOpen(false)} />
|
||||
<EditRouteDrawer route={editingRoute} onClose={() => setEditingRoute(null)} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
128
apps/app/src/pages/_authenticated.boats.request.tsx
Normal file
128
apps/app/src/pages/_authenticated.boats.request.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { Stack, Card, Select, Textarea, NumberInput, Button, Group } from '@pikku/mantine/core'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/boats/request')({
|
||||
validateSearch: (search: Record<string, unknown>): { tripId?: string } => ({
|
||||
tripId: typeof search.tripId === 'string' ? search.tripId : undefined,
|
||||
}),
|
||||
component: RequestBoatSeatPage,
|
||||
})
|
||||
|
||||
function RequestBoatSeatPage() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
const { tripId: preselectedTripId } = Route.useSearch()
|
||||
|
||||
const [tripId, setTripId] = useState<string | null>(preselectedTripId ?? null)
|
||||
const [requestedSeats, setRequestedSeats] = useState<number>(1)
|
||||
const [cargoNotes, setCargoNotes] = useState('')
|
||||
const [specialRequirements, setSpecialRequirements] = useState('')
|
||||
|
||||
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
|
||||
const { data: tripsData } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
|
||||
|
||||
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
|
||||
|
||||
const tripOptions = (tripsData?.items ?? [])
|
||||
.filter((t) => t.status === 'open')
|
||||
.map((t) => {
|
||||
const route = routeMap.get(t.routeId)
|
||||
const routeLabel = route ? `${route.name} (${route.origin} - ${route.destination})` : 'Trip'
|
||||
const dateLabel = new Date(t.scheduledDepartureAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
return { value: t.tripId, label: `${routeLabel} - ${dateLabel}` }
|
||||
})
|
||||
|
||||
const mutation = usePikkuMutation('requestBoatSeat', {
|
||||
onSuccess: () => {
|
||||
notifications.show({ title: 'Seat requested', message: 'Your seat request has been submitted.', color: 'green' })
|
||||
navigate({ to: '/boats' })
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Something went wrong.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!tripId) {
|
||||
notifications.show({ title: 'Missing fields', message: 'Please select a trip.', color: 'red' })
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
tripId,
|
||||
requestedSeats,
|
||||
cargoNotes: cargoNotes || undefined,
|
||||
specialRequirements: specialRequirements || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader title={m.boats_request_seat_title()} description={m.boats_request_seat_description()} />
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
maxWidth: 560,
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={m.boats_trip()}
|
||||
placeholder={m.boats_select_trip()}
|
||||
data={tripOptions}
|
||||
value={tripId}
|
||||
onChange={setTripId}
|
||||
required
|
||||
searchable
|
||||
nothingFoundMessage={asI18n(tripOptions.length === 0 ? m.boats_no_open_trips() : m.boats_no_matching_trips())}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={m.boats_number_of_seats()}
|
||||
value={requestedSeats}
|
||||
onChange={(val) => setRequestedSeats(Number(val) || 1)}
|
||||
min={1}
|
||||
max={10}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.boats_cargo_notes()}
|
||||
placeholder={m.boats_describe_cargo()}
|
||||
value={cargoNotes}
|
||||
onChange={(e) => setCargoNotes(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.boats_special_requirements()}
|
||||
placeholder={m.boats_accessibility_needs()}
|
||||
value={specialRequirements}
|
||||
onChange={(e) => setSpecialRequirements(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" component={Link} to="/boats">
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.boats_request_seat()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
306
apps/app/src/pages/_authenticated.boats.trips.$tripId.tsx
Normal file
306
apps/app/src/pages/_authenticated.boats.trips.$tripId.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Stack,
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
Loader,
|
||||
Center,
|
||||
Table,
|
||||
Title,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Printer, Check, X } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { StatusBadge } from '@perauset/components'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/boats/trips/$tripId')({
|
||||
component: TripDetailPage,
|
||||
})
|
||||
|
||||
type SeatRequest = {
|
||||
requestId: string
|
||||
tripId: string
|
||||
userId: string
|
||||
status: string
|
||||
requestedSeats: number
|
||||
cargoNotes: string | null
|
||||
specialRequirements: string | null
|
||||
priorityReason: string | null
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | Date): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | Date): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return String(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
function RequestActions({ requestId }: { requestId: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['listBoatRequests'] })
|
||||
|
||||
const confirmMutation = usePikkuMutation('confirmBoatRequest', { onSuccess: invalidate })
|
||||
const declineMutation = usePikkuMutation('declineBoatRequest', { onSuccess: invalidate })
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
onClick={() => confirmMutation.mutate({ requestId })}
|
||||
loading={confirmMutation.isPending}
|
||||
aria-label={m.boats_confirm()}
|
||||
>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => declineMutation.mutate({ requestId })}
|
||||
loading={declineMutation.isPending}
|
||||
aria-label={m.boats_decline()}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function SeatRequestsSection({ seatRequests }: { seatRequests: SeatRequest[] }) {
|
||||
const canManage = usePermission('boats.manage')
|
||||
|
||||
const columns: Column<SeatRequest>[] = [
|
||||
{
|
||||
key: 'userId',
|
||||
label: m.boats_user(),
|
||||
render: (row) => (
|
||||
<Text size="sm" truncate>
|
||||
{asI18n(row.userId)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seats',
|
||||
label: m.boats_seats(),
|
||||
render: (row) => <Text size="sm">{row.requestedSeats}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: m.boats_status(),
|
||||
render: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: m.boats_requested(),
|
||||
render: (row) => <Text size="sm">{asI18n(formatDate(row.createdAt))}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'cargo',
|
||||
label: m.boats_cargo(),
|
||||
render: (row) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{asI18n(row.cargoNotes ?? '-')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
label: m.boats_actions(),
|
||||
render: (row: SeatRequest) =>
|
||||
row.status === 'submitted' || row.status === 'under_review' ? <RequestActions requestId={row.requestId} /> : null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.boats_seat_requests()}
|
||||
</Title>
|
||||
<DataTable
|
||||
data={seatRequests}
|
||||
columns={columns}
|
||||
rowKey={(row) => row.requestId}
|
||||
emptyMessage="No seat requests for this trip."
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function TripDetailPage() {
|
||||
useLocale()
|
||||
const { tripId } = Route.useParams()
|
||||
|
||||
const { data: tripsData, isLoading: tripsLoading } = usePikkuQuery('listBoatTrips', { limit: 50, offset: 0 })
|
||||
const { data: routesData } = usePikkuQuery('listBoatRoutes', { limit: 100, offset: 0 })
|
||||
const { data: boatsData } = usePikkuQuery('listBoats', { limit: 100, offset: 0 })
|
||||
|
||||
const routeMap = new Map((routesData?.items ?? []).map((r) => [r.routeId, r]))
|
||||
const boatMap = new Map((boatsData?.items ?? []).map((b) => [b.boatId, b]))
|
||||
|
||||
const { data: manifestData } = usePikkuQuery('getBoatManifest', { tripId }, { enabled: !!tripId })
|
||||
const { data: requestsData } = usePikkuQuery('listBoatRequests', { tripId, limit: 50, offset: 0 }, { enabled: !!tripId })
|
||||
|
||||
const trip = (tripsData?.items ?? []).find((t) => t.tripId === tripId) ?? null
|
||||
const seatRequests = (requestsData?.items ?? []) as SeatRequest[]
|
||||
const passengers = manifestData?.passengers ?? []
|
||||
|
||||
if (tripsLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!trip) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader title={m.boats_trip_not_found()} />
|
||||
<Card padding="xl" radius="md" style={cardStyle}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.boats_trip_not_found_message()}
|
||||
</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const route = routeMap.get(trip.routeId)
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.boats_trip_details()}
|
||||
description={asI18n(route ? `${route.name} (${route.origin} - ${route.destination})` : m.boats_trip_details())}
|
||||
action={
|
||||
trip.status === 'open' ? (
|
||||
<Button component={Link} to="/boats/request" search={{ tripId: trip.tripId } as never} color="teal" size="sm">
|
||||
{m.boats_request_seat()}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_status()}</Text>
|
||||
<StatusBadge status={trip.status} />
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_departure()}</Text>
|
||||
<Text size="sm">{asI18n(formatDateTime(trip.scheduledDepartureAt))}</Text>
|
||||
</div>
|
||||
{trip.scheduledArrivalAt && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_arrival()}</Text>
|
||||
<Text size="sm">{asI18n(formatDateTime(trip.scheduledArrivalAt))}</Text>
|
||||
</div>
|
||||
)}
|
||||
{trip.boatId && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.boats_boat()}</Text>
|
||||
<Text size="sm">{asI18n(boatMap.get(trip.boatId)?.name ?? m.boats_unknown_boat())}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
{trip.notes && (
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{asI18n(trip.notes)}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.boats_manifest()}
|
||||
</Title>
|
||||
<Button variant="light" color="teal" size="xs" leftSection={<Printer size={14} />} onClick={() => window.print()}>
|
||||
{m.boats_print_manifest()}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{passengers.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{m.boats_no_confirmed_passengers()}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.boats_name()}</Table.Th>
|
||||
<Table.Th>{m.boats_seats()}</Table.Th>
|
||||
<Table.Th>{m.boats_cargo_notes()}</Table.Th>
|
||||
<Table.Th>{m.boats_special_requirements()}</Table.Th>
|
||||
<Table.Th>{m.boats_status()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{passengers.map((p) => (
|
||||
<Table.Tr key={p.requestId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{asI18n(p.displayName || p.email || 'Passenger')}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{p.requestedSeats}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{asI18n(p.cargoNotes || '-')}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{asI18n(p.specialRequirements || '-')}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="green" variant="light" size="sm">{asI18n(p.status)}</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<SeatRequestsSection seatRequests={seatRequests} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
5
apps/app/src/pages/_authenticated.boats.tsx
Normal file
5
apps/app/src/pages/_authenticated.boats.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/boats')({
|
||||
component: Outlet,
|
||||
})
|
||||
899
apps/app/src/pages/_authenticated.finance.tsx
Normal file
899
apps/app/src/pages/_authenticated.finance.tsx
Normal file
@@ -0,0 +1,899 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Button,
|
||||
Loader,
|
||||
Center,
|
||||
Drawer,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Textarea,
|
||||
SimpleGrid,
|
||||
Table,
|
||||
} from '@pikku/mantine/core'
|
||||
import { DollarSign, Plus, Pencil, Link2 } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/finance')({
|
||||
component: FinancePage,
|
||||
})
|
||||
|
||||
interface FinanceRecord {
|
||||
financeId: string
|
||||
name: string
|
||||
description: string | null
|
||||
amount: number
|
||||
amountEgp: number | null
|
||||
currency: string
|
||||
recordType: string
|
||||
purchasedByUserId: string | null
|
||||
purchasedAt: string
|
||||
category: string
|
||||
createdByUserId: string
|
||||
}
|
||||
|
||||
interface FinanceLink {
|
||||
linkId: string
|
||||
financeId: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
const RECORD_TYPES = [
|
||||
{ value: 'expense', label: 'Expense' },
|
||||
{ value: 'income', label: 'Income' },
|
||||
{ value: 'reimbursement', label: 'Reimbursement' },
|
||||
]
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'operations', label: 'Operations' },
|
||||
{ value: 'kitchen', label: 'Kitchen' },
|
||||
{ value: 'maintenance', label: 'Maintenance' },
|
||||
{ value: 'transport', label: 'Transport' },
|
||||
{ value: 'retreat', label: 'Retreat' },
|
||||
{ value: 'housekeeping', label: 'Housekeeping' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: 'inventory_item', label: 'Inventory Item' },
|
||||
{ value: 'stay', label: 'Stay' },
|
||||
{ value: 'boat_trip', label: 'Boat Trip' },
|
||||
{ value: 'retreat', label: 'Retreat' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
]
|
||||
|
||||
const recordTypeBadgeColor: Record<string, string> = {
|
||||
expense: 'red',
|
||||
income: 'green',
|
||||
reimbursement: 'blue',
|
||||
}
|
||||
|
||||
const entityTypeBadgeColor: Record<string, string> = {
|
||||
inventory_item: 'orange',
|
||||
stay: 'blue',
|
||||
boat_trip: 'cyan',
|
||||
retreat: 'grape',
|
||||
task: 'teal',
|
||||
}
|
||||
|
||||
type Currency = 'EUR' | 'USD' | 'GBP' | 'EGP'
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: 'EUR', label: 'EUR (€)' },
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'GBP', label: 'GBP (£)' },
|
||||
{ value: 'EGP', label: 'EGP (ج.م)' },
|
||||
]
|
||||
|
||||
function AddRecordDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [currency, setCurrency] = useState<Currency>('EUR')
|
||||
const [recordType, setRecordType] = useState<string>('expense')
|
||||
const [category, setCategory] = useState<string>('operations')
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setAmount(0)
|
||||
setCurrency('EUR')
|
||||
setRecordType('expense')
|
||||
setCategory('operations')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createFinanceRecord', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
|
||||
notifications.show({
|
||||
title: 'Record created',
|
||||
message: 'Finance record has been added.',
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to create record.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name || !amount) {
|
||||
notifications.show({
|
||||
title: 'Missing fields',
|
||||
message: 'Name and amount are required.',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
name,
|
||||
description: description || undefined,
|
||||
amount,
|
||||
currency,
|
||||
recordType: recordType as 'expense' | 'income' | 'reimbursement',
|
||||
category: category as
|
||||
| 'operations'
|
||||
| 'kitchen'
|
||||
| 'maintenance'
|
||||
| 'transport'
|
||||
| 'retreat'
|
||||
| 'housekeeping'
|
||||
| 'other',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.finance_add_record()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.finance_name()}
|
||||
placeholder={asI18n('e.g. Office Supplies, Boat Fuel')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={m.finance_description()}
|
||||
placeholder={asI18n('Optional details')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={m.finance_amount()}
|
||||
value={amount}
|
||||
onChange={(val) => setAmount(Number(val) || 0)}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={m.finance_currency()}
|
||||
data={CURRENCY_OPTIONS}
|
||||
value={currency}
|
||||
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
label={m.finance_type()}
|
||||
data={RECORD_TYPES}
|
||||
value={recordType}
|
||||
onChange={(val) => setRecordType(val ?? 'expense')}
|
||||
/>
|
||||
<Select
|
||||
label={m.finance_category()}
|
||||
data={CATEGORIES}
|
||||
value={category}
|
||||
onChange={(val) => setCategory(val ?? 'operations')}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.finance_create_record()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function EditRecordDrawer({
|
||||
record,
|
||||
onClose,
|
||||
}: {
|
||||
record: FinanceRecord | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(record?.name ?? '')
|
||||
const [description, setDescription] = useState(record?.description ?? '')
|
||||
const [amount, setAmount] = useState<number>(record?.amount ?? 0)
|
||||
const [currency, setCurrency] = useState<Currency>((record?.currency as Currency) ?? 'EUR')
|
||||
const [recordType, setRecordType] = useState<string>(record?.recordType ?? 'expense')
|
||||
const [category, setCategory] = useState<string>(record?.category ?? 'operations')
|
||||
|
||||
const mutation = usePikkuMutation('updateFinanceRecord', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
|
||||
notifications.show({
|
||||
title: 'Record updated',
|
||||
message: 'Finance record has been updated.',
|
||||
color: 'green',
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to update record.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!record) return
|
||||
mutation.mutate({
|
||||
financeId: record.financeId,
|
||||
name,
|
||||
description: description || undefined,
|
||||
amount,
|
||||
currency,
|
||||
recordType: recordType as 'expense' | 'income' | 'reimbursement',
|
||||
category: category as
|
||||
| 'operations'
|
||||
| 'kitchen'
|
||||
| 'maintenance'
|
||||
| 'transport'
|
||||
| 'retreat'
|
||||
| 'housekeeping'
|
||||
| 'other',
|
||||
})
|
||||
}
|
||||
|
||||
if (!record) return null
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={m.finance_edit_record()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.finance_name()}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={m.finance_description()}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={m.finance_amount()}
|
||||
value={amount}
|
||||
onChange={(val) => setAmount(Number(val) || 0)}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
/>
|
||||
<Select
|
||||
label={m.finance_currency()}
|
||||
data={CURRENCY_OPTIONS}
|
||||
value={currency}
|
||||
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select
|
||||
label={m.finance_type()}
|
||||
data={RECORD_TYPES}
|
||||
value={recordType}
|
||||
onChange={(val) => setRecordType(val ?? 'expense')}
|
||||
/>
|
||||
<Select
|
||||
label={m.finance_category()}
|
||||
data={CATEGORIES}
|
||||
value={category}
|
||||
onChange={(val) => setCategory(val ?? 'operations')}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.finance_save_changes()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkDrawer({
|
||||
record,
|
||||
onClose,
|
||||
}: {
|
||||
record: FinanceRecord | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [entityType, setEntityType] = useState<string>('inventory_item')
|
||||
const [entityId, setEntityId] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const { data: linksData, isLoading: linksLoading } = usePikkuQuery(
|
||||
'listFinanceLinks',
|
||||
{ financeId: record?.financeId ?? '', limit: 50, offset: 0 },
|
||||
{ enabled: !!record },
|
||||
)
|
||||
|
||||
const existingLinks = (linksData?.items ?? []) as FinanceLink[]
|
||||
|
||||
const mutation = usePikkuMutation('linkFinanceRecord', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listFinanceLinks'] })
|
||||
notifications.show({
|
||||
title: 'Link created',
|
||||
message: 'Entity has been linked to this finance record.',
|
||||
color: 'green',
|
||||
})
|
||||
setEntityId('')
|
||||
setNotes('')
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to create link.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!record || !entityId) {
|
||||
notifications.show({
|
||||
title: 'Missing fields',
|
||||
message: 'Entity ID is required.',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
financeId: record.financeId,
|
||||
entityType: entityType as
|
||||
| 'inventory_item'
|
||||
| 'inventory_request'
|
||||
| 'stay'
|
||||
| 'boat_trip'
|
||||
| 'retreat'
|
||||
| 'task'
|
||||
| 'room',
|
||||
entityId,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (!record) return null
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={asI18n(`Link: ${record.name}`)}>
|
||||
<Stack gap="md">
|
||||
{linksLoading ? (
|
||||
<Center py="sm">
|
||||
<Loader color="teal" size="sm" />
|
||||
</Center>
|
||||
) : existingLinks.length > 0 ? (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{m.finance_existing_links()}
|
||||
</Text>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.finance_type()}</Table.Th>
|
||||
<Table.Th>{m.finance_reference()}</Table.Th>
|
||||
<Table.Th>{m.finance_notes()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{existingLinks.map((link) => (
|
||||
<Table.Tr key={link.linkId}>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
|
||||
>
|
||||
{asI18n(link.entityType.replace(/_/g, ' '))}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(link.notes || 'Linked')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(link.notes || '--')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.finance_no_links()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>
|
||||
{m.finance_add_link()}
|
||||
</Text>
|
||||
<Select
|
||||
label={m.finance_entity_type()}
|
||||
data={ENTITY_TYPES}
|
||||
value={entityType}
|
||||
onChange={(val) => setEntityType(val ?? 'inventory_item')}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.finance_reference_id()}
|
||||
description={asI18n('Enter the identifier of the entity to link (e.g. from the relevant list page)')}
|
||||
placeholder={asI18n('Entity reference')}
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={m.finance_notes()}
|
||||
placeholder={asI18n('Optional notes about this link')}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_close()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.finance_link_entity()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordLinkBadges({ financeId }: { financeId: string }) {
|
||||
const { data } = usePikkuQuery('listFinanceLinks', {
|
||||
financeId,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const links = (data?.items ?? []) as FinanceLink[]
|
||||
if (links.length === 0) return null
|
||||
|
||||
return (
|
||||
<Group gap={4} mt={4}>
|
||||
{links.map((link) => (
|
||||
<Badge
|
||||
key={link.linkId}
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
|
||||
>
|
||||
{asI18n(link.entityType.replace(/_/g, ' '))}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function FinancePage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('finance.manage')
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<FinanceRecord | null>(null)
|
||||
const [linkingRecord, setLinkingRecord] = useState<FinanceRecord | null>(null)
|
||||
const [filterCategory, setFilterCategory] = useState<string | null>(null)
|
||||
const [filterType, setFilterType] = useState<string | null>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: summary } = usePikkuQuery('getFinanceSummary', {})
|
||||
|
||||
const { data: ratesData } = usePikkuQuery('getExchangeRates', {})
|
||||
|
||||
const fetchRatesMutation = usePikkuMutation('fetchExchangeRates', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['getExchangeRates'] })
|
||||
notifications.show({
|
||||
title: 'Exchange rates updated',
|
||||
message: 'Latest rates fetched successfully.',
|
||||
color: 'green',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to fetch rates.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listFinanceRecords', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
...(filterCategory
|
||||
? {
|
||||
category: filterCategory as
|
||||
| 'operations'
|
||||
| 'kitchen'
|
||||
| 'maintenance'
|
||||
| 'transport'
|
||||
| 'retreat'
|
||||
| 'housekeeping'
|
||||
| 'other',
|
||||
}
|
||||
: {}),
|
||||
...(filterType
|
||||
? { recordType: filterType as 'expense' | 'income' | 'reimbursement' }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const items = (data?.items ?? []) as FinanceRecord[]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: Column<FinanceRecord>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: m.finance_name(),
|
||||
render: (record) => (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(record.name)}
|
||||
</Text>
|
||||
<RecordLinkBadges financeId={record.financeId} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: m.finance_amount(),
|
||||
render: (record) => (
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{asI18n(`${record.currency} ${record.amount.toFixed(2)}`)}
|
||||
</Text>
|
||||
{record.amountEgp != null && record.currency !== 'EGP' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`≈ EGP ${record.amountEgp.toFixed(2)}`)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'recordType',
|
||||
label: m.finance_type(),
|
||||
render: (record) => (
|
||||
<Badge color={recordTypeBadgeColor[record.recordType] ?? 'gray'} variant="light" size="sm">
|
||||
{asI18n(record.recordType)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: m.finance_category(),
|
||||
render: (record) => (
|
||||
<Badge color="teal" variant="outline" size="sm">
|
||||
{asI18n(record.category)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'purchasedAt',
|
||||
label: m.finance_date(),
|
||||
render: (record) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(new Date(record.purchasedAt).toLocaleDateString())}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
label: m.finance_actions(),
|
||||
render: (record: FinanceRecord) => (
|
||||
<Group gap={4}>
|
||||
<Tooltip label={m.finance_link_entity()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => setLinkingRecord(record)}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={m.finance_edit_record()}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
onClick={() => setEditingRecord(record)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.finance_title()}
|
||||
description={m.finance_subtitle()}
|
||||
action={
|
||||
canManage ? (
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
color="teal"
|
||||
radius="md"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
>
|
||||
{m.finance_add_record()}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{summary && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.finance_total_income()}
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="green">
|
||||
{asI18n(`$${summary.totalIncome.toFixed(2)}`)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.finance_total_expenses()}
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="red">
|
||||
{asI18n(`$${summary.totalExpenses.toFixed(2)}`)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.finance_net()}
|
||||
</Text>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={summary.totalIncome - summary.totalExpenses >= 0 ? 'teal' : 'red'}
|
||||
>
|
||||
{asI18n(`$${(summary.totalIncome - summary.totalExpenses).toFixed(2)}`)}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{summary.byCategory.length > 0 && (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
mb="sm"
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
|
||||
>
|
||||
{m.finance_by_category()}
|
||||
</Text>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
{summary.byCategory.map((cat) => (
|
||||
<Group key={cat.category} gap="xs">
|
||||
<Badge color="teal" variant="outline" size="sm">
|
||||
{asI18n(cat.category)}
|
||||
</Badge>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(`$${cat.total.toFixed(2)}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ratesData && ratesData.rates.length > 0 && (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<div>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
|
||||
>
|
||||
{asI18n(`${m.finance_exchange_rates()} (${m.finance_base()}: ${ratesData.base})`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`${m.finance_last_updated()}: ${ratesData.date}`)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => fetchRatesMutation.mutate({})}
|
||||
loading={fetchRatesMutation.isPending}
|
||||
>
|
||||
{m.finance_refresh_rates()}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
{ratesData.rates
|
||||
.filter((r) => r.targetCurrency !== ratesData.base)
|
||||
.map((r) => (
|
||||
<Group key={r.targetCurrency} gap="xs">
|
||||
<Badge color="blue" variant="outline" size="sm">
|
||||
{asI18n(r.targetCurrency)}
|
||||
</Badge>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(r.rate.toFixed(4))}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
<Select
|
||||
placeholder={asI18n('All categories')}
|
||||
data={[{ value: '', label: 'All categories' }, ...CATEGORIES]}
|
||||
value={filterCategory}
|
||||
onChange={(val) => setFilterCategory(val || null)}
|
||||
clearable
|
||||
size="sm"
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
placeholder={asI18n('All types')}
|
||||
data={[{ value: '', label: 'All types' }, ...RECORD_TYPES]}
|
||||
value={filterType}
|
||||
onChange={(val) => setFilterType(val || null)}
|
||||
clearable
|
||||
size="sm"
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" py="xl">
|
||||
<DollarSign size={48} strokeWidth={1} color="#aaa" />
|
||||
<Text c="dimmed">{m.finance_empty()}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.financeId}
|
||||
emptyMessage="No finance records found."
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddRecordDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
|
||||
<EditRecordDrawer record={editingRecord} onClose={() => setEditingRecord(null)} />
|
||||
<LinkDrawer record={linkingRecord} onClose={() => setLinkingRecord(null)} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
36
apps/app/src/pages/_authenticated.index.tsx
Normal file
36
apps/app/src/pages/_authenticated.index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Title, Text, SimpleGrid, Card, Group, Stack } from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import type { I18nNode } from '@pikku/react'
|
||||
import { BedDouble, CheckSquare, Sailboat, CalendarDays } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
useLocale()
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{m.dashboard_title()}</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<SummaryCard icon={BedDouble} label={m.dashboard_stays()} color="teal" />
|
||||
<SummaryCard icon={CheckSquare} label={m.dashboard_tasks()} color="blue" />
|
||||
<SummaryCard icon={Sailboat} label={m.dashboard_boats()} color="cyan" />
|
||||
<SummaryCard icon={CalendarDays} label={m.dashboard_retreats()} color="grape" />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ icon: Icon, label, color }: { icon: React.ComponentType<any>; label: I18nNode; color: string }) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group>
|
||||
<Icon size={24} color={`var(--mantine-color-${color}-6)`} />
|
||||
<Text fw={600}>{label}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
703
apps/app/src/pages/_authenticated.inventory.tsx
Normal file
703
apps/app/src/pages/_authenticated.inventory.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Button,
|
||||
Loader,
|
||||
Tabs,
|
||||
Center,
|
||||
Drawer,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Table,
|
||||
Textarea,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Package, Plus, AlertTriangle, Pencil, History, ClipboardCheck } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/inventory')({
|
||||
component: InventoryPage,
|
||||
})
|
||||
|
||||
type InventoryCategory = 'kitchen' | 'rooms' | 'equipment' | 'garden' | 'other'
|
||||
|
||||
interface InventoryItem {
|
||||
itemId: string
|
||||
name: string
|
||||
currentQuantity: number
|
||||
minimumQuantity: number
|
||||
unit: string
|
||||
isActive: boolean
|
||||
category: string
|
||||
subcategory: string | null
|
||||
}
|
||||
|
||||
interface InventoryTransaction {
|
||||
transactionId: string
|
||||
itemId: string
|
||||
quantityChange: number
|
||||
reason: string
|
||||
notes: string | null
|
||||
createdByUserId: string
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'kitchen', label: m.inventory_cat_kitchen() },
|
||||
{ value: 'rooms', label: m.inventory_cat_rooms() },
|
||||
{ value: 'equipment', label: m.inventory_cat_equipment() },
|
||||
{ value: 'garden', label: m.inventory_cat_garden() },
|
||||
{ value: 'other', label: m.inventory_cat_other() },
|
||||
]
|
||||
|
||||
function AddItemDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [unit, setUnit] = useState('')
|
||||
const [currentQuantity, setCurrentQuantity] = useState<number>(0)
|
||||
const [minimumQuantity, setMinimumQuantity] = useState<number>(0)
|
||||
const [category, setCategory] = useState<InventoryCategory>('kitchen')
|
||||
|
||||
const mutation = usePikkuMutation('createInventoryItem', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
|
||||
notifications.show({
|
||||
title: m.inventory_item_created_title(),
|
||||
message: m.inventory_item_created_message(),
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.inventory_error_title(),
|
||||
message: err.message || m.inventory_error_create(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setUnit('')
|
||||
setCurrentQuantity(0)
|
||||
setMinimumQuantity(0)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name || !unit) {
|
||||
notifications.show({
|
||||
title: m.inventory_missing_fields_title(),
|
||||
message: m.inventory_missing_fields_message(),
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
mutation.mutate({ name, unit, currentQuantity, minimumQuantity, category })
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.inventory_add_item_title()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.inventory_field_name()}
|
||||
placeholder={m.inventory_name_placeholder()}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={m.inventory_field_unit()}
|
||||
placeholder={m.inventory_unit_placeholder()}
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label={m.inventory_current_quantity()}
|
||||
value={currentQuantity}
|
||||
onChange={(val) => setCurrentQuantity(Number(val) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label={m.inventory_minimum_quantity()}
|
||||
description={m.inventory_alert_threshold()}
|
||||
value={minimumQuantity}
|
||||
onChange={(val) => setMinimumQuantity(Number(val) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
<Select
|
||||
label={m.inventory_category()}
|
||||
data={CATEGORY_OPTIONS}
|
||||
value={category}
|
||||
onChange={(val) => setCategory((val as InventoryCategory) || 'kitchen')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.inventory_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.inventory_create_item()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function EditItemDrawer({ item, onClose }: { item: InventoryItem | null; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(item?.name ?? '')
|
||||
const [unit, setUnit] = useState(item?.unit ?? '')
|
||||
const [currentQuantity, setCurrentQuantity] = useState<number>(item?.currentQuantity ?? 0)
|
||||
const [minimumQuantity, setMinimumQuantity] = useState<number>(item?.minimumQuantity ?? 0)
|
||||
|
||||
const mutation = usePikkuMutation('updateInventoryItem', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
|
||||
notifications.show({
|
||||
title: m.inventory_item_updated_title(),
|
||||
message: m.inventory_item_updated_message(),
|
||||
color: 'green',
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.inventory_error_title(),
|
||||
message: err.message || m.inventory_error_update(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!item) return
|
||||
mutation.mutate({
|
||||
itemId: item.itemId,
|
||||
name,
|
||||
unit,
|
||||
currentQuantity,
|
||||
minimumQuantity,
|
||||
})
|
||||
}
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={!!item} onClose={onClose} title={m.inventory_edit_item_title()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.inventory_field_name()}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={m.inventory_field_unit()}
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label={m.inventory_current_quantity()}
|
||||
value={currentQuantity}
|
||||
onChange={(val) => setCurrentQuantity(Number(val) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label={m.inventory_minimum_quantity()}
|
||||
description={m.inventory_alert_threshold()}
|
||||
value={minimumQuantity}
|
||||
onChange={(val) => setMinimumQuantity(Number(val) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.inventory_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.inventory_save_changes()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryDrawer({ item, onClose }: { item: InventoryItem | null; onClose: () => void }) {
|
||||
const { data, isLoading } = usePikkuQuery(
|
||||
'listInventoryTransactions',
|
||||
{ itemId: item?.itemId ?? '', limit: 50, offset: 0 },
|
||||
{ enabled: !!item }
|
||||
)
|
||||
|
||||
const transactions = (data?.items ?? []) as InventoryTransaction[]
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
position="right"
|
||||
size="lg"
|
||||
opened={!!item}
|
||||
onClose={onClose}
|
||||
title={m.inventory_history_title({ name: item.name })}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" size="sm" />
|
||||
</Center>
|
||||
) : transactions.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
{m.inventory_no_history()}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.inventory_col_date()}</Table.Th>
|
||||
<Table.Th>{m.inventory_col_change()}</Table.Th>
|
||||
<Table.Th>{m.inventory_col_reason()}</Table.Th>
|
||||
<Table.Th>{m.inventory_col_notes()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{transactions.map((tx) => (
|
||||
<Table.Tr key={tx.transactionId}>
|
||||
<Table.Td>
|
||||
<Text size="xs">{asI18n(new Date(tx.createdAt).toLocaleString())}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600} c={tx.quantityChange >= 0 ? 'green' : 'red'}>
|
||||
{asI18n(`${tx.quantityChange >= 0 ? '+' : ''}${tx.quantityChange}`)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{asI18n(tx.reason)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{asI18n(tx.notes || '--')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function StocktakeDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
items,
|
||||
}: {
|
||||
opened: boolean
|
||||
onClose: () => void
|
||||
items: InventoryItem[]
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>(() => {
|
||||
const q: Record<string, number> = {}
|
||||
for (const item of items) {
|
||||
q[item.itemId] = item.currentQuantity
|
||||
}
|
||||
return q
|
||||
})
|
||||
|
||||
const mutation = usePikkuMutation('submitStocktake', {
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
|
||||
notifications.show({
|
||||
title: m.inventory_stocktake_submitted_title(),
|
||||
message: m.inventory_stocktake_submitted_message({ count: result.adjustedCount }),
|
||||
color: 'green',
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.inventory_error_title(),
|
||||
message: err.message || m.inventory_error_stocktake(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const stocktakeItems = items.map((item) => ({
|
||||
itemId: item.itemId,
|
||||
actualQuantity: quantities[item.itemId] ?? item.currentQuantity,
|
||||
}))
|
||||
mutation.mutate({ items: stocktakeItems })
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="lg" opened={opened} onClose={onClose} title={m.inventory_stocktake_title()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
{m.inventory_stocktake_intro()}
|
||||
</Text>
|
||||
{items.map((item) => (
|
||||
<Group key={item.itemId} justify="space-between" align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(item.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.inventory_stocktake_current({ quantity: item.currentQuantity, unit: item.unit })}
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
w={120}
|
||||
size="sm"
|
||||
value={quantities[item.itemId] ?? item.currentQuantity}
|
||||
onChange={(val) =>
|
||||
setQuantities((prev) => ({ ...prev, [item.itemId]: Number(val) || 0 }))
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.inventory_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.inventory_submit_stocktake()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestItemDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [itemName, setItemName] = useState('')
|
||||
const [quantity, setQuantity] = useState<number | string>('')
|
||||
const [unit, setUnit] = useState('')
|
||||
const [purpose, setPurpose] = useState('')
|
||||
|
||||
const mutation = usePikkuMutation('requestInventory', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listInventory'] })
|
||||
notifications.show({
|
||||
title: m.inventory_request_submitted_title(),
|
||||
message: m.inventory_request_submitted_message(),
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.inventory_error_title(),
|
||||
message: err.message || m.inventory_error_request(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setItemName('')
|
||||
setQuantity('')
|
||||
setUnit('')
|
||||
setPurpose('')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
...(itemName ? { itemName } : {}),
|
||||
quantity: Number(quantity),
|
||||
unit,
|
||||
...(purpose ? { purpose } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.inventory_request_item_title()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.inventory_field_name()}
|
||||
placeholder={m.inventory_request_name_placeholder()}
|
||||
required
|
||||
value={itemName}
|
||||
onChange={(e) => setItemName(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={m.inventory_request_quantity()}
|
||||
placeholder={m.inventory_request_quantity_placeholder()}
|
||||
required
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.inventory_field_unit()}
|
||||
placeholder={m.inventory_request_unit_placeholder()}
|
||||
required
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label={m.inventory_request_purpose()}
|
||||
placeholder={m.inventory_request_purpose_placeholder()}
|
||||
minRows={2}
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.inventory_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.inventory_submit_request()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryPage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('inventory.manage')
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const [requestDrawerOpen, setRequestDrawerOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null)
|
||||
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null)
|
||||
const [stocktakeOpen, setStocktakeOpen] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<string>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (['all', 'kitchen', 'rooms', 'equipment', 'garden', 'other'].includes(hash)) return hash
|
||||
}
|
||||
return 'all'
|
||||
})
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listInventory', {
|
||||
activeOnly: true,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const allItems = (data?.items ?? []) as InventoryItem[]
|
||||
const items =
|
||||
activeCategory === 'all' ? allItems : allItems.filter((i) => i.category === activeCategory)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: Column<InventoryItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: m.inventory_col_item(),
|
||||
render: (item) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(item.name)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
label: m.inventory_col_quantity(),
|
||||
render: (item) => {
|
||||
const isLow = item.currentQuantity <= item.minimumQuantity
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<Text size="sm">{item.currentQuantity}</Text>
|
||||
{isLow && <AlertTriangle size={14} style={{ color: '#e67700' }} />}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'unit',
|
||||
label: m.inventory_col_unit(),
|
||||
render: (item) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(item.unit)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: m.inventory_col_status(),
|
||||
render: (item) => {
|
||||
const isLow = item.currentQuantity <= item.minimumQuantity
|
||||
return isLow ? (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{m.inventory_low_stock()}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{m.inventory_in_stock()}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
label: m.inventory_col_actions(),
|
||||
render: (item: InventoryItem) => (
|
||||
<Group gap={4}>
|
||||
<Tooltip label={m.inventory_transaction_history()}>
|
||||
<ActionIcon variant="light" color="gray" size="sm" onClick={() => setHistoryItem(item)}>
|
||||
<History size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={m.inventory_edit_item()}>
|
||||
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingItem(item)}>
|
||||
<Pencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.inventory_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.inventory_description()}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Button onClick={() => setRequestDrawerOpen(true)} variant="light" color="teal" radius="md">
|
||||
{m.inventory_request_item()}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
leftSection={<ClipboardCheck size={16} />}
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
onClick={() => setStocktakeOpen(true)}
|
||||
>
|
||||
{m.inventory_stocktake()}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
color="teal"
|
||||
radius="md"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
>
|
||||
{m.inventory_add_item()}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={activeCategory}
|
||||
onChange={(val) => {
|
||||
setActiveCategory(val || 'all')
|
||||
window.history.replaceState(null, '', `#${val || 'all'}`)
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="all">{m.inventory_tab_all({ count: allItems.length })}</Tabs.Tab>
|
||||
<Tabs.Tab value="kitchen">
|
||||
{m.inventory_tab_kitchen({ count: allItems.filter((i) => i.category === 'kitchen').length })}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="rooms">
|
||||
{m.inventory_tab_rooms({ count: allItems.filter((i) => i.category === 'rooms').length })}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="equipment">
|
||||
{m.inventory_tab_equipment({ count: allItems.filter((i) => i.category === 'equipment').length })}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="garden">
|
||||
{m.inventory_tab_garden({ count: allItems.filter((i) => i.category === 'garden').length })}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="other">
|
||||
{m.inventory_tab_other({ count: allItems.filter((i) => i.category === 'other').length })}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" py="xl">
|
||||
<Package size={48} style={{ color: '#aaa' }} />
|
||||
<Text c="dimmed">{m.inventory_no_items()}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
rowKey={(item) => item.itemId}
|
||||
emptyMessage={m.inventory_no_items()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddItemDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
|
||||
<EditItemDrawer item={editingItem} onClose={() => setEditingItem(null)} />
|
||||
<HistoryDrawer item={historyItem} onClose={() => setHistoryItem(null)} />
|
||||
<StocktakeDrawer opened={stocktakeOpen} onClose={() => setStocktakeOpen(false)} items={items} />
|
||||
<RequestItemDrawer opened={requestDrawerOpen} onClose={() => setRequestDrawerOpen(false)} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
163
apps/app/src/pages/_authenticated.kitchen.dietary.tsx
Normal file
163
apps/app/src/pages/_authenticated.kitchen.dietary.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
Button,
|
||||
Stack,
|
||||
Group,
|
||||
Alert,
|
||||
SimpleGrid,
|
||||
} from '@pikku/mantine/core'
|
||||
import { AlertCircle, Check } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { m } from '@/i18n/messages'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/kitchen/dietary')({
|
||||
component: DietaryProfilePage,
|
||||
})
|
||||
|
||||
function DietaryProfilePage() {
|
||||
useLocale()
|
||||
const [isVegan, setIsVegan] = useState(false)
|
||||
const [isVegetarian, setIsVegetarian] = useState(false)
|
||||
const [isGlutenFree, setIsGlutenFree] = useState(false)
|
||||
const [isDairyFree, setIsDairyFree] = useState(false)
|
||||
const [hasNutAllergy, setHasNutAllergy] = useState(false)
|
||||
const [allergyNotes, setAllergyNotes] = useState('')
|
||||
const [otherRequirements, setOtherRequirements] = useState('')
|
||||
const [dislikes, setDislikes] = useState('')
|
||||
|
||||
const mutation = usePikkuMutation('upsertDietaryProfile')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
isVegan,
|
||||
isVegetarian,
|
||||
isGlutenFree,
|
||||
isDairyFree,
|
||||
hasNutAllergy,
|
||||
...(allergyNotes ? { allergyNotes } : {}),
|
||||
...(otherRequirements ? { otherRequirements } : {}),
|
||||
...(dislikes ? { dislikes } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.kitchen_dietary_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.kitchen_dietary_description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
maw={600}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
{mutation.isError && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
|
||||
{asI18n(mutation.error?.message ?? m.kitchen_dietary_error())}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutation.isSuccess && (
|
||||
<Alert icon={<Check size={16} />} color="green" variant="light">
|
||||
{m.kitchen_dietary_success()}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={500} style={{ color: '#3D2B1F' }}>
|
||||
{m.kitchen_dietary_preferences()}
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
|
||||
<Checkbox
|
||||
label={m.kitchen_dietary_vegan()}
|
||||
checked={isVegan}
|
||||
onChange={(e) => setIsVegan(e.currentTarget.checked)}
|
||||
color="teal"
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.kitchen_dietary_vegetarian()}
|
||||
checked={isVegetarian}
|
||||
onChange={(e) => setIsVegetarian(e.currentTarget.checked)}
|
||||
color="teal"
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.kitchen_dietary_gluten_free()}
|
||||
checked={isGlutenFree}
|
||||
onChange={(e) => setIsGlutenFree(e.currentTarget.checked)}
|
||||
color="teal"
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.kitchen_dietary_dairy_free()}
|
||||
checked={isDairyFree}
|
||||
onChange={(e) => setIsDairyFree(e.currentTarget.checked)}
|
||||
color="teal"
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.kitchen_dietary_nut_allergy()}
|
||||
checked={hasNutAllergy}
|
||||
onChange={(e) => setHasNutAllergy(e.currentTarget.checked)}
|
||||
color="red"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
label={m.kitchen_dietary_allergy_notes()}
|
||||
placeholder={m.kitchen_dietary_allergy_notes_placeholder()}
|
||||
minRows={2}
|
||||
value={allergyNotes}
|
||||
onChange={(e) => setAllergyNotes(e.currentTarget.value)}
|
||||
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.kitchen_dietary_other_requirements()}
|
||||
placeholder={m.kitchen_dietary_other_requirements_placeholder()}
|
||||
minRows={2}
|
||||
value={otherRequirements}
|
||||
onChange={(e) => setOtherRequirements(e.currentTarget.value)}
|
||||
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.kitchen_dietary_dislikes()}
|
||||
placeholder={m.kitchen_dietary_dislikes_placeholder()}
|
||||
minRows={2}
|
||||
value={dislikes}
|
||||
onChange={(e) => setDislikes(e.currentTarget.value)}
|
||||
styles={{ label: { fontWeight: 500, color: '#3D2B1F' } }}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button component={Link} to="/kitchen" variant="subtle" color="gray">
|
||||
{m.kitchen_dietary_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
|
||||
{m.kitchen_dietary_save()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
265
apps/app/src/pages/_authenticated.kitchen.index.tsx
Normal file
265
apps/app/src/pages/_authenticated.kitchen.index.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
Loader,
|
||||
Center,
|
||||
Drawer,
|
||||
Select,
|
||||
NumberInput,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from '@pikku/mantine/core'
|
||||
import { UtensilsCrossed, Plus } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { useState } from 'react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { m } from '@/i18n/messages'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/kitchen/')({
|
||||
component: KitchenPage,
|
||||
})
|
||||
|
||||
const mealTypeIcon: Record<string, string> = {
|
||||
breakfast: '\u{1F305}',
|
||||
lunch: '\u{2600}️',
|
||||
dinner: '\u{1F319}',
|
||||
snack: '\u{1F34E}',
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
planned: 'blue',
|
||||
preparing: 'orange',
|
||||
served: 'green',
|
||||
cancelled: 'red',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, () => string> = {
|
||||
planned: () => m.kitchen_status_planned(),
|
||||
preparing: () => m.kitchen_status_preparing(),
|
||||
served: () => m.kitchen_status_served(),
|
||||
cancelled: () => m.kitchen_status_cancelled(),
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function KitchenPage() {
|
||||
useLocale()
|
||||
const queryClient = useQueryClient()
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
|
||||
const [serviceType, setServiceType] = useState<string | null>(null)
|
||||
const [serviceDate, setServiceDate] = useState('')
|
||||
const [plannedHeadcount, setPlannedHeadcount] = useState<number | string>('')
|
||||
const [menuNotes, setMenuNotes] = useState('')
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listMealServices', { limit: 50, offset: 0 })
|
||||
|
||||
const createMutation = usePikkuMutation('createMealService', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listMealServices'] })
|
||||
notifications.show({
|
||||
title: m.kitchen_meal_created_title(),
|
||||
message: m.kitchen_meal_created_message(),
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
close()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.kitchen_error_title(),
|
||||
message: err.message || m.kitchen_error_create_meal(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
setServiceType(null)
|
||||
setServiceDate('')
|
||||
setPlannedHeadcount('')
|
||||
setMenuNotes('')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!serviceType || !serviceDate) return
|
||||
createMutation.mutate({
|
||||
serviceType: serviceType as 'breakfast' | 'lunch' | 'dinner' | 'snack',
|
||||
serviceDate: new Date(serviceDate).toISOString(),
|
||||
...(plannedHeadcount !== '' ? { plannedHeadcount: Number(plannedHeadcount) } : {}),
|
||||
...(menuNotes.trim() ? { menuNotes: menuNotes.trim() } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const meals = data?.items ?? []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.kitchen_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.kitchen_description()}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Button leftSection={<Plus size={16} />} color="teal" radius="md" onClick={open}>
|
||||
{m.kitchen_create_meal()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{meals.length === 0 ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<UtensilsCrossed size={48} style={{ color: '#aaa', marginBottom: 8 }} />
|
||||
<Text c="dimmed">{m.kitchen_no_meals()}</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{meals.map((meal) => (
|
||||
<Card
|
||||
key={meal.serviceId}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
transition: 'transform 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="xs">
|
||||
<Text size="lg">{asI18n(mealTypeIcon[meal.serviceType] ?? '\u{1F37D}️')}</Text>
|
||||
<Title
|
||||
order={4}
|
||||
style={{
|
||||
fontFamily: '"Cormorant", serif',
|
||||
color: '#3D2B1F',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{asI18n(meal.serviceType)}
|
||||
</Title>
|
||||
</Group>
|
||||
<Badge color={statusColor[meal.status] ?? 'gray'} variant="light" size="sm">
|
||||
{asI18n(STATUS_LABEL[meal.status]?.() ?? meal.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(formatDate(meal.serviceDate))}
|
||||
</Text>
|
||||
|
||||
{meal.plannedHeadcount != null && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.kitchen_expected_headcount({ count: meal.plannedHeadcount })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{meal.menuNotes && (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{asI18n(meal.menuNotes)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Drawer opened={opened} onClose={close} title={m.kitchen_create_meal_service()} position="right" size="md">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={m.kitchen_service_type()}
|
||||
placeholder={m.kitchen_select_meal_type()}
|
||||
data={[
|
||||
{ value: 'breakfast', label: m.kitchen_meal_type_breakfast() },
|
||||
{ value: 'lunch', label: m.kitchen_meal_type_lunch() },
|
||||
{ value: 'dinner', label: m.kitchen_meal_type_dinner() },
|
||||
{ value: 'snack', label: m.kitchen_meal_type_snack() },
|
||||
]}
|
||||
value={serviceType}
|
||||
onChange={setServiceType}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.kitchen_service_date()}
|
||||
type="date"
|
||||
value={serviceDate}
|
||||
onChange={(e) => setServiceDate(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={m.kitchen_planned_headcount()}
|
||||
placeholder={m.kitchen_planned_headcount_placeholder()}
|
||||
min={1}
|
||||
value={plannedHeadcount}
|
||||
onChange={setPlannedHeadcount}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.kitchen_menu_notes()}
|
||||
placeholder={m.kitchen_menu_notes_placeholder()}
|
||||
rows={4}
|
||||
value={menuNotes}
|
||||
onChange={(e) => setMenuNotes(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={close}>
|
||||
{m.kitchen_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={handleSubmit}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!serviceType || !serviceDate}
|
||||
>
|
||||
{m.kitchen_create_meal()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
apps/app/src/pages/_authenticated.kitchen.tsx
Normal file
5
apps/app/src/pages/_authenticated.kitchen.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/kitchen')({
|
||||
component: Outlet,
|
||||
})
|
||||
274
apps/app/src/pages/_authenticated.my.profile.tsx
Normal file
274
apps/app/src/pages/_authenticated.my.profile.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
TextInput,
|
||||
Button,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
SimpleGrid,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MessageCircle,
|
||||
Salad,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useSessionUser } from '@/lib/auth-context'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/my/profile')({
|
||||
component: MyProfilePage,
|
||||
})
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
maxWidth: 'calc(40rem * var(--mantine-scale))',
|
||||
}
|
||||
|
||||
const labelStyle = { label: { fontWeight: 500, color: '#3D2B1F' } }
|
||||
|
||||
function MyProfilePage() {
|
||||
useLocale()
|
||||
const sessionUser = useSessionUser()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const userId = sessionUser?.id
|
||||
|
||||
const { data: user, isLoading } = usePikkuQuery(
|
||||
'getUser',
|
||||
{ userId: userId! },
|
||||
{ enabled: !!userId },
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [mobileNumber, setMobileNumber] = useState('')
|
||||
const [whatsappNumber, setWhatsappNumber] = useState('')
|
||||
const [formInitialized, setFormInitialized] = useState(false)
|
||||
|
||||
if (user && !formInitialized) {
|
||||
setName(user.name ?? '')
|
||||
setDisplayName(user.displayName ?? '')
|
||||
setMobileNumber(user.mobileNumber ?? '')
|
||||
setWhatsappNumber(user.whatsappNumber ?? '')
|
||||
setFormInitialized(true)
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('updateUserProfile', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['getUser'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (!sessionUser) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_profile_title()}
|
||||
</Title>
|
||||
<Text c="dimmed">{m.my_sign_in_to_view()}</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
userId: userId!,
|
||||
name: name || undefined,
|
||||
displayName: displayName || undefined,
|
||||
mobileNumber: mobileNumber || undefined,
|
||||
whatsappNumber: whatsappNumber || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const fullName =
|
||||
user?.displayName ??
|
||||
user?.name ??
|
||||
sessionUser?.name ??
|
||||
'User'
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_profile_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.my_profile_description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<div>
|
||||
<Group gap={6} mb={4}>
|
||||
<ThemeIcon size="sm" variant="transparent" color="teal">
|
||||
<User size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.my_field_name()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" pl={26}>
|
||||
{asI18n(fullName)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Group gap={6} mb={4}>
|
||||
<ThemeIcon size="sm" variant="transparent" color="teal">
|
||||
<Mail size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.my_field_email()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" pl={26}>
|
||||
{asI18n(user?.email ?? sessionUser?.email ?? '--')}
|
||||
</Text>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>
|
||||
{m.my_field_roles()}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{(user?.memberRoles ?? []).map((role: string) => (
|
||||
<Badge key={role} color="teal" variant="light" size="sm">
|
||||
{asI18n(role.replace(/_/g, ' '))}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" radius="md" maw={640} style={cardStyle}>
|
||||
<Title order={4} mb="md" style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_edit_profile()}
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
{mutation.isError && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
|
||||
{mutation.error?.message
|
||||
? asI18n(mutation.error.message)
|
||||
: m.my_update_failed()}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutation.isSuccess && (
|
||||
<Alert icon={<Check size={16} />} color="green" variant="light">
|
||||
{m.my_update_success()}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={m.profile_name()}
|
||||
placeholder={m.profile_name_placeholder()}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.my_display_name()}
|
||||
placeholder={m.my_display_name_placeholder()}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label={m.my_mobile_number()}
|
||||
placeholder={m.my_phone_placeholder()}
|
||||
value={mobileNumber}
|
||||
onChange={(e) => setMobileNumber(e.currentTarget.value)}
|
||||
leftSection={<Phone size={16} />}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.my_whatsapp_number()}
|
||||
placeholder={m.my_phone_placeholder()}
|
||||
value={whatsappNumber}
|
||||
onChange={(e) => setWhatsappNumber(e.currentTarget.value)}
|
||||
leftSection={<MessageCircle size={16} />}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
|
||||
{m.my_save_profile()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
component={Link}
|
||||
to={'/kitchen/dietary' as any}
|
||||
style={{
|
||||
...cardStyle,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={48} variant="light" color="teal" radius="xl">
|
||||
<Salad size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text
|
||||
fw={600}
|
||||
style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}
|
||||
>
|
||||
{m.my_dietary_profile()}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.my_dietary_profile_description()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<ThemeIcon size="md" variant="transparent" color="gray">
|
||||
<ChevronRight size={20} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
237
apps/app/src/pages/_authenticated.my.stays.tsx
Normal file
237
apps/app/src/pages/_authenticated.my.stays.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Stack,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Home, FileText, CalendarDays, NotebookPen } from 'lucide-react'
|
||||
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { StatusBadge } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n, type I18nNode } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/my/stays')({
|
||||
component: MyStaysPage,
|
||||
})
|
||||
|
||||
interface StayRequest {
|
||||
requestId: string
|
||||
requestType: string
|
||||
requestedStartAt: string | Date
|
||||
requestedEndAt: string | Date
|
||||
status: string
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
interface Stay {
|
||||
stayId: string
|
||||
stayType: string
|
||||
startAt: string | Date
|
||||
endAt: string | Date
|
||||
status: string
|
||||
}
|
||||
|
||||
function formatDateRange(startStr: string | Date, endStr: string | Date): string {
|
||||
try {
|
||||
const start = new Date(startStr)
|
||||
const end = new Date(endStr)
|
||||
const startFormatted = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
const sameYear = start.getFullYear() === end.getFullYear()
|
||||
const endFormatted = end.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
if (sameYear) {
|
||||
return `${startFormatted} – ${endFormatted}`
|
||||
}
|
||||
const startWithYear = start.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
return `${startWithYear} – ${endFormatted}`
|
||||
} catch {
|
||||
return `${String(startStr)} - ${String(endStr)}`
|
||||
}
|
||||
}
|
||||
|
||||
function formatType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
maxWidth: 'calc(40rem * var(--mantine-scale))',
|
||||
}
|
||||
|
||||
function StayRequestCard({ request }: { request: StayRequest }) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text fw={600} style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}>
|
||||
{asI18n(formatType(request.requestType))}
|
||||
</Text>
|
||||
<StatusBadge status={request.status} />
|
||||
</Group>
|
||||
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size="sm" variant="transparent" color="teal">
|
||||
<CalendarDays size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(formatDateRange(request.requestedStartAt, request.requestedEndAt))}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{request.notes && (
|
||||
<Group gap={6} align="flex-start">
|
||||
<ThemeIcon size="sm" variant="transparent" color="gray">
|
||||
<NotebookPen size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{asI18n(request.notes)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function StayCard({ stay }: { stay: Stay }) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text fw={600} style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}>
|
||||
{asI18n(formatType(stay.stayType))}
|
||||
</Text>
|
||||
<StatusBadge status={stay.status} />
|
||||
</Group>
|
||||
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size="sm" variant="transparent" color="teal">
|
||||
<CalendarDays size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(formatDateRange(stay.startAt, stay.endAt))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ message, icon }: { message: I18nNode; icon: React.ReactNode }) {
|
||||
return (
|
||||
<Card padding="xl" radius="md" style={{ ...cardStyle, textAlign: 'center' as const }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MyStaysPage() {
|
||||
useLocale()
|
||||
|
||||
const { data: requestsData, isLoading: requestsLoading } = usePikkuQuery('listStayRequests', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const { data: staysData, isLoading: staysLoading } = usePikkuQuery('listStays', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const stayRequests = requestsData?.items ?? []
|
||||
const stays = staysData?.items ?? []
|
||||
|
||||
if (requestsLoading || staysLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_stays_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.my_stays_description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size="md" variant="light" color="teal" radius="xl">
|
||||
<FileText size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_requests()}
|
||||
</Title>
|
||||
{stayRequests.length > 0 && (
|
||||
<Badge size="sm" color="teal" variant="light">
|
||||
{stayRequests.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{stayRequests.length === 0 ? (
|
||||
<EmptyState message={m.my_no_requests()} icon={<FileText size={24} />} />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{stayRequests.map((request) => (
|
||||
<StayRequestCard key={request.requestId} request={request} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size="md" variant="light" color="teal" radius="xl">
|
||||
<Home size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_active_stays()}
|
||||
</Title>
|
||||
{stays.length > 0 && (
|
||||
<Badge size="sm" color="teal" variant="light">
|
||||
{stays.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{stays.length === 0 ? (
|
||||
<EmptyState message={m.my_no_active_stays()} icon={<Home size={24} />} />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{stays.map((stay) => (
|
||||
<StayCard key={stay.stayId} stay={stay} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
220
apps/app/src/pages/_authenticated.my.tasks.tsx
Normal file
220
apps/app/src/pages/_authenticated.my.tasks.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Stack,
|
||||
Badge,
|
||||
Group,
|
||||
Loader,
|
||||
Center,
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
} from '@pikku/mantine/core'
|
||||
import { MapPin, Clock, CheckSquare, Smile } from 'lucide-react'
|
||||
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { TaskActions } from '@/components/task-actions'
|
||||
import { m, mKey } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n, type I18nNode } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/my/tasks')({
|
||||
component: MyTasksPage,
|
||||
})
|
||||
|
||||
const STATUS_CONFIG: Record<string, { labelKey: string; color: string }> = {
|
||||
in_progress: { labelKey: 'my.tasks_in_progress', color: 'orange' },
|
||||
assigned: { labelKey: 'my.tasks_assigned', color: 'blue' },
|
||||
open: { labelKey: 'my.tasks_open', color: 'teal' },
|
||||
blocked: { labelKey: 'my.tasks_blocked', color: 'red' },
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
housekeeping: 'pink',
|
||||
kitchen: 'yellow',
|
||||
maintenance: 'orange',
|
||||
operations: 'blue',
|
||||
retreat: 'violet',
|
||||
transport: 'cyan',
|
||||
}
|
||||
|
||||
const DISPLAY_STATUSES = ['in_progress', 'assigned', 'open', 'blocked']
|
||||
|
||||
interface TaskItem {
|
||||
taskId: string
|
||||
templateId: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
category: string
|
||||
status: string
|
||||
location: string | null
|
||||
scheduledStartAt: string | Date | null
|
||||
scheduledEndAt: string | Date | null
|
||||
createdByUserId: string
|
||||
}
|
||||
|
||||
function formatTime(val: string | Date | null): string | null {
|
||||
if (!val) return null
|
||||
const d = typeof val === 'string' ? new Date(val) : val
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
maxWidth: 'calc(40rem * var(--mantine-scale))',
|
||||
}
|
||||
|
||||
function TaskCard({ task }: { task: TaskItem }) {
|
||||
const cfg = STATUS_CONFIG[task.status]
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text
|
||||
fw={600}
|
||||
lineClamp={2}
|
||||
style={{ fontFamily: '"Cormorant", serif', fontSize: 18, color: '#3D2B1F' }}
|
||||
>
|
||||
{asI18n(task.title)}
|
||||
</Text>
|
||||
<Badge size="sm" color={cfg?.color ?? 'gray'} variant="light" style={{ flexShrink: 0 }}>
|
||||
{cfg ? mKey(cfg.labelKey) : asI18n(task.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<Badge size="sm" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
|
||||
{asI18n(task.category)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{task.description && (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{asI18n(task.description)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{task.location && (
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size="sm" variant="transparent" color="gray">
|
||||
<MapPin size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(task.location)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{task.scheduledStartAt && (
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size="sm" variant="transparent" color="gray">
|
||||
<Clock size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(
|
||||
`${formatTime(task.scheduledStartAt)}${
|
||||
task.scheduledEndAt ? ` – ${formatTime(task.scheduledEndAt)}` : ''
|
||||
}`,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TaskActions taskId={task.taskId} status={task.status} />
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: I18nNode }) {
|
||||
return (
|
||||
<Card padding="xl" radius="md" style={{ ...cardStyle, textAlign: 'center' as const }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
|
||||
<Smile size={24} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MyTasksPage() {
|
||||
useLocale()
|
||||
const { data, isLoading } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
|
||||
|
||||
const tasks = (data?.items ?? []) as TaskItem[]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const grouped: Record<string, TaskItem[]> = {}
|
||||
for (const status of DISPLAY_STATUSES) {
|
||||
grouped[status] = []
|
||||
}
|
||||
for (const task of tasks) {
|
||||
if (DISPLAY_STATUSES.includes(task.status)) {
|
||||
grouped[task.status]!.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveTasks = DISPLAY_STATUSES.some((s) => (grouped[s]?.length ?? 0) > 0)
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.my_tasks_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.my_tasks_description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!hasActiveTasks ? (
|
||||
<EmptyState message={m.my_no_active_tasks()} />
|
||||
) : (
|
||||
DISPLAY_STATUSES.map((status) => {
|
||||
const items = grouped[status]!
|
||||
if (items.length === 0) return null
|
||||
const cfg = STATUS_CONFIG[status]!
|
||||
|
||||
return (
|
||||
<div key={status}>
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size="md" variant="light" color={cfg.color} radius="xl">
|
||||
<CheckSquare size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{mKey(cfg.labelKey)}
|
||||
</Title>
|
||||
<Badge size="sm" color={cfg.color} variant="light">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{items.map((task) => (
|
||||
<TaskCard key={task.taskId} task={task} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
91
apps/app/src/pages/_authenticated.notifications.tsx
Normal file
91
apps/app/src/pages/_authenticated.notifications.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Card, Stack, Loader, Center, Text } from '@pikku/mantine/core'
|
||||
import { CheckCheck } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { NotificationRow } from '@/components/notification-row'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/notifications')({
|
||||
component: NotificationsPage,
|
||||
})
|
||||
|
||||
function NotificationsPage() {
|
||||
useLocale()
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('listNotifications', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const items = data?.items ?? []
|
||||
const unreadCount = items.filter((n) => !n.readAt).length
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.notifications_title()}
|
||||
description={
|
||||
unreadCount > 0
|
||||
? m.notifications_unread_count({ count: unreadCount })
|
||||
: m.notifications_all_caught_up()
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
|
||||
<Text size="sm" c="red">
|
||||
{asI18n(error.message ?? 'Failed to load notifications')}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.length === 0 && !error ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<CheckCheck size={40} strokeWidth={1} color="#aaa" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.notifications_empty()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
padding={0}
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{items.map((n) => (
|
||||
<NotificationRow key={n.notificationId} notification={n} />
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
224
apps/app/src/pages/_authenticated.profile.tsx
Normal file
224
apps/app/src/pages/_authenticated.profile.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Anchor,
|
||||
TextInput,
|
||||
Button,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
} from '@pikku/mantine/core'
|
||||
import { AlertCircle, Check } from 'lucide-react'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useSessionUser } from '@/lib/auth-context'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/profile')({
|
||||
component: ProfilePage,
|
||||
})
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}
|
||||
|
||||
const labelStyle = { label: { fontWeight: 500, color: '#3D2B1F' } }
|
||||
|
||||
function ProfileEditor({
|
||||
userId,
|
||||
name: initialName,
|
||||
displayName: initialDisplay,
|
||||
mobileNumber: initialMobile,
|
||||
whatsappNumber: initialWhatsapp,
|
||||
}: {
|
||||
userId: string
|
||||
name: string
|
||||
displayName: string
|
||||
mobileNumber: string
|
||||
whatsappNumber: string
|
||||
}) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [displayName, setDisplayName] = useState(initialDisplay)
|
||||
const [mobileNumber, setMobileNumber] = useState(initialMobile)
|
||||
const [whatsappNumber, setWhatsappNumber] = useState(initialWhatsapp)
|
||||
|
||||
const mutation = usePikkuMutation('updateUserProfile')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
userId,
|
||||
name: name || undefined,
|
||||
displayName: displayName || undefined,
|
||||
mobileNumber: mobileNumber || undefined,
|
||||
whatsappNumber: whatsappNumber || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
{mutation.isError && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
|
||||
{mutation.error?.message ? asI18n(mutation.error.message) : m.profile_update_failed()}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutation.isSuccess && (
|
||||
<Alert icon={<Check size={16} />} color="green" variant="light">
|
||||
{m.profile_update_success()}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={m.profile_name()}
|
||||
placeholder={m.profile_name_placeholder()}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.profile_display_name()}
|
||||
placeholder={m.profile_display_name_placeholder()}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label={m.profile_mobile_number()}
|
||||
placeholder={m.profile_mobile_number_placeholder()}
|
||||
value={mobileNumber}
|
||||
onChange={(e) => setMobileNumber(e.currentTarget.value)}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.profile_whatsapp_number()}
|
||||
placeholder={m.profile_whatsapp_number_placeholder()}
|
||||
value={whatsappNumber}
|
||||
onChange={(e) => setWhatsappNumber(e.currentTarget.value)}
|
||||
styles={labelStyle}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button type="submit" loading={mutation.isPending} color="teal" radius="md">
|
||||
{m.profile_save_profile()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
useLocale()
|
||||
const sessionUser = useSessionUser()
|
||||
|
||||
const userId = sessionUser?.id
|
||||
|
||||
const { data: user, isLoading } = usePikkuQuery(
|
||||
'getUser',
|
||||
{ userId: userId! },
|
||||
{ enabled: !!userId },
|
||||
)
|
||||
|
||||
if (!sessionUser) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.profile_title()}
|
||||
</Title>
|
||||
<Text c="dimmed">{m.profile_sign_in_to_view()}</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.profile_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.profile_description()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Stack gap="md">
|
||||
<Group gap="lg">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.profile_field_email()}
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
{asI18n(user?.email ?? sessionUser?.email ?? '--')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.profile_field_roles()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
{(user?.memberRoles ?? []).map((role: string) => (
|
||||
<Badge key={role} color="teal" variant="light" size="sm">
|
||||
{asI18n(role.replace(/_/g, ' '))}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" radius="md" maw={600} style={cardStyle}>
|
||||
<Title order={4} mb="md" style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.profile_edit_profile()}
|
||||
</Title>
|
||||
<ProfileEditor
|
||||
userId={userId!}
|
||||
name={user?.name ?? ''}
|
||||
displayName={user?.displayName ?? ''}
|
||||
mobileNumber={user?.mobileNumber ?? ''}
|
||||
whatsappNumber={user?.whatsappNumber ?? ''}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" radius="md" style={cardStyle}>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="sm" fw={500} style={{ color: '#3D2B1F' }}>
|
||||
{m.profile_dietary_profile()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.profile_dietary_profile_description()}
|
||||
</Text>
|
||||
</div>
|
||||
<Anchor component={Link} to={'/kitchen/dietary' as any} size="sm">
|
||||
{m.profile_edit_dietary_profile()}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
896
apps/app/src/pages/_authenticated.retreats.$retreatId.tsx
Normal file
896
apps/app/src/pages/_authenticated.retreats.$retreatId.tsx
Normal file
@@ -0,0 +1,896 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Tabs,
|
||||
Table,
|
||||
Button,
|
||||
Anchor,
|
||||
Loader,
|
||||
Center,
|
||||
Drawer,
|
||||
TextInput,
|
||||
Select,
|
||||
Textarea,
|
||||
NumberInput,
|
||||
SimpleGrid,
|
||||
} from '@pikku/mantine/core'
|
||||
import { CalendarDays, Users, ClipboardList, Plus, UtensilsCrossed, Bell } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { UserSelect } from '@/components/user-select'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/retreats/$retreatId')({
|
||||
component: RetreatDetailPage,
|
||||
})
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
published: 'blue',
|
||||
active: 'teal',
|
||||
completed: 'green',
|
||||
cancelled: 'red',
|
||||
}
|
||||
|
||||
const roleColor: Record<string, string> = {
|
||||
participant: 'teal',
|
||||
facilitator: 'violet',
|
||||
assistant_facilitator: 'grape',
|
||||
organizer: 'orange',
|
||||
support_staff: 'blue',
|
||||
}
|
||||
|
||||
const mealTypeColor: Record<string, string> = {
|
||||
breakfast: 'yellow',
|
||||
lunch: 'orange',
|
||||
dinner: 'violet',
|
||||
snack: 'pink',
|
||||
}
|
||||
|
||||
const mealStatusColor: Record<string, string> = {
|
||||
planned: 'gray',
|
||||
ready: 'blue',
|
||||
served: 'teal',
|
||||
completed: 'green',
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(d: string | Date): string {
|
||||
return new Date(d).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function RetreatDetailPage() {
|
||||
useLocale()
|
||||
const { retreatId } = Route.useParams()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [personDrawerOpened, { open: openPersonDrawer, close: closePersonDrawer }] =
|
||||
useDisclosure(false)
|
||||
const [scheduleDrawerOpened, { open: openScheduleDrawer, close: closeScheduleDrawer }] =
|
||||
useDisclosure(false)
|
||||
const [mealDrawerOpened, { open: openMealDrawer, close: closeMealDrawer }] =
|
||||
useDisclosure(false)
|
||||
const [notifyDrawerOpened, { open: openNotifyDrawer, close: closeNotifyDrawer }] =
|
||||
useDisclosure(false)
|
||||
|
||||
// Person form state
|
||||
const [personUserId, setPersonUserId] = useState('')
|
||||
const [personRole, setPersonRole] = useState<string | null>(null)
|
||||
|
||||
// Schedule form state
|
||||
const [scheduleTitle, setScheduleTitle] = useState('')
|
||||
const [scheduleDescription, setScheduleDescription] = useState('')
|
||||
const [scheduleStartsAt, setScheduleStartsAt] = useState('')
|
||||
const [scheduleEndsAt, setScheduleEndsAt] = useState('')
|
||||
const [scheduleLocation, setScheduleLocation] = useState('')
|
||||
const [scheduleFacilitatorId, setScheduleFacilitatorId] = useState('')
|
||||
|
||||
// Meal form state
|
||||
const [mealServiceType, setMealServiceType] = useState<string | null>(null)
|
||||
const [mealServiceDate, setMealServiceDate] = useState('')
|
||||
const [mealHeadcount, setMealHeadcount] = useState<number | string>('')
|
||||
const [mealMenuNotes, setMealMenuNotes] = useState('')
|
||||
|
||||
// Notify form state
|
||||
const [notifyTitle, setNotifyTitle] = useState('')
|
||||
const [notifyBody, setNotifyBody] = useState('')
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listRetreats', { limit: 50, offset: 0 })
|
||||
|
||||
const { data: personsData, isLoading: personsLoading } = usePikkuQuery('listRetreatPersons', {
|
||||
retreatId,
|
||||
})
|
||||
|
||||
const { data: scheduleData, isLoading: scheduleLoading } = usePikkuQuery(
|
||||
'listRetreatSchedule',
|
||||
{ retreatId },
|
||||
)
|
||||
|
||||
const { data: mealsData, isLoading: mealsLoading } = usePikkuQuery('listRetreatMeals', {
|
||||
retreatId,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const addPersonMutation = usePikkuMutation('addRetreatPerson', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listRetreatPersons'] })
|
||||
notifications.show({
|
||||
title: 'Person added',
|
||||
message: 'Person has been added to the retreat.',
|
||||
color: 'green',
|
||||
})
|
||||
resetPersonForm()
|
||||
closePersonDrawer()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to add person.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const addScheduleMutation = usePikkuMutation('addRetreatScheduleItem', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listRetreatSchedule'] })
|
||||
notifications.show({
|
||||
title: 'Schedule item added',
|
||||
message: 'Schedule item has been added to the retreat.',
|
||||
color: 'green',
|
||||
})
|
||||
resetScheduleForm()
|
||||
closeScheduleDrawer()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to add schedule item.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const addMealMutation = usePikkuMutation('createRetreatMeal', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listRetreatMeals'] })
|
||||
notifications.show({
|
||||
title: 'Meal added',
|
||||
message: 'Meal service has been added to the retreat.',
|
||||
color: 'green',
|
||||
})
|
||||
resetMealForm()
|
||||
closeMealDrawer()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to add meal service.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const notifyMutation = usePikkuMutation('notifyRetreatParticipants', {
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
title: 'Notifications sent',
|
||||
message: `Notified ${result.notified} participant(s).`,
|
||||
color: 'green',
|
||||
})
|
||||
resetNotifyForm()
|
||||
closeNotifyDrawer()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to send notifications.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function resetPersonForm() {
|
||||
setPersonUserId('')
|
||||
setPersonRole(null)
|
||||
}
|
||||
|
||||
function resetScheduleForm() {
|
||||
setScheduleTitle('')
|
||||
setScheduleDescription('')
|
||||
setScheduleStartsAt('')
|
||||
setScheduleEndsAt('')
|
||||
setScheduleLocation('')
|
||||
setScheduleFacilitatorId('')
|
||||
}
|
||||
|
||||
function resetMealForm() {
|
||||
setMealServiceType(null)
|
||||
setMealServiceDate('')
|
||||
setMealHeadcount('')
|
||||
setMealMenuNotes('')
|
||||
}
|
||||
|
||||
function resetNotifyForm() {
|
||||
setNotifyTitle('')
|
||||
setNotifyBody('')
|
||||
}
|
||||
|
||||
function handleAddPerson() {
|
||||
if (!personRole) return
|
||||
addPersonMutation.mutate({
|
||||
retreatId,
|
||||
...(personUserId.trim() ? { userId: personUserId.trim() } : {}),
|
||||
roleType: personRole as
|
||||
| 'participant'
|
||||
| 'facilitator'
|
||||
| 'assistant_facilitator'
|
||||
| 'organizer'
|
||||
| 'support_staff',
|
||||
})
|
||||
}
|
||||
|
||||
function handleAddScheduleItem() {
|
||||
if (!scheduleTitle.trim() || !scheduleStartsAt || !scheduleEndsAt) return
|
||||
addScheduleMutation.mutate({
|
||||
retreatId,
|
||||
title: scheduleTitle.trim(),
|
||||
...(scheduleDescription.trim() ? { description: scheduleDescription.trim() } : {}),
|
||||
startsAt: new Date(scheduleStartsAt).toISOString(),
|
||||
endsAt: new Date(scheduleEndsAt).toISOString(),
|
||||
...(scheduleLocation.trim() ? { location: scheduleLocation.trim() } : {}),
|
||||
...(scheduleFacilitatorId.trim()
|
||||
? { leadRetreatPersonId: scheduleFacilitatorId.trim() }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
function handleAddMeal() {
|
||||
if (!mealServiceType || !mealServiceDate) return
|
||||
addMealMutation.mutate({
|
||||
retreatId,
|
||||
serviceType: mealServiceType as 'breakfast' | 'lunch' | 'dinner' | 'snack',
|
||||
serviceDate: mealServiceDate,
|
||||
...(typeof mealHeadcount === 'number' && mealHeadcount > 0
|
||||
? { plannedHeadcount: mealHeadcount }
|
||||
: {}),
|
||||
...(mealMenuNotes.trim() ? { menuNotes: mealMenuNotes.trim() } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function handleNotify() {
|
||||
if (!notifyTitle.trim() || !notifyBody.trim()) return
|
||||
notifyMutation.mutate({
|
||||
retreatId,
|
||||
title: notifyTitle.trim(),
|
||||
body: notifyBody.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const retreat = (data?.items ?? []).find((r) => r.retreatId === retreatId) ?? null
|
||||
const people = personsData?.items ?? []
|
||||
const schedule = scheduleData?.items ?? []
|
||||
const meals = mealsData?.items ?? []
|
||||
|
||||
// Group meals by date
|
||||
const mealsByDate: Record<string, typeof meals> = {}
|
||||
for (const meal of meals) {
|
||||
const dateKey = new Date(meal.serviceDate).toISOString().split('T')[0]
|
||||
if (!mealsByDate[dateKey]) mealsByDate[dateKey] = []
|
||||
mealsByDate[dateKey].push(meal)
|
||||
}
|
||||
const sortedMealDates = Object.keys(mealsByDate).sort()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!retreat) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.retreats_not_found_title()}
|
||||
</Title>
|
||||
<Text c="dimmed">{m.retreats_not_found_body()}</Text>
|
||||
<Anchor component={Link} to="/retreats" size="sm">
|
||||
{m.retreats_back()}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Group gap="sm" align="center">
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{asI18n(retreat.name)}
|
||||
</Title>
|
||||
<Badge color={statusColor[retreat.status] ?? 'gray'} variant="light" size="lg">
|
||||
{asI18n(retreat.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{asI18n(
|
||||
`${formatDate(retreat.startAt)} – ${formatDate(retreat.endAt)}${retreat.capacity != null ? ` | Capacity: ${retreat.capacity}` : ''}`,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
leftSection={<Bell size={14} />}
|
||||
onClick={openNotifyDrawer}
|
||||
>
|
||||
{m.retreats_notify_participants()}
|
||||
</Button>
|
||||
<Anchor component={Link} to="/retreats" size="sm">
|
||||
{m.retreats_back()}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs defaultValue="overview" color="teal">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview" leftSection={<CalendarDays size={16} />}>
|
||||
{m.retreats_tab_overview()}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="people" leftSection={<Users size={16} />}>
|
||||
{asI18n(`${m.retreats_tab_people()} (${people.length})`)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="schedule" leftSection={<ClipboardList size={16} />}>
|
||||
{asI18n(`${m.retreats_tab_schedule()} (${schedule.length})`)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="meals" leftSection={<UtensilsCrossed size={16} />}>
|
||||
{asI18n(`${m.retreats_tab_meals()} (${meals.length})`)}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="md">
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap="lg">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.retreats_status()}
|
||||
</Text>
|
||||
<Badge color={statusColor[retreat.status] ?? 'gray'} variant="light" mt={4}>
|
||||
{asI18n(retreat.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.retreats_slug()}
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
{asI18n(retreat.slug)}
|
||||
</Text>
|
||||
</div>
|
||||
{retreat.capacity != null && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.retreats_capacity()}
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
{asI18n(String(retreat.capacity))}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{retreat.description && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.retreats_description()}
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
{asI18n(retreat.description)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="people" pt="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={openPersonDrawer}
|
||||
>
|
||||
{m.retreats_add_person()}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
{personsLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" size="sm" />
|
||||
</Center>
|
||||
) : people.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{m.retreats_no_people()}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.retreats_person_name()}</Table.Th>
|
||||
<Table.Th>{m.retreats_role()}</Table.Th>
|
||||
<Table.Th>{m.retreats_status()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{people.map((person) => (
|
||||
<Table.Tr key={person.retreatPersonId}>
|
||||
<Table.Td>{person.fullName ?? person.email ?? 'Unknown'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={roleColor[person.roleType] ?? 'gray'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{asI18n(person.roleType?.replace(/_/g, ' ') ?? '')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="dot" color="teal" size="sm">
|
||||
{asI18n(person.attendanceStatus)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="schedule" pt="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={openScheduleDrawer}
|
||||
>
|
||||
{m.retreats_add_schedule_item()}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
{scheduleLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" size="sm" />
|
||||
</Center>
|
||||
) : schedule.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{m.retreats_no_schedule()}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{schedule.map((item) => (
|
||||
<Group key={item.itemId} gap="md" align="flex-start">
|
||||
<Text size="sm" fw={600} style={{ minWidth: 140 }}>
|
||||
{asI18n(formatTime(item.startsAt))}
|
||||
</Text>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(item.title)}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(item.description)}
|
||||
</Text>
|
||||
)}
|
||||
{item.location && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`${m.retreats_location()}: ${item.location}`)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(formatTime(item.endsAt))}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="meals" pt="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={openMealDrawer}
|
||||
>
|
||||
{m.retreats_add_meal()}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{mealsLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" size="sm" />
|
||||
</Center>
|
||||
) : meals.length === 0 ? (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{m.retreats_no_meals()}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
sortedMealDates.map((dateKey) => (
|
||||
<div key={dateKey}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
mb="xs"
|
||||
style={{
|
||||
fontFamily: '"Cormorant", serif',
|
||||
fontSize: 18,
|
||||
color: '#3D2B1F',
|
||||
}}
|
||||
>
|
||||
{asI18n(formatDate(dateKey))}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="md">
|
||||
{mealsByDate[dateKey].map((meal) => (
|
||||
<Card
|
||||
key={meal.serviceId}
|
||||
padding="md"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Badge
|
||||
color={mealTypeColor[meal.serviceType] ?? 'gray'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
tt="capitalize"
|
||||
>
|
||||
{asI18n(meal.serviceType)}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={mealStatusColor[meal.status] ?? 'gray'}
|
||||
variant="dot"
|
||||
size="sm"
|
||||
>
|
||||
{asI18n(meal.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
{meal.plannedHeadcount != null && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`${m.retreats_headcount()}: ${meal.plannedHeadcount}`)}
|
||||
</Text>
|
||||
)}
|
||||
{meal.menuNotes && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{asI18n(meal.menuNotes)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<Drawer
|
||||
opened={personDrawerOpened}
|
||||
onClose={closePersonDrawer}
|
||||
title={m.retreats_add_person_title()}
|
||||
position="right"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<UserSelect
|
||||
label="User"
|
||||
placeholder="Search for a user..."
|
||||
value={personUserId || null}
|
||||
onChange={(val) => setPersonUserId(val ?? '')}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={m.retreats_role()}
|
||||
placeholder={asI18n('Select role')}
|
||||
data={[
|
||||
{ value: 'participant', label: 'Participant' },
|
||||
{ value: 'facilitator', label: 'Facilitator' },
|
||||
{ value: 'assistant_facilitator', label: 'Assistant Facilitator' },
|
||||
{ value: 'organizer', label: 'Organizer' },
|
||||
{ value: 'support_staff', label: 'Support Staff' },
|
||||
]}
|
||||
value={personRole}
|
||||
onChange={setPersonRole}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={closePersonDrawer}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={handleAddPerson}
|
||||
loading={addPersonMutation.isPending}
|
||||
disabled={!personRole}
|
||||
>
|
||||
{m.retreats_add_person()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
opened={scheduleDrawerOpened}
|
||||
onClose={closeScheduleDrawer}
|
||||
title={m.retreats_add_schedule_item()}
|
||||
position="right"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.retreats_schedule_title()}
|
||||
placeholder={asI18n('Schedule item title')}
|
||||
value={scheduleTitle}
|
||||
onChange={(e) => setScheduleTitle(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.retreats_description()}
|
||||
placeholder={asI18n('Optional description')}
|
||||
rows={3}
|
||||
value={scheduleDescription}
|
||||
onChange={(e) => setScheduleDescription(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_starts_at()}
|
||||
type="datetime-local"
|
||||
value={scheduleStartsAt}
|
||||
onChange={(e) => setScheduleStartsAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_ends_at()}
|
||||
type="datetime-local"
|
||||
value={scheduleEndsAt}
|
||||
onChange={(e) => setScheduleEndsAt(e.currentTarget.value)}
|
||||
min={scheduleStartsAt || undefined}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_location()}
|
||||
placeholder={asI18n('Optional location')}
|
||||
value={scheduleLocation}
|
||||
onChange={(e) => setScheduleLocation(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={m.retreats_lead_facilitator()}
|
||||
placeholder={asI18n('Select a retreat participant...')}
|
||||
data={people.map((p) => ({
|
||||
value: p.retreatPersonId,
|
||||
label: p.fullName ?? p.email ?? p.roleType?.replace(/_/g, ' ') ?? 'Unknown',
|
||||
}))}
|
||||
value={scheduleFacilitatorId || null}
|
||||
onChange={(val) => setScheduleFacilitatorId(val ?? '')}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage={asI18n('No people added to retreat')}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={closeScheduleDrawer}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={handleAddScheduleItem}
|
||||
loading={addScheduleMutation.isPending}
|
||||
disabled={!scheduleTitle.trim() || !scheduleStartsAt || !scheduleEndsAt}
|
||||
>
|
||||
{m.retreats_add_schedule_item()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
opened={mealDrawerOpened}
|
||||
onClose={closeMealDrawer}
|
||||
title={m.retreats_add_meal_service()}
|
||||
position="right"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={m.retreats_service_type()}
|
||||
placeholder={asI18n('Select meal type')}
|
||||
data={[
|
||||
{ value: 'breakfast', label: 'Breakfast' },
|
||||
{ value: 'lunch', label: 'Lunch' },
|
||||
{ value: 'dinner', label: 'Dinner' },
|
||||
{ value: 'snack', label: 'Snack' },
|
||||
]}
|
||||
value={mealServiceType}
|
||||
onChange={setMealServiceType}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_service_date()}
|
||||
type="date"
|
||||
value={mealServiceDate}
|
||||
onChange={(e) => setMealServiceDate(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={m.retreats_planned_headcount()}
|
||||
placeholder={asI18n('Expected number of diners')}
|
||||
min={1}
|
||||
value={mealHeadcount}
|
||||
onChange={setMealHeadcount}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.retreats_menu_notes()}
|
||||
placeholder={asI18n('Optional menu description or notes')}
|
||||
rows={3}
|
||||
value={mealMenuNotes}
|
||||
onChange={(e) => setMealMenuNotes(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={closeMealDrawer}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={handleAddMeal}
|
||||
loading={addMealMutation.isPending}
|
||||
disabled={!mealServiceType || !mealServiceDate}
|
||||
>
|
||||
{m.retreats_add_meal()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
opened={notifyDrawerOpened}
|
||||
onClose={closeNotifyDrawer}
|
||||
title={m.retreats_notify_title()}
|
||||
position="right"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.retreats_notify_subject()}
|
||||
placeholder={asI18n('Notification title')}
|
||||
value={notifyTitle}
|
||||
onChange={(e) => setNotifyTitle(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.retreats_notify_body()}
|
||||
placeholder={asI18n('Notification message')}
|
||||
rows={5}
|
||||
value={notifyBody}
|
||||
onChange={(e) => setNotifyBody(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={closeNotifyDrawer}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="violet"
|
||||
onClick={handleNotify}
|
||||
loading={notifyMutation.isPending}
|
||||
disabled={!notifyTitle.trim() || !notifyBody.trim()}
|
||||
leftSection={<Bell size={14} />}
|
||||
>
|
||||
{m.retreats_send_notification()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
314
apps/app/src/pages/_authenticated.retreats.index.tsx
Normal file
314
apps/app/src/pages/_authenticated.retreats.index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Button,
|
||||
Loader,
|
||||
Center,
|
||||
Drawer,
|
||||
TextInput,
|
||||
Textarea,
|
||||
NumberInput,
|
||||
Alert,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { CalendarDays, Plus, User, Users, AlertCircle } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/retreats/')({
|
||||
component: RetreatsPage,
|
||||
})
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
published: 'blue',
|
||||
active: 'teal',
|
||||
completed: 'green',
|
||||
cancelled: 'red',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
published: 'Open',
|
||||
active: 'Active',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function CreateRetreatDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [startAt, setStartAt] = useState('')
|
||||
const [endAt, setEndAt] = useState('')
|
||||
const [capacity, setCapacity] = useState<number | string>('')
|
||||
|
||||
const handleNameChange = (val: string) => {
|
||||
setName(val)
|
||||
setSlug(
|
||||
val
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setSlug('')
|
||||
setDescription('')
|
||||
setStartAt('')
|
||||
setEndAt('')
|
||||
setCapacity('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createRetreat', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listRetreats'] })
|
||||
notifications.show({
|
||||
title: 'Retreat created',
|
||||
message: 'Retreat has been created successfully.',
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to create retreat.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({
|
||||
name,
|
||||
slug,
|
||||
description: description || undefined,
|
||||
startAt: startAt ? new Date(startAt).toISOString() : '',
|
||||
endAt: endAt ? new Date(endAt).toISOString() : '',
|
||||
capacity: capacity ? Number(capacity) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.retreats_create()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
{mutation.isError && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
|
||||
{asI18n(mutation.error?.message ?? 'Failed to create retreat')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_name()}
|
||||
placeholder={asI18n('Spring Equinox Retreat')}
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_slug()}
|
||||
placeholder={asI18n('spring-equinox-retreat')}
|
||||
required
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.retreats_description()}
|
||||
placeholder={asI18n('Describe the retreat...')}
|
||||
minRows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_start_date()}
|
||||
type="date"
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.retreats_end_date()}
|
||||
type="date"
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.currentTarget.value)}
|
||||
min={startAt || undefined}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={m.retreats_capacity()}
|
||||
placeholder={asI18n('Maximum participants')}
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={setCapacity}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.retreats_create()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function RetreatsPage() {
|
||||
useLocale()
|
||||
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listRetreats', { limit: 50, offset: 0 })
|
||||
|
||||
const retreats = data?.items ?? []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.retreats_title()}
|
||||
description={m.retreats_subtitle()}
|
||||
action={
|
||||
<Button
|
||||
onClick={() => setCreateDrawerOpen(true)}
|
||||
leftSection={<Plus size={16} />}
|
||||
color="teal"
|
||||
radius="md"
|
||||
>
|
||||
{m.retreats_create()}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{retreats.length === 0 ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<CalendarDays size={48} strokeWidth={1} color="#aaa" />
|
||||
<Text c="dimmed">{m.retreats_empty()}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{retreats.map((retreat) => (
|
||||
<Card
|
||||
key={retreat.retreatId}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
component={Link}
|
||||
to="/retreats/$retreatId"
|
||||
params={{ retreatId: retreat.retreatId } as never}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Title
|
||||
order={4}
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
|
||||
>
|
||||
{asI18n(retreat.name)}
|
||||
</Title>
|
||||
<Badge
|
||||
color={statusColor[retreat.status] ?? 'gray'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{asI18n(statusLabel[retreat.status] ?? retreat.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{retreat.description && (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{asI18n(retreat.description)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(`${formatDate(retreat.startAt)} – ${formatDate(retreat.endAt)}`)}
|
||||
</Text>
|
||||
|
||||
{retreat.facilitatorName && (
|
||||
<Group gap="xs">
|
||||
<User size={14} strokeWidth={1.5} color="#2A7B88" />
|
||||
<Text size="sm" fw={500} c="teal">
|
||||
{asI18n(retreat.facilitatorName)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{retreat.capacity != null && (
|
||||
<Group gap="xs">
|
||||
<Users size={14} strokeWidth={1.5} color="#888" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(`${m.retreats_capacity()}: ${retreat.capacity}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<CreateRetreatDrawer
|
||||
opened={createDrawerOpen}
|
||||
onClose={() => setCreateDrawerOpen(false)}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
5
apps/app/src/pages/_authenticated.retreats.tsx
Normal file
5
apps/app/src/pages/_authenticated.retreats.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/retreats')({
|
||||
component: Outlet,
|
||||
})
|
||||
115
apps/app/src/pages/_authenticated.rooms.allocations.tsx
Normal file
115
apps/app/src/pages/_authenticated.rooms.allocations.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Stack, Loader, Center, Text } from '@pikku/mantine/core'
|
||||
import { usePikkuQuery } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { StatusBadge } from '@perauset/components'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/rooms/allocations')({
|
||||
validateSearch: (search: Record<string, unknown>): { roomId?: string } => ({
|
||||
roomId: typeof search.roomId === 'string' ? search.roomId : undefined,
|
||||
}),
|
||||
component: RoomAllocationsPage,
|
||||
})
|
||||
|
||||
type RoomAllocation = {
|
||||
allocationId: string
|
||||
roomId: string
|
||||
stayId: string
|
||||
startsAt: string | Date
|
||||
endsAt: string | Date
|
||||
status: string
|
||||
allocationReason: string | null
|
||||
createdByUserId: string
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | Date): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return String(dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
function AllocationsTable({ allocations }: { allocations: RoomAllocation[] }) {
|
||||
const { data: roomsData } = usePikkuQuery('listRooms', { limit: 100, offset: 0 })
|
||||
const roomMap = new Map((roomsData?.items ?? []).map((r) => [r.roomId, r]))
|
||||
|
||||
const columns: Column<RoomAllocation>[] = [
|
||||
{
|
||||
key: 'roomId',
|
||||
label: m.rooms_room(),
|
||||
render: (row) => {
|
||||
const room = roomMap.get(row.roomId)
|
||||
return (
|
||||
<Text size="sm" truncate>
|
||||
{asI18n(room ? `${room.name} (${room.code})` : 'Room')}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
label: m.rooms_dates(),
|
||||
render: (row) => (
|
||||
<Text size="sm">
|
||||
{asI18n(`${formatDate(row.startsAt)} - ${formatDate(row.endsAt)}`)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: m.rooms_status(),
|
||||
render: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'reason',
|
||||
label: m.rooms_reason(),
|
||||
render: (row) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{asI18n(row.allocationReason ?? '-')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={allocations}
|
||||
columns={columns}
|
||||
rowKey={(row) => row.allocationId}
|
||||
emptyMessage="No room allocations found."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomAllocationsPage() {
|
||||
useLocale()
|
||||
const { roomId } = Route.useSearch()
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listRoomAllocations', { roomId, limit: 50, offset: 0 })
|
||||
|
||||
const allocations = (data?.items ?? []) as RoomAllocation[]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.rooms_allocations_title()}
|
||||
description={roomId ? m.rooms_allocations_room_description() : m.rooms_allocations_all_description()}
|
||||
/>
|
||||
<AllocationsTable allocations={allocations} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
314
apps/app/src/pages/_authenticated.rooms.index.tsx
Normal file
314
apps/app/src/pages/_authenticated.rooms.index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Stack,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Drawer,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Select,
|
||||
Textarea,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Pencil, Search, Plus } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
type RoomType = 'dorm' | 'facilitator' | 'private' | 'shared' | 'staff'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/rooms/')({
|
||||
component: RoomsPage,
|
||||
})
|
||||
|
||||
type Room = {
|
||||
roomId: string
|
||||
code: string
|
||||
name: string
|
||||
roomType: string
|
||||
capacity: number
|
||||
pricePerNight: number | null
|
||||
isActive: boolean
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
const ROOM_TYPES = [
|
||||
{ value: 'dorm', label: 'Dorm' },
|
||||
{ value: 'facilitator', label: 'Facilitator' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'shared', label: 'Shared' },
|
||||
{ value: 'staff', label: 'Staff' },
|
||||
]
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}
|
||||
|
||||
function formatType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function RoomActions({ onCreateRoom }: { onCreateRoom?: () => void }) {
|
||||
const canManage = usePermission('rooms.manage')
|
||||
if (!canManage) return null
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<Button component={Link} to="/rooms/allocations" variant="light" color="teal" size="sm">
|
||||
{m.rooms_view_allocations()}
|
||||
</Button>
|
||||
<Button leftSection={<Plus size={16} />} color="teal" size="sm" onClick={onCreateRoom}>
|
||||
{m.rooms_create_room()}
|
||||
</Button>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRoomDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [code, setCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [roomType, setRoomType] = useState<RoomType | null>(null)
|
||||
const [capacity, setCapacity] = useState<number>(1)
|
||||
const [pricePerNight, setPricePerNight] = useState<number | undefined>(undefined)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setCode('')
|
||||
setName('')
|
||||
setRoomType(null)
|
||||
setCapacity(1)
|
||||
setPricePerNight(undefined)
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('createRoom', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listRooms'] })
|
||||
notifications.show({ title: 'Room created', message: 'New room has been added.', color: 'green' })
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to create room.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!code || !name || !roomType) {
|
||||
notifications.show({ title: 'Missing fields', message: 'Code, name, and room type are required.', color: 'red' })
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
code,
|
||||
name,
|
||||
roomType,
|
||||
capacity,
|
||||
pricePerNight: pricePerNight || undefined,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.rooms_create_room()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={m.rooms_room_code()} placeholder={m.rooms_room_code_placeholder()} value={code} onChange={(e) => setCode(e.currentTarget.value)} required />
|
||||
<TextInput label={m.rooms_room_name()} placeholder={m.rooms_room_name_placeholder()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
||||
<Select label={m.rooms_room_type()} placeholder={m.rooms_select_type()} data={ROOM_TYPES} value={roomType} onChange={(v) => setRoomType(v as RoomType | null)} required />
|
||||
<NumberInput label={m.rooms_capacity()} value={capacity} onChange={(val) => setCapacity(Number(val) || 1)} min={1} />
|
||||
<NumberInput label={m.rooms_price_per_night()} placeholder={m.rooms_price_placeholder()} value={pricePerNight} onChange={(val) => setPricePerNight(val ? Number(val) : undefined)} min={0} decimalScale={2} prefix={asI18n('€')} />
|
||||
<Textarea label={m.rooms_notes()} placeholder={m.rooms_notes_placeholder()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>{m.rooms_save_room()}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function EditRoomDrawer({ room, onClose }: { room: Room | null; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(room?.name ?? '')
|
||||
const [roomType, setRoomType] = useState<string | null>(room?.roomType ?? null)
|
||||
const [capacity, setCapacity] = useState<number>(room?.capacity ?? 1)
|
||||
const [notes, setNotes] = useState(room?.notes ?? '')
|
||||
|
||||
const mutation = usePikkuMutation('updateRoom', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listRooms'] })
|
||||
notifications.show({ title: 'Room updated', message: 'Room details have been saved.', color: 'green' })
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => notifications.show({ title: 'Error', message: err.message || 'Failed to update room.', color: 'red' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!room) return
|
||||
mutation.mutate({
|
||||
roomId: room.roomId,
|
||||
name,
|
||||
roomType: roomType ?? undefined,
|
||||
capacity,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (!room) return null
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={!!room} onClose={onClose} title={m.rooms_edit_room()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={m.rooms_room_name()} value={name} onChange={(e) => setName(e.currentTarget.value)} required />
|
||||
<Select label={m.rooms_room_type()} data={ROOM_TYPES} value={roomType} onChange={setRoomType} required />
|
||||
<NumberInput label={m.rooms_capacity()} value={capacity} onChange={(val) => setCapacity(Number(val) || 1)} min={1} />
|
||||
<Textarea label={m.rooms_notes()} value={notes} onChange={(e) => setNotes(e.currentTarget.value)} minRows={2} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>{m.common_cancel()}</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>{m.common_save_changes()}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomsPage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('rooms.manage')
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [editingRoom, setEditingRoom] = useState<Room | null>(null)
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listRooms', { limit: 50, offset: 0 })
|
||||
|
||||
const { data: availabilityData } = usePikkuQuery(
|
||||
'checkRoomAvailability',
|
||||
{ startDate, endDate },
|
||||
{ enabled: !!startDate && !!endDate },
|
||||
)
|
||||
|
||||
const availabilityMap = new Map((availabilityData?.rooms ?? []).map((r) => [r.roomId, r]))
|
||||
|
||||
const rooms = data?.items ?? []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.rooms_title()}
|
||||
description={m.rooms_description()}
|
||||
action={<RoomActions onCreateRoom={() => setCreateModalOpen(true)} />}
|
||||
/>
|
||||
|
||||
<Card padding="md" radius="md" style={cardStyle}>
|
||||
<Group gap="md" align="flex-end">
|
||||
<TextInput type="date" label={m.rooms_availability_start()} value={startDate} onChange={(e) => setStartDate(e.currentTarget.value)} size="sm" />
|
||||
<TextInput type="date" label={m.rooms_availability_end()} value={endDate} onChange={(e) => setEndDate(e.currentTarget.value)} size="sm" min={startDate || undefined} />
|
||||
{startDate && endDate && (
|
||||
<Badge color="teal" variant="light" size="lg">
|
||||
<Search size={12} style={{ marginRight: 4 }} />
|
||||
{m.rooms_showing_availability()}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{rooms.length === 0 ? (
|
||||
<Card padding="xl" radius="md" style={cardStyle}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.rooms_no_rooms()}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="md">
|
||||
{rooms.map((room) => {
|
||||
const availability = availabilityMap.get(room.roomId)
|
||||
return (
|
||||
<Card key={room.roomId} padding="lg" radius="md" className="card-hover" style={{ ...cardStyle, transition: 'transform 0.15s ease, box-shadow 0.15s ease' }}>
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<Link to="/rooms/allocations" search={{ roomId: room.roomId }} style={{ textDecoration: 'none', flex: 1 }}>
|
||||
<Text size="lg" fw={600} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{asI18n(room.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{asI18n(room.code)}
|
||||
</Text>
|
||||
</Link>
|
||||
<Group gap="xs">
|
||||
<Badge color={room.isActive ? 'green' : 'gray'} variant="light" size="sm">
|
||||
{room.isActive ? m.rooms_active() : m.rooms_inactive()}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<Tooltip label={m.rooms_edit_room()}>
|
||||
<ActionIcon variant="light" color="teal" size="sm" onClick={() => setEditingRoom(room)}>
|
||||
<Pencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="lg" mt="md">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_type()}</Text>
|
||||
<Text size="sm">{asI18n(formatType(room.roomType))}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_capacity()}</Text>
|
||||
<Text size="sm">{room.capacity}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_price_night()}</Text>
|
||||
<Text size="sm" fw={500}>{asI18n(room.pricePerNight != null ? `€${Number(room.pricePerNight).toFixed(2)}` : '—')}</Text>
|
||||
</div>
|
||||
{availability && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{m.rooms_availability()}</Text>
|
||||
<Badge color={availability.available ? 'green' : 'red'} variant="filled" size="sm">
|
||||
{availability.available ? m.rooms_available() : m.rooms_full()}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{room.notes && (
|
||||
<Text size="xs" c="dimmed" mt="sm" lineClamp={2}>
|
||||
{asI18n(room.notes)}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<CreateRoomDrawer opened={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
<EditRoomDrawer room={editingRoom} onClose={() => setEditingRoom(null)} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
5
apps/app/src/pages/_authenticated.rooms.tsx
Normal file
5
apps/app/src/pages/_authenticated.rooms.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/rooms')({
|
||||
component: Outlet,
|
||||
})
|
||||
320
apps/app/src/pages/_authenticated.stays.tsx
Normal file
320
apps/app/src/pages/_authenticated.stays.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Stack,
|
||||
Button,
|
||||
Loader,
|
||||
Center,
|
||||
Group,
|
||||
Select,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Drawer,
|
||||
Text,
|
||||
Alert,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Plus, Info } from 'lucide-react'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { StaysTabs } from '@/components/stays-tabs'
|
||||
import { m, mKey } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/stays')({
|
||||
component: StaysPage,
|
||||
})
|
||||
|
||||
type RequestType = 'day_visit' | 'facilitator' | 'guest' | 'staff' | 'volunteer'
|
||||
|
||||
const REQUEST_TYPES: { value: RequestType; labelKey: string }[] = [
|
||||
{ value: 'day_visit', labelKey: 'stays.type_day_visit' },
|
||||
{ value: 'facilitator', labelKey: 'stays.type_facilitator' },
|
||||
{ value: 'guest', labelKey: 'stays.type_guest' },
|
||||
{ value: 'staff', labelKey: 'stays.type_staff' },
|
||||
{ value: 'volunteer', labelKey: 'stays.type_volunteer' },
|
||||
]
|
||||
|
||||
function RequestStayDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [requestType, setRequestType] = useState<string | null>(null)
|
||||
const [startAt, setStartAt] = useState('')
|
||||
const [endAt, setEndAt] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setRequestType(null)
|
||||
setStartAt('')
|
||||
setEndAt('')
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('requestStay', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
|
||||
notifications.show({
|
||||
title: m.stays_request_submitted(),
|
||||
message: m.stays_request_submitted_message(),
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err.message ? asI18n(err.message) : m.stays_something_went_wrong(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!requestType || !startAt || !endAt) {
|
||||
notifications.show({
|
||||
title: m.stays_missing_fields(),
|
||||
message: m.stays_missing_fields_message(),
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
requestType: requestType as RequestType,
|
||||
requestedStartAt: startAt,
|
||||
requestedEndAt: endAt,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.stays_request_stay()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={m.stays_request_type()}
|
||||
placeholder={m.stays_select_type()}
|
||||
data={REQUEST_TYPES.map((t) => ({ value: t.value, label: String(mKey(t.labelKey)) }))}
|
||||
value={requestType}
|
||||
onChange={setRequestType}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="date"
|
||||
label={m.stays_start_date()}
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="date"
|
||||
label={m.stays_end_date()}
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.stays_notes()}
|
||||
placeholder={m.stays_notes_placeholder()}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.stays_submit_request()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateStayDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [requestType, setRequestType] = useState<string | null>(null)
|
||||
const [startAt, setStartAt] = useState('')
|
||||
const [endAt, setEndAt] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const resetForm = () => {
|
||||
setRequestType(null)
|
||||
setStartAt('')
|
||||
setEndAt('')
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
const requestStay = usePikkuMutation('requestStay')
|
||||
const approveStayRequest = usePikkuMutation('approveStayRequest')
|
||||
const createStayFromRequest = usePikkuMutation('createStayFromRequest')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!requestType || !startAt || !endAt) {
|
||||
notifications.show({
|
||||
title: m.stays_missing_fields(),
|
||||
message: m.stays_missing_fields_message(),
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsPending(true)
|
||||
try {
|
||||
const requestResult = await requestStay.mutateAsync({
|
||||
requestType: requestType as RequestType,
|
||||
requestedStartAt: startAt,
|
||||
requestedEndAt: endAt,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
await approveStayRequest.mutateAsync({ requestId: requestResult.requestId })
|
||||
await createStayFromRequest.mutateAsync({ requestId: requestResult.requestId })
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['listStays'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['listStayRequests'] })
|
||||
notifications.show({
|
||||
title: m.stays_stay_created(),
|
||||
message: m.stays_stay_created_message(),
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: m.common_error(),
|
||||
message: err instanceof Error && err.message ? asI18n(err.message) : m.stays_create_failed(),
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.stays_create_stay()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="teal" variant="light">
|
||||
<Text size="sm">{m.stays_create_stay_hint()}</Text>
|
||||
</Alert>
|
||||
|
||||
<Select
|
||||
label={m.stays_stay_type()}
|
||||
placeholder={m.stays_select_type()}
|
||||
data={REQUEST_TYPES.map((t) => ({ value: t.value, label: String(mKey(t.labelKey)) }))}
|
||||
value={requestType}
|
||||
onChange={setRequestType}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="date"
|
||||
label={m.stays_start_date()}
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="date"
|
||||
label={m.stays_end_date()}
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.stays_notes()}
|
||||
placeholder={m.stays_notes_placeholder()}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{m.stays_create_stay()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function StaysPage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('stays.manage')
|
||||
const [requestDrawerOpen, setRequestDrawerOpen] = useState(false)
|
||||
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
|
||||
|
||||
const { data: staysData, isLoading: staysLoading } = usePikkuQuery('listStays', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const { data: requestsData, isLoading: requestsLoading } = usePikkuQuery('listStayRequests', {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const stays = staysData?.items ?? []
|
||||
const stayRequests = requestsData?.items ?? []
|
||||
|
||||
if (staysLoading || requestsLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.stays_title()}
|
||||
description={m.stays_description()}
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<Button onClick={() => setRequestDrawerOpen(true)} variant="light" color="teal" size="sm">
|
||||
{m.stays_request_stay()}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setCreateDrawerOpen(true)}
|
||||
leftSection={<Plus size={16} />}
|
||||
color="teal"
|
||||
size="sm"
|
||||
>
|
||||
{m.stays_create_stay()}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<StaysTabs stays={stays} stayRequests={stayRequests} />
|
||||
|
||||
<RequestStayDrawer opened={requestDrawerOpen} onClose={() => setRequestDrawerOpen(false)} />
|
||||
<CreateStayDrawer opened={createDrawerOpen} onClose={() => setCreateDrawerOpen(false)} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
577
apps/app/src/pages/_authenticated.tasks.index.tsx
Normal file
577
apps/app/src/pages/_authenticated.tasks.index.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
Accordion,
|
||||
Anchor,
|
||||
Box,
|
||||
Drawer,
|
||||
Select,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from '@pikku/mantine/core'
|
||||
import { MapPin, Clock, Plus, Repeat, ShieldCheck } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { useState } from 'react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { TaskActions } from '@/components/task-actions'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tasks/')({
|
||||
component: TasksPage,
|
||||
})
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; order: number }> = {
|
||||
open: { color: 'teal', order: 0 },
|
||||
assigned: { color: 'blue', order: 1 },
|
||||
in_progress: { color: 'orange', order: 2 },
|
||||
blocked: { color: 'red', order: 3 },
|
||||
planned: { color: 'gray', order: 4 },
|
||||
completed: { color: 'green', order: 5 },
|
||||
cancelled: { color: 'gray', order: 6 },
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
housekeeping: 'pink',
|
||||
kitchen: 'yellow',
|
||||
maintenance: 'orange',
|
||||
operations: 'blue',
|
||||
retreat: 'violet',
|
||||
transport: 'cyan',
|
||||
}
|
||||
|
||||
const KANBAN_STATUSES = ['open', 'assigned', 'in_progress', 'blocked']
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'housekeeping', label: m.tasks_category_housekeeping() },
|
||||
{ value: 'kitchen', label: m.tasks_category_kitchen() },
|
||||
{ value: 'maintenance', label: m.tasks_category_maintenance() },
|
||||
{ value: 'operations', label: m.tasks_category_operations() },
|
||||
{ value: 'retreat', label: m.tasks_category_retreat() },
|
||||
{ value: 'transport', label: m.tasks_category_transport() },
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'admin', label: m.tasks_role_admin() },
|
||||
{ value: 'coordinator', label: m.tasks_role_coordinator() },
|
||||
{ value: 'facilitator', label: m.tasks_role_facilitator() },
|
||||
{ value: 'boat_coordinator', label: m.tasks_role_boat_coordinator() },
|
||||
{ value: 'kitchen_lead', label: m.tasks_role_kitchen_lead() },
|
||||
{ value: 'staff', label: m.tasks_role_staff() },
|
||||
{ value: 'volunteer', label: m.tasks_role_volunteer() },
|
||||
{ value: 'community_member', label: m.tasks_role_community_member() },
|
||||
]
|
||||
|
||||
const STATUS_LABEL: Record<string, () => string> = {
|
||||
open: () => m.tasks_status_open(),
|
||||
assigned: () => m.tasks_status_assigned(),
|
||||
in_progress: () => m.tasks_status_in_progress(),
|
||||
blocked: () => m.tasks_status_blocked(),
|
||||
planned: () => m.tasks_status_planned(),
|
||||
completed: () => m.tasks_status_completed(),
|
||||
cancelled: () => m.tasks_status_cancelled(),
|
||||
}
|
||||
|
||||
interface TaskItem {
|
||||
taskId: string
|
||||
templateId: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
category: string
|
||||
status: string
|
||||
location: string | null
|
||||
scheduledStartAt: string | Date | null
|
||||
scheduledEndAt: string | Date | null
|
||||
assignedRole: string | null
|
||||
createdByUserId: string
|
||||
}
|
||||
|
||||
function formatTime(val: string | Date | null): string | null {
|
||||
if (!val) return null
|
||||
const d = typeof val === 'string' ? new Date(val) : val
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return STATUS_LABEL[status]?.() ?? status
|
||||
}
|
||||
|
||||
function TaskCard({ task, onAssignRole }: { task: TaskItem; onAssignRole: (taskId: string) => void }) {
|
||||
const cfg = STATUS_CONFIG[task.status] ?? { color: 'gray', order: 99 }
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="md"
|
||||
radius="md"
|
||||
className="card-hover"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
lineClamp={2}
|
||||
style={{ fontFamily: '"Cormorant", serif', fontSize: 16 }}
|
||||
>
|
||||
{asI18n(task.title)}
|
||||
</Text>
|
||||
<Badge size="xs" color={cfg.color} variant="light" style={{ flexShrink: 0 }}>
|
||||
{asI18n(statusLabel(task.status))}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" variant="dot" color={CATEGORY_COLORS[task.category] ?? 'gray'}>
|
||||
{asI18n(task.category)}
|
||||
</Badge>
|
||||
{task.assignedRole && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
{asI18n(task.assignedRole.replace(/_/g, ' '))}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{task.location && (
|
||||
<Group gap={4}>
|
||||
<MapPin size={14} color="#888" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(task.location)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{task.scheduledStartAt && (
|
||||
<Group gap={4}>
|
||||
<Clock size={14} color="#888" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(
|
||||
`${formatTime(task.scheduledStartAt)}${task.scheduledEndAt ? ` - ${formatTime(task.scheduledEndAt)}` : ''}`
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<TaskActions taskId={task.taskId} status={task.status} />
|
||||
{(task.status === 'open' || task.status === 'planned') && (
|
||||
<Button size="xs" variant="light" color="grape" onClick={() => onAssignRole(task.taskId)}>
|
||||
{m.tasks_assign_role()}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTaskDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [category, setCategory] = useState<string | null>(null)
|
||||
const [location, setLocation] = useState('')
|
||||
const [scheduledStartAt, setScheduledStartAt] = useState('')
|
||||
const [scheduledEndAt, setScheduledEndAt] = useState('')
|
||||
const [assignedRole, setAssignedRole] = useState<string | null>(null)
|
||||
|
||||
const assignToRole = usePikkuMutation('assignTaskToRole')
|
||||
const mutation = usePikkuMutation('createTaskInstance', {
|
||||
onSuccess: async (result) => {
|
||||
if (assignedRole && result.taskId) {
|
||||
await assignToRole.mutateAsync({ taskId: result.taskId, role: assignedRole })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
|
||||
notifications.show({
|
||||
title: m.tasks_created_title(),
|
||||
message: m.tasks_created_message({ title }),
|
||||
color: 'teal',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.tasks_error_title(),
|
||||
message: err.message ?? m.tasks_error_generic(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setCategory(null)
|
||||
setLocation('')
|
||||
setScheduledStartAt('')
|
||||
setScheduledEndAt('')
|
||||
setAssignedRole(null)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!title.trim() || !category) {
|
||||
notifications.show({
|
||||
title: m.tasks_validation_title(),
|
||||
message: m.tasks_validation_message(),
|
||||
color: 'orange',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
title: title.trim(),
|
||||
category: category as 'housekeeping' | 'kitchen' | 'maintenance' | 'operations' | 'retreat' | 'transport',
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
...(location.trim() ? { location: location.trim() } : {}),
|
||||
...(scheduledStartAt
|
||||
? { scheduledStartAt: new Date(scheduledStartAt).toISOString() }
|
||||
: {}),
|
||||
...(scheduledEndAt
|
||||
? { scheduledEndAt: new Date(scheduledEndAt).toISOString() }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.tasks_create_new_task()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.tasks_field_title()}
|
||||
placeholder={m.tasks_title_placeholder()}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={m.tasks_field_description()}
|
||||
placeholder={m.tasks_description_placeholder()}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={m.tasks_category()}
|
||||
placeholder={m.tasks_select_category()}
|
||||
data={CATEGORIES}
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={m.tasks_location()}
|
||||
placeholder={m.tasks_location_placeholder()}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label={m.tasks_scheduled_start()}
|
||||
type="datetime-local"
|
||||
value={scheduledStartAt}
|
||||
onChange={(e) => setScheduledStartAt(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.tasks_scheduled_end()}
|
||||
type="datetime-local"
|
||||
value={scheduledEndAt}
|
||||
onChange={(e) => setScheduledEndAt(e.currentTarget.value)}
|
||||
min={scheduledStartAt || undefined}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
label={m.tasks_assign_to_role_optional()}
|
||||
placeholder={m.tasks_select_role()}
|
||||
data={ROLE_OPTIONS}
|
||||
value={assignedRole}
|
||||
onChange={setAssignedRole}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.tasks_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending || assignToRole.isPending}>
|
||||
{m.tasks_create_task()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function TasksPage() {
|
||||
useLocale()
|
||||
const queryClient = useQueryClient()
|
||||
const [roleDrawerOpened, { open: openRoleDrawer, close: closeRoleDrawer }] = useDisclosure(false)
|
||||
const [createDrawerOpened, { open: openCreateDrawer, close: closeCreateDrawer }] = useDisclosure(false)
|
||||
const [roleTaskId, setRoleTaskId] = useState<string | null>(null)
|
||||
const [selectedRole, setSelectedRole] = useState<string | null>(null)
|
||||
|
||||
function handleOpenRoleDrawer(taskId: string) {
|
||||
setRoleTaskId(taskId)
|
||||
setSelectedRole(null)
|
||||
openRoleDrawer()
|
||||
}
|
||||
|
||||
const assignRoleMutation = usePikkuMutation('assignTaskToRole', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
|
||||
notifications.show({
|
||||
title: m.tasks_role_assigned_title(),
|
||||
message: m.tasks_role_assigned_message(),
|
||||
color: 'green',
|
||||
})
|
||||
closeRoleDrawer()
|
||||
setRoleTaskId(null)
|
||||
setSelectedRole(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.tasks_error_title(),
|
||||
message: err.message || m.tasks_error_assign_role(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const generateMutation = usePikkuMutation('generateRecurringTasks', {
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listTasks'] })
|
||||
notifications.show({
|
||||
title: m.tasks_generated_title(),
|
||||
message: m.tasks_generated_message({ count: result.generated }),
|
||||
color: 'green',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: m.tasks_error_title(),
|
||||
message: err.message || m.tasks_error_generate(),
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const { data, error: queryError } = usePikkuQuery('listTasks', { limit: 50, offset: 0 })
|
||||
const tasks = (data?.items ?? []) as TaskItem[]
|
||||
const error = queryError?.message ?? null
|
||||
|
||||
const grouped: Record<string, TaskItem[]> = {}
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
grouped[status] = []
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const key = KANBAN_STATUSES.includes(task.status) ? task.status : 'open'
|
||||
grouped[key]!.push(task)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.tasks_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.tasks_description()}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="xs"
|
||||
leftSection={<Repeat size={14} />}
|
||||
onClick={() => generateMutation.mutate({ date: new Date().toISOString().split('T')[0] })}
|
||||
loading={generateMutation.isPending}
|
||||
>
|
||||
{m.tasks_generate_recurring()}
|
||||
</Button>
|
||||
<Anchor component={Link} to="/tasks/templates" size="sm" c="dimmed" style={{ textDecoration: 'none' }}>
|
||||
{m.tasks_templates()}
|
||||
</Anchor>
|
||||
<Button variant="subtle" size="xs" onClick={openCreateDrawer} leftSection={<Plus size={14} />}>
|
||||
{m.tasks_new_task()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
|
||||
<Text size="sm" c="red">
|
||||
{asI18n(error)}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mobile: Accordion */}
|
||||
<Box hiddenFrom="sm">
|
||||
<Accordion
|
||||
variant="separated"
|
||||
radius="md"
|
||||
multiple
|
||||
defaultValue={KANBAN_STATUSES}
|
||||
styles={{
|
||||
item: {
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{KANBAN_STATUSES.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status]!
|
||||
const items = grouped[status]!
|
||||
return (
|
||||
<Accordion.Item key={status} value={status}>
|
||||
<Accordion.Control>
|
||||
<Group gap="xs">
|
||||
<Badge size="sm" color={cfg.color} variant="light">
|
||||
{items.length}
|
||||
</Badge>
|
||||
<Text fw={600} size="sm">
|
||||
{asI18n(statusLabel(status))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{items.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{m.tasks_no_status_tasks({ status: statusLabel(status).toLowerCase() })}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{items.map((task) => (
|
||||
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</Box>
|
||||
|
||||
{/* Desktop: Kanban columns */}
|
||||
<Box visibleFrom="sm">
|
||||
<SimpleGrid cols={4} spacing="md">
|
||||
{KANBAN_STATUSES.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status]!
|
||||
const items = grouped[status]!
|
||||
return (
|
||||
<Stack key={status} gap="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
<Badge size="sm" color={cfg.color} variant="filled">
|
||||
{items.length}
|
||||
</Badge>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
style={{ fontFamily: '"Cormorant", serif', fontSize: 16, color: '#3D2B1F' }}
|
||||
>
|
||||
{asI18n(statusLabel(status))}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: 'rgba(245, 240, 232, 0.5)',
|
||||
border: '2px dashed rgba(61, 43, 31, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.tasks_no_tasks()}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
items.map((task) => (
|
||||
<TaskCard key={task.taskId} task={task} onAssignRole={handleOpenRoleDrawer} />
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Drawer
|
||||
opened={roleDrawerOpened}
|
||||
onClose={closeRoleDrawer}
|
||||
title={m.tasks_assign_to_role()}
|
||||
position="right"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={m.tasks_role()}
|
||||
placeholder={m.tasks_select_role()}
|
||||
data={ROLE_OPTIONS}
|
||||
value={selectedRole}
|
||||
onChange={setSelectedRole}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={closeRoleDrawer}>
|
||||
{m.tasks_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
color="grape"
|
||||
onClick={() => {
|
||||
if (roleTaskId && selectedRole) {
|
||||
assignRoleMutation.mutate({ taskId: roleTaskId, role: selectedRole })
|
||||
}
|
||||
}}
|
||||
loading={assignRoleMutation.isPending}
|
||||
disabled={!selectedRole}
|
||||
>
|
||||
{m.tasks_assign_role()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<CreateTaskDrawer opened={createDrawerOpened} onClose={closeCreateDrawer} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
180
apps/app/src/pages/_authenticated.tasks.templates.tsx
Normal file
180
apps/app/src/pages/_authenticated.tasks.templates.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Title, Text, Card, Group, Stack, Badge, Table, Anchor, Box } from '@pikku/mantine/core'
|
||||
import { LayoutTemplate } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { CreateTemplateForm } from '@/components/create-template-form'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tasks/templates')({
|
||||
component: TaskTemplatesPage,
|
||||
})
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
housekeeping: 'pink',
|
||||
kitchen: 'yellow',
|
||||
maintenance: 'orange',
|
||||
operations: 'blue',
|
||||
retreat: 'violet',
|
||||
transport: 'cyan',
|
||||
}
|
||||
|
||||
interface TaskTemplate {
|
||||
templateId: string
|
||||
title: string
|
||||
description: string | null
|
||||
category: string
|
||||
isClaimable: boolean
|
||||
defaultLocation: string | null
|
||||
estimatedMinutes: number | null
|
||||
recurrenceCron: string | null
|
||||
}
|
||||
|
||||
function TaskTemplatesPage() {
|
||||
useLocale()
|
||||
// There is no list-templates RPC; new templates are created via the form below.
|
||||
const templates: TaskTemplate[] = []
|
||||
const error: string | null = null
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/tasks"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
style={{ textDecoration: 'none', marginBottom: 8, display: 'block' }}
|
||||
>
|
||||
{m.tasks_templates_back()}
|
||||
</Anchor>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.tasks_templates_title()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{m.tasks_templates_description()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Card padding="md" radius="md" style={{ backgroundColor: '#fff3f3' }}>
|
||||
<Text size="sm" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CreateTemplateForm />
|
||||
|
||||
{templates.length === 0 && !error ? (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<LayoutTemplate size={40} color="#aaa" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.tasks_templates_empty()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
padding={0}
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Desktop table */}
|
||||
<Box visibleFrom="sm">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.tasks_templates_col_title()}</Table.Th>
|
||||
<Table.Th>{m.tasks_templates_col_category()}</Table.Th>
|
||||
<Table.Th>{m.tasks_templates_col_claimable()}</Table.Th>
|
||||
<Table.Th>{m.tasks_templates_col_recurrence()}</Table.Th>
|
||||
<Table.Th>{m.tasks_templates_col_duration()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{templates.map((t) => (
|
||||
<Table.Tr key={t.templateId}>
|
||||
<Table.Td>
|
||||
<Text fw={500} size="sm">
|
||||
{asI18n(t.title)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={CATEGORY_COLORS[t.category] ?? 'gray'}>
|
||||
{asI18n(t.category)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={t.isClaimable ? 'teal' : 'gray'}>
|
||||
{t.isClaimable ? m.tasks_templates_yes() : m.tasks_templates_no()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(t.recurrenceCron ?? m.tasks_templates_one_time())}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(t.estimatedMinutes ? m.tasks_templates_minutes({ minutes: t.estimatedMinutes }) : '--')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<Box hiddenFrom="sm" p="md">
|
||||
<Stack gap="sm">
|
||||
{templates.map((t) => (
|
||||
<Card key={t.templateId} padding="sm" radius="sm" withBorder>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fw={600} size="sm">
|
||||
{asI18n(t.title)}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={CATEGORY_COLORS[t.category] ?? 'gray'}>
|
||||
{asI18n(t.category)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" variant="light" color={t.isClaimable ? 'teal' : 'gray'}>
|
||||
{t.isClaimable ? m.tasks_templates_claimable() : m.tasks_templates_not_claimable()}
|
||||
</Badge>
|
||||
{t.recurrenceCron && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
{m.tasks_templates_recurring()}
|
||||
</Badge>
|
||||
)}
|
||||
{t.estimatedMinutes && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.tasks_templates_minutes({ minutes: t.estimatedMinutes })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
5
apps/app/src/pages/_authenticated.tasks.tsx
Normal file
5
apps/app/src/pages/_authenticated.tasks.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tasks')({
|
||||
component: Outlet,
|
||||
})
|
||||
331
apps/app/src/pages/_authenticated.tsx
Normal file
331
apps/app/src/pages/_authenticated.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { createFileRoute, Outlet, Navigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AppShell,
|
||||
Burger,
|
||||
Group,
|
||||
NavLink,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Avatar,
|
||||
Divider,
|
||||
Box,
|
||||
Stack,
|
||||
Center,
|
||||
Loader,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
|
||||
import { m, mKey } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
Home,
|
||||
BedDouble,
|
||||
CheckSquare,
|
||||
User,
|
||||
Sailboat,
|
||||
DoorOpen,
|
||||
CalendarDays,
|
||||
UtensilsCrossed,
|
||||
Package,
|
||||
Users,
|
||||
FileText,
|
||||
Bell,
|
||||
LogOut,
|
||||
Menu as MenuIcon,
|
||||
LayoutDashboard,
|
||||
FileText as FileDescription,
|
||||
DollarSign,
|
||||
Bot,
|
||||
} from 'lucide-react'
|
||||
import { authClient, signOut } from '../lib/auth'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useRoles, hasPermission } from '@/lib/permissions'
|
||||
import { AuthContext, useSessionUser, type SessionUser } from '@/lib/auth-context'
|
||||
import { NotificationBell } from '@/components/notification-bell'
|
||||
import { LocaleSwitcher } from '@/components/locale-switcher'
|
||||
import { AIChatDrawer } from '@/components/ai-chat'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
component: AuthenticatedLayout,
|
||||
})
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
const { data: user, isPending } = useQuery({
|
||||
queryKey: ['session'],
|
||||
queryFn: async () => {
|
||||
const { data } = await authClient.getSession()
|
||||
return (data?.user as SessionUser | undefined) ?? null
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" />
|
||||
}
|
||||
return (
|
||||
<AuthContext.Provider value={user}>
|
||||
<AuthenticatedShell />
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
labelKey: string
|
||||
href: string
|
||||
icon: React.ElementType
|
||||
permission?: string
|
||||
}
|
||||
|
||||
const personalNav: NavItem[] = [
|
||||
{ labelKey: 'nav.dashboard', href: '/', icon: Home },
|
||||
{ labelKey: 'nav.my_stays', href: '/my/stays', icon: BedDouble },
|
||||
{ labelKey: 'nav.my_tasks', href: '/my/tasks', icon: CheckSquare },
|
||||
{ labelKey: 'nav.my_profile', href: '/my/profile', icon: User },
|
||||
]
|
||||
|
||||
const operationsNav: NavItem[] = [
|
||||
{ labelKey: 'nav.stays', href: '/stays', icon: BedDouble, permission: 'stays.view_all' },
|
||||
{ labelKey: 'nav.boats', href: '/boats', icon: Sailboat, permission: 'boats.view_all' },
|
||||
{ labelKey: 'nav.tasks', href: '/tasks', icon: CheckSquare, permission: 'tasks.view_all' },
|
||||
{ labelKey: 'nav.kitchen', href: '/kitchen', icon: UtensilsCrossed, permission: 'kitchen.view' },
|
||||
{ labelKey: 'nav.inventory', href: '/inventory', icon: Package, permission: 'inventory.request' },
|
||||
{ labelKey: 'nav.retreats', href: '/retreats', icon: CalendarDays, permission: 'retreats.view' },
|
||||
{ labelKey: 'nav.finance', href: '/finance', icon: DollarSign, permission: 'finance.view' },
|
||||
]
|
||||
|
||||
const managementNav: NavItem[] = [
|
||||
{ labelKey: 'nav.rooms', href: '/rooms', icon: DoorOpen, permission: 'rooms.view_all' },
|
||||
{ labelKey: 'nav.boat_management', href: '/boats/manage', icon: Sailboat, permission: 'boats.manage' },
|
||||
{ labelKey: 'nav.users', href: '/users', icon: Users, permission: 'users.manage' },
|
||||
{ labelKey: 'nav.audit_log', href: '/audit-log', icon: FileText, permission: 'audit_log.view' },
|
||||
]
|
||||
|
||||
const mobileTabItems: NavItem[] = [
|
||||
{ labelKey: 'nav.dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ labelKey: 'nav.my_stays', href: '/my/stays', icon: FileDescription },
|
||||
{ labelKey: 'nav.my_tasks', href: '/my/tasks', icon: CheckSquare },
|
||||
{ labelKey: 'nav.more', href: '#more', icon: MenuIcon },
|
||||
]
|
||||
|
||||
function AuthenticatedShell() {
|
||||
useLocale()
|
||||
const user = useSessionUser()
|
||||
const navigate = useNavigate()
|
||||
const [opened, { toggle, close }] = useDisclosure()
|
||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||
const [chatOpen, setChatOpen] = useState(false)
|
||||
|
||||
const roles = useRoles()
|
||||
const can = (permission?: string) =>
|
||||
!permission || hasPermission(roles, permission)
|
||||
const canUseAI = can('ai_agent.use')
|
||||
|
||||
const visibleOps = operationsNav.filter((item) => can(item.permission))
|
||||
const visibleMgmt = managementNav.filter((item) => can(item.permission))
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
const go = (href: string) => {
|
||||
navigate({ to: href as any })
|
||||
close()
|
||||
}
|
||||
|
||||
const renderNavLink = (item: NavItem) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
label={mKey(item.labelKey)}
|
||||
leftSection={<item.icon size={18} />}
|
||||
onClick={() => go(item.href)}
|
||||
styles={{ root: { borderRadius: 8 } }}
|
||||
/>
|
||||
)
|
||||
|
||||
const sidebar = (
|
||||
<Stack gap={0} p="sm">
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
|
||||
{m.nav_personal()}
|
||||
</Text>
|
||||
{personalNav.map(renderNavLink)}
|
||||
|
||||
{visibleOps.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
|
||||
{m.nav_operations()}
|
||||
</Text>
|
||||
{visibleOps.map(renderNavLink)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleMgmt.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" mb={4} px="sm">
|
||||
{m.nav_management()}
|
||||
</Text>
|
||||
{visibleMgmt.map(renderNavLink)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||
padding="md"
|
||||
styles={{
|
||||
main: {
|
||||
backgroundColor: '#F5F0E8',
|
||||
minHeight: '100vh',
|
||||
paddingBottom: isMobile ? 72 : undefined,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottom: '1px solid rgba(61, 43, 31, 0.08)',
|
||||
},
|
||||
navbar: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid rgba(61, 43, 31, 0.08)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<Group gap={8} align="center">
|
||||
<img src="/favicon-32x32.png" alt="PerAuset" width={28} height={28} />
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#2A7B88' }}
|
||||
>
|
||||
{m.brand()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<LocaleSwitcher />
|
||||
{canUseAI && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={() => setChatOpen(true)}
|
||||
aria-label={asI18n('AI Assistant')}
|
||||
>
|
||||
<Bot size={20} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<NotificationBell />
|
||||
|
||||
<Menu shadow="md" width={200} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
|
||||
<Avatar size="sm" color="teal" radius="xl">
|
||||
{user?.name?.[0]?.toUpperCase() ?? 'U'}
|
||||
</Avatar>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{asI18n(user?.name ?? 'User')}
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(user?.email ?? '')}
|
||||
</Text>
|
||||
</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={14} />}
|
||||
onClick={() => navigate({ to: '/my/profile' as any })}
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={14} />}
|
||||
color="red"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
{m.nav_sign_out()}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>{sidebar}</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Mobile bottom tab bar */}
|
||||
{isMobile && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 64,
|
||||
backgroundColor: '#ffffff',
|
||||
borderTop: '1px solid rgba(61, 43, 31, 0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
zIndex: 200,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{mobileTabItems.map((item) => (
|
||||
<Box
|
||||
key={item.labelKey}
|
||||
component="button"
|
||||
onClick={() => (item.href === '#more' ? toggle() : go(item.href))}
|
||||
style={mobileTabStyle}
|
||||
>
|
||||
<item.icon size={22} />
|
||||
<span>{mKey(item.labelKey)}</span>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{canUseAI && (
|
||||
<AIChatDrawer opened={chatOpen} onClose={() => setChatOpen(false)} />
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
const mobileTabStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: '8px 12px',
|
||||
textDecoration: 'none',
|
||||
color: '#3D2B1F',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
fontFamily: '"Raleway", sans-serif',
|
||||
}
|
||||
184
apps/app/src/pages/_authenticated.users.$userId.tsx
Normal file
184
apps/app/src/pages/_authenticated.users.$userId.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Anchor,
|
||||
Loader,
|
||||
Center,
|
||||
Title,
|
||||
Checkbox,
|
||||
Button,
|
||||
Alert,
|
||||
SimpleGrid,
|
||||
} from '@pikku/mantine/core'
|
||||
import { AlertCircle, Check } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/users/$userId')({
|
||||
component: UserDetailPage,
|
||||
})
|
||||
|
||||
function RoleEditor({
|
||||
userId,
|
||||
currentRoles,
|
||||
availableRoles,
|
||||
}: {
|
||||
userId: string
|
||||
currentRoles: string[]
|
||||
availableRoles: string[]
|
||||
}) {
|
||||
const [roles, setRoles] = useState<string[]>(currentRoles)
|
||||
|
||||
const mutation = usePikkuMutation('setUserRoles')
|
||||
|
||||
const toggleRole = (role: string) => {
|
||||
setRoles((prev) =>
|
||||
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
|
||||
)
|
||||
mutation.reset()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
mutation.mutate({ userId, memberRoles: roles })
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{mutation.isError && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="red" variant="light">
|
||||
{asI18n(mutation.error?.message ?? 'Failed to update roles')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutation.isSuccess && (
|
||||
<Alert icon={<Check size={16} />} color="green" variant="light">
|
||||
{m.users_roles_updated()}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }} spacing="sm">
|
||||
{availableRoles.map((role) => (
|
||||
<Checkbox
|
||||
key={role}
|
||||
label={asI18n(role.replace(/_/g, ' '))}
|
||||
checked={roles.includes(role)}
|
||||
onChange={() => toggleRole(role)}
|
||||
color="teal"
|
||||
styles={{ label: { textTransform: 'capitalize' } }}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSave} loading={mutation.isPending} color="teal" radius="md">
|
||||
{m.users_save_roles()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function UserDetailPage() {
|
||||
useLocale()
|
||||
const { userId } = Route.useParams()
|
||||
|
||||
const { data: user, isLoading: userLoading } = usePikkuQuery('getUser', { userId })
|
||||
|
||||
const { data: rolesData } = usePikkuQuery('listRoles', {})
|
||||
|
||||
const availableRoles: string[] = (rolesData?.roles ?? []).map((r) => r.key)
|
||||
|
||||
if (userLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{m.users_not_found_title()}
|
||||
</Title>
|
||||
<Text c="dimmed">{m.users_not_found_body()}</Text>
|
||||
<Anchor component={Link} to="/users" size="sm">
|
||||
{m.users_back()}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const name = user.displayName ?? user.name ?? user.email
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2} style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}>
|
||||
{asI18n(name)}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{asI18n(`${user.email}${user.mobileNumber ? ` | ${user.mobileNumber}` : ''}`)}
|
||||
</Text>
|
||||
</div>
|
||||
<Anchor component={Link} to="/users" size="sm">
|
||||
{m.users_back()}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group gap="lg">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{m.users_display_name()}
|
||||
</Text>
|
||||
<Text size="sm" mt={4}>
|
||||
{asI18n(user.displayName ?? '--')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow:
|
||||
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
order={4}
|
||||
mb="md"
|
||||
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
|
||||
>
|
||||
{m.users_role_management()}
|
||||
</Title>
|
||||
<RoleEditor
|
||||
userId={user.userId}
|
||||
currentRoles={user.memberRoles ?? []}
|
||||
availableRoles={availableRoles}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
263
apps/app/src/pages/_authenticated.users.index.tsx
Normal file
263
apps/app/src/pages/_authenticated.users.index.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Badge,
|
||||
Anchor,
|
||||
Loader,
|
||||
Center,
|
||||
Button,
|
||||
Drawer,
|
||||
TextInput,
|
||||
MultiSelect,
|
||||
} from '@pikku/mantine/core'
|
||||
import { UserPlus } from 'lucide-react'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
|
||||
import { usePermission } from '@/lib/permissions'
|
||||
import { PageHeader } from '@perauset/components'
|
||||
import { DataTable, type Column } from '@perauset/components'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/users/')({
|
||||
component: UsersPage,
|
||||
})
|
||||
|
||||
const roleColor: Record<string, string> = {
|
||||
admin: 'red',
|
||||
coordinator: 'violet',
|
||||
facilitator: 'grape',
|
||||
boat_coordinator: 'blue',
|
||||
kitchen_lead: 'orange',
|
||||
staff: 'teal',
|
||||
volunteer: 'green',
|
||||
community_member: 'cyan',
|
||||
guest: 'gray',
|
||||
}
|
||||
|
||||
const AVAILABLE_ROLES = [
|
||||
{ value: 'coordinator', label: 'Coordinator' },
|
||||
{ value: 'facilitator', label: 'Facilitator' },
|
||||
{ value: 'boat_coordinator', label: 'Boat Coordinator' },
|
||||
{ value: 'kitchen_lead', label: 'Kitchen Lead' },
|
||||
{ value: 'staff', label: 'Staff' },
|
||||
{ value: 'volunteer', label: 'Volunteer' },
|
||||
{ value: 'community_member', label: 'Community Member' },
|
||||
{ value: 'guest', label: 'Guest' },
|
||||
]
|
||||
|
||||
interface UserRow {
|
||||
userId: string
|
||||
name: string | null
|
||||
displayName: string | null
|
||||
email: string
|
||||
memberRoles: string[]
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
function InviteUserDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [email, setEmail] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [roles, setRoles] = useState<string[]>([])
|
||||
|
||||
const resetForm = () => {
|
||||
setEmail('')
|
||||
setName('')
|
||||
setDisplayName('')
|
||||
setRoles([])
|
||||
}
|
||||
|
||||
const mutation = usePikkuMutation('inviteUser', {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['listUsers'] })
|
||||
notifications.show({
|
||||
title: 'User invited',
|
||||
message: 'Invitation has been sent.',
|
||||
color: 'green',
|
||||
})
|
||||
resetForm()
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || 'Failed to invite user.',
|
||||
color: 'red',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email || !name || roles.length === 0) {
|
||||
notifications.show({
|
||||
title: 'Missing fields',
|
||||
message: 'Email, name, and at least one role are required.',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
mutation.mutate({
|
||||
email,
|
||||
name,
|
||||
displayName: displayName || undefined,
|
||||
memberRoles: roles,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.users_invite()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.common_email()}
|
||||
placeholder={asI18n('user@example.com')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={m.profile_name()}
|
||||
placeholder={asI18n('Name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={m.users_display_name()}
|
||||
placeholder={asI18n('Display name (optional)')}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={m.users_roles()}
|
||||
placeholder={asI18n('Select roles')}
|
||||
data={AVAILABLE_ROLES}
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button type="submit" color="teal" loading={mutation.isPending}>
|
||||
{m.users_send_invitation()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
useLocale()
|
||||
const canManage = usePermission('users.manage')
|
||||
const [inviteOpen, setInviteOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = usePikkuQuery('listUsers', { limit: 50, offset: 0 })
|
||||
|
||||
const users = (data?.items ?? []) as UserRow[]
|
||||
const total = data?.total ?? users.length
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader color="teal" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: Column<UserRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: m.users_name(),
|
||||
render: (user) => {
|
||||
const name = user.displayName ?? user.name ?? '--'
|
||||
return (
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(name)}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: m.common_email(),
|
||||
render: (user) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(user.email)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
label: m.users_roles(),
|
||||
render: (user) => {
|
||||
const roles = user.memberRoles ?? []
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{roles.map((role) => (
|
||||
<Badge key={role} color={roleColor[role] ?? 'gray'} variant="light" size="xs">
|
||||
{asI18n(role.replace(/_/g, ' '))}
|
||||
</Badge>
|
||||
))}
|
||||
{roles.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.users_no_roles()}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: asI18n(''),
|
||||
render: (user) => (
|
||||
<Anchor component={Link} to="/users/$userId" params={{ userId: user.userId } as never} size="sm">
|
||||
{m.users_edit_roles()}
|
||||
</Anchor>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={m.users_title()}
|
||||
description={m.users_registered_count({ count: total })}
|
||||
action={
|
||||
canManage ? (
|
||||
<Button
|
||||
leftSection={<UserPlus size={16} />}
|
||||
color="teal"
|
||||
radius="md"
|
||||
onClick={() => setInviteOpen(true)}
|
||||
>
|
||||
{m.users_invite()}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
rowKey={(user) => user.userId}
|
||||
emptyMessage="No users found."
|
||||
/>
|
||||
|
||||
<InviteUserDrawer opened={inviteOpen} onClose={() => setInviteOpen(false)} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
5
apps/app/src/pages/_authenticated.users.tsx
Normal file
5
apps/app/src/pages/_authenticated.users.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/users')({
|
||||
component: Outlet,
|
||||
})
|
||||
96
apps/app/src/pages/login.tsx
Normal file
96
apps/app/src/pages/login.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Text,
|
||||
Stack,
|
||||
Alert,
|
||||
Group,
|
||||
} from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { signIn, INVALID_CREDENTIALS } from '../lib/auth'
|
||||
|
||||
function LoginPage() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [email, setEmail] = useState(import.meta.env.DEV ? 'admin@perauset.org' : '')
|
||||
const [password, setPassword] = useState(import.meta.env.DEV ? 'test' : '')
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [error, setError] = useState<I18nString | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
await signIn(email, password)
|
||||
await qc.invalidateQueries({ queryKey: ['me'] })
|
||||
navigate({ to: '/' })
|
||||
} catch (err: any) {
|
||||
setError(err.message === INVALID_CREDENTIALS ? m.login_invalid_credentials() : m.login_error())
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F5F0E8',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Card shadow="lg" padding="xl" radius="lg" w={400} maw="100%" style={{ backgroundColor: '#ffffff' }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Group justify="center">
|
||||
<Text size="xl" fw={700} style={{ color: '#2A7B88', fontSize: 28 }}>
|
||||
{m.brand()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
{m.login_subtitle()}
|
||||
</Text>
|
||||
{error && (
|
||||
<Alert color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={m.common_email()}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={m.common_password()}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isPending} color="teal" size="md" radius="md" mt="sm">
|
||||
{m.login_cta()}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginPage,
|
||||
})
|
||||
16
apps/app/src/router.tsx
Normal file
16
apps/app/src/router.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
25
apps/app/tsconfig.json
Normal file
25
apps/app/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
44
apps/app/vite.config.ts
Normal file
44
apps/app/vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import { paraglideVitePlugin } from '@inlang/paraglide-js'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR on
|
||||
// message edits. Must run first.
|
||||
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
|
||||
tanstackStart({
|
||||
router: {
|
||||
routesDirectory: path.resolve(import.meta.dirname, 'src/pages'),
|
||||
generatedRouteTree: path.resolve(import.meta.dirname, 'src/routeTree.gen.ts'),
|
||||
routeFileIgnorePrefix: '-',
|
||||
quoteStyle: 'single',
|
||||
},
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
port: 6001,
|
||||
host: '0.0.0.0',
|
||||
// The standalone Pikku backend serves better-auth under /api/auth but data
|
||||
// RPC + REST routes at the root (/rpc, /users, …). The app calls both under
|
||||
// a single /api base, so strip /api for everything except /api/auth. Keeping
|
||||
// it same-origin (via this proxy) means no CORS and better-auth sees a
|
||||
// consistent Origin. Backend URL overridable for e2e via VITE_BACKEND_URL.
|
||||
proxy: {
|
||||
'/api/auth': process.env.VITE_BACKEND_URL || 'http://localhost:6002',
|
||||
'/api': {
|
||||
target: process.env.VITE_BACKEND_URL || 'http://localhost:6002',
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
alias: {
|
||||
'@': path.resolve(import.meta.dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user