Compare commits

...

262 Commits

Author SHA1 Message Date
e54ef9e596 chore: add visual preview HTML for MonacoUSA visual audit
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
2025-09-05 17:33:59 +02:00
5e5bcdfb4f Update member list ID display and move Mark Paid button
All checks were successful
Build And Push Image / docker (push) Successful in 2m0s
- Changed ID display from "DB ID: pending" to "ID Pending" for cleaner UI
- Moved Mark Paid button from Dues column to Actions column for better organization
- Increased Actions column width to accommodate the Mark Paid button
- Simplified the dues status display to just show the chip

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 17:03:26 +02:00
793d973f35 Remove notification buttons, terminal button, and 404-causing admin links
All checks were successful
Build And Push Image / docker (push) Successful in 1m58s
- Removed all notification bell buttons from admin, board, and member layouts
- Removed notification menu items from profile dropdowns across all layouts
- Removed terminal/console button and command palette from admin dashboard
- Removed non-existent admin navigation links that were causing 404 errors:
  - Roles & Permissions, Import/Export Members, Stripe Dashboard
  - Financial Reports, Email/Security Settings, System Logs
  - Backup & Restore, Analytics & Insights, Admin Profile

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 16:55:42 +02:00
12469a7952 Fix member management issues and add refined view
All checks were successful
Build And Push Image / docker (push) Successful in 2m0s
- Sort dues management cards alphabetically by last name
- Change 'Invalid Date' display to 'N/A' in formatDate functions
- Add new refined member management view with modern UI design
  - Glassmorphism effects and gradient accents
  - Enhanced stat cards with progress indicators
  - Improved search and filter interface
  - Better card and table layouts
  - Smooth animations and transitions

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 16:26:59 +02:00
183bba0c9e Fix Edge browser black background issue in Board and Member portals
All checks were successful
Build And Push Image / docker (push) Successful in 2m11s
- Added background-color: #fafafa to v-app element in both layouts
- Added solid background-color fallback to .glass-main class before gradients
- Ensures Edge and other browsers show light background even without gradient support
- Fixes transparent rgba gradient rendering issues in Edge

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:55:05 +02:00
1162d0c9ca Add Edge browser compatibility for dashboard backgrounds
All checks were successful
Build And Push Image / docker (push) Successful in 1m53s
- Added background-color fallback for browsers without gradient support
- Split background property for better cross-browser compatibility
- Ensures Edge and other browsers display light background correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 23:18:20 +02:00
64dbc78569 Fix black background issue on Board and Member portal pages
All checks were successful
Build And Push Image / docker (push) Successful in 1m52s
- Added board-dashboard class wrapper to Board pages (Governance, Meetings, Members)
- Added member-dashboard class wrapper to Member pages (Events, Profile, Resources)
- All pages now properly display the light gradient background matching admin dashboard

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 23:06:38 +02:00
9800245b7e Fix member ID display in dues management cards
All checks were successful
Build And Push Image / docker (push) Successful in 1m52s
- Changed to display actual member_id field value
- Show 'Pending' when member_id is not set instead of generating MUSA-ID format
- Ensures consistency with actual database member IDs

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 22:55:19 +02:00
d13b9837b3 Fix admin dashboard black background issue
Some checks failed
Build And Push Image / docker (push) Has been cancelled
- Added proper background color (#f5f5f5) to admin dashboard
- Set min-height to ensure full viewport coverage

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 22:53:35 +02:00
95c253a552 UI improvements for Board Portal dashboard
All checks were successful
Build And Push Image / docker (push) Successful in 2m0s
- Added profile picture between welcome message and title
- Removed Events and Members boxes from dashboard
- Added distinct borders and icon to Dues Management card
- Moved hamburger menu to the right side of app bar
- Removed notification bell icon from app bar
- Enhanced profile card appearance in sidebar with gradient background
- Fixed Mark as Paid button alignment to be inline with other action buttons
- Added support for displaying multiple nationality flags in dues cards

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 22:49:03 +02:00
b67100df2a Add support for multiple nationalities display with flags
All checks were successful
Build And Push Image / docker (push) Successful in 2m18s
- Create MultipleCountryFlags component to display multiple country flags
- Support comma-separated nationality values (e.g., 'FR,MC,US')
- Update admin members page to use MultipleCountryFlags in both list and grid views
- Update board members page to display nationalities with flags
- Add nationality column to board members table
- Update member forms to support multiple nationality selection
- Display flags with slight overlap for space efficiency, expand on hover
- Maintain backward compatibility with single nationality values

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 19:08:53 +02:00
d34d16fda1 Fix critical member management issues: dues tracking, member IDs, and profile display
All checks were successful
Build And Push Image / docker (push) Successful in 2m20s
- Fix dues payment logic to automatically calculate payment_due_date as 1 year from payment date
- Remove redundant dues_paid_until field and replace with payment_due_date throughout
- Implement member ID generation system with format MUSA-YYYY-XXXX
- Create migration endpoints for generating member IDs and fixing payment dates
- Update admin members page to display actual member_id from database
- Ensure ProfileAvatar components use correct member_id field
- Add support for profile images in list and grid views with initials fallback
- Fix countries export alias for backward compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 18:45:47 +02:00
67bb9e32ac removed screenshots from repo
All checks were successful
Build And Push Image / docker (push) Successful in 2m8s
2025-09-04 16:13:05 +02:00
f57f6b6bb2 Fix countries export in utils/countries.ts - add lowercase alias for backward compatibility
All checks were successful
Build And Push Image / docker (push) Successful in 2m13s
The ViewMemberDialog component was importing 'countries' (lowercase) but the file only exported 'COUNTRIES' (uppercase). Added an export alias to maintain backward compatibility.
2025-09-04 14:39:34 +02:00
3e7d04c521 Redesign member management section with enhanced UI/UX
Some checks failed
Build And Push Image / docker (push) Failing after 1m34s
- Add dual view modes (list and grid) with toggle functionality
- Enhance list view with profile avatars, nationality flags, and dues status
- Implement responsive grid view with member cards
- Add inline 'Mark as Paid' functionality in both views
- Redesign ViewMemberDialog with modern hero header and tabbed interface
- Add payment history, activity timeline, and notes management tabs
- Integrate profile avatars throughout the application
- Make all member entries clickable to open detailed modal
- Clean up console.log statements and remove unused code
- Improve overall design consistency with glass morphism effects

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 14:33:44 +02:00
bdbb5694ae Fix admin payments page to use correct API response structure (data.list instead of data.members)
All checks were successful
Build And Push Image / docker (push) Successful in 2m10s
2025-09-04 14:00:34 +02:00
a41a483de5 Fix member directory display in admin and board views - properly handle API response structure
All checks were successful
Build And Push Image / docker (push) Successful in 2m12s
2025-09-04 13:49:34 +02:00
f84adeff21 Add NocoDB configuration tab to admin settings for persistent database connectivity
All checks were successful
Build And Push Image / docker (push) Successful in 2m12s
2025-09-04 13:36:03 +02:00
3b455a3989 Fix build errors: Add all missing SCSS variables and mixins for dashboard-v2 compatibility
All checks were successful
Build And Push Image / docker (push) Successful in 2m21s
- Added missing primary color variations (-600, -700, -800)
- Added semantic color variations (-500, -500, -500, -500)
- Added additional color variables (-500, -600)
- Added missing shadow variables (-inset-sm, -soft-md)
- Added spring-smooth easing and transition variables
- Added neumorphic-card, morphing-dropdown, neumorphic-button, and responsive mixins
- Fixed duplicate -4xl variable
- Ensured backward compatibility with existing dashboard-v2 pages

This resolves all build errors and ensures the design system supports both old and new dashboard implementations.
2025-09-04 13:17:04 +02:00
8c2847cbd9 Fix broken design-mockups imports in dashboard-v2 pages
Some checks failed
Build And Push Image / docker (push) Failing after 1m18s
- Updated imports to use new design-system-v2.scss
- Fixed build error caused by deleted design-mockups directory

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 11:50:07 +02:00
4ba24f8626 Implement comprehensive design system improvements
Some checks failed
Build And Push Image / docker (push) Failing after 1m14s
- Created new design-system-v2.scss with modern design tokens
- Enhanced Vuetify theme configuration with refined colors
- Added professional dashboard styles component
- Improved typography, spacing, and visual hierarchy
- Implemented glass morphism effects with better contrast
- Added smooth animations and micro-interactions
- Improved responsive design for mobile devices
- Enhanced stat cards, data tables, and navigation
- Fixed color contrast issues identified in audit
- Added professional gradients and shadows

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 11:45:17 +02:00
e949df311b design mockups
All checks were successful
Build And Push Image / docker (push) Successful in 2m3s
2025-09-03 22:15:26 +02:00
4d24315103 Mockups for Designs
All checks were successful
Build And Push Image / docker (push) Successful in 1m55s
2025-09-03 21:04:44 +02:00
e75de8b9f4 Fix board dashboard layout - now uses correct board layout
All checks were successful
Build And Push Image / docker (push) Successful in 2m2s
- Changed board dashboard from 'dashboard' layout to 'board' layout
- This fixes the missing hamburger menu and sidebar toggle functionality
- Removed debug console.log statements from board layout
- Board dashboard now has same sidebar behavior as admin and member dashboards

The issue was that board/dashboard/index.vue was using the old 'dashboard'
layout instead of the proper 'board' layout that has rail mode support.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 10:42:35 +02:00
0199e5ba8c Debug and fix board dashboard hamburger menu visibility
All checks were successful
Build And Push Image / docker (push) Successful in 1m57s
- Added debug logging to track toggleDrawer function calls
- Added explicit CSS to ensure hamburger button is visible
- Added mounted hook logging to verify initial state
- Ensured button has proper z-index and display properties

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 00:04:01 +02:00
299a49c258 Fix hamburger menu visibility in all dashboards
All checks were successful
Build And Push Image / docker (push) Successful in 1m47s
- Removed CSS that was hiding all hamburger menu buttons
- This was preventing the sidebar toggle button from appearing
- Now all dashboards (admin, board, member) have working toggle buttons
- Board dashboard now has same functionality as admin and member views

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 23:41:09 +02:00
f68e3afacd Fix sidebar width and profile section design issues
All checks were successful
Build And Push Image / docker (push) Successful in 1m48s
- Increased collapsed sidebar width from 80px to 100px for better icon visibility
- Updated rail-width prop to 100 in all three layouts (admin, board, member)
- Fixed profile section avatar sizing - now uses size 32 in collapsed mode
- Simplified menu button to always show vertical dots icon
- Improved spacing and alignment in profile section for both states
- Removed conditional button variants for cleaner, consistent design

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 23:35:28 +02:00
557f9b6260 Fix board dashboard sidebar navigation in collapsed mode
All checks were successful
Build And Push Image / docker (push) Successful in 2m3s
- Added collapsed mode icons for Members and Events sections
- Members and Events groups now show as single icons with tooltips when sidebar is collapsed
- Maintains badge for pending applications in collapsed mode
- Ensures consistent navigation experience across all dashboard layouts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 23:21:56 +02:00
c37e0d0b1b Fix sidebar collapse and redesign profile section
Some checks failed
Build And Push Image / docker (push) Has been cancelled
- Increased rail mode width from 56px to 80px to properly fit icons
- Updated rail-width prop in all three layout files (admin, board, member)
- Redesigned profile section with improved layout and positioning
- Added horizontal layout in expanded mode with avatar, info, and menu button
- Implemented responsive design that switches to vertical layout in collapsed mode
- Enhanced menu with colorful icons and hover effects
- Added role badges (Admin, Board, Member) for better visual identification
- Improved glass morphism effects on profile card and menu
- Added smooth transitions and hover animations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 23:19:50 +02:00
9e4f037917 Fix sidebar collapse by removing CSS width overrides
All checks were successful
Build And Push Image / docker (push) Successful in 1m54s
- Modified main.scss to respect Vuetify 3 rail mode
- Removed !important width overrides that blocked collapse
- Added conditional CSS: 280px when expanded, 56px when in rail mode
- Sidebar now properly collapses/expands when toggle button is clicked
- Preserves glass morphism effects and smooth transitions

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 23:08:06 +02:00
52c10a6c1b Fix navigation drawer collapse functionality in all layouts
All checks were successful
Build And Push Image / docker (push) Successful in 1m53s
- Replace dynamic width binding with static width and rail-width props
- Remove Vuetify 2 pattern (:width="miniVariant ? 56 : 280")
- Add Vuetify 3 compatible props (width="280" rail-width="56")
- Applied fix to admin.vue, board.vue, and member.vue layouts
- Sidebar now properly collapses to rail mode when toggled

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 22:42:15 +02:00
f20c1ef96a Fix sidebar collapse functionality for all three layouts
All checks were successful
Build And Push Image / docker (push) Successful in 2m7s
- Changed from fixed width to dynamic width based on miniVariant state
- Replaced v-show with v-if for list groups to properly hide when collapsed
- Added proper template conditionals for logo section
- Added tooltips for all single navigation items when in rail mode
- Fixed badge display to use dot when collapsed
- Ensured proper title binding with undefined when collapsed

This fixes the sidebar collapse issue across admin, board, and member layouts.
2025-08-31 19:56:54 +02:00
8954621813 CRITICAL FIX: Remove deprecated 'app' prop to enable sidebar collapse
All checks were successful
Build And Push Image / docker (push) Successful in 2m1s
- Removed 'app' prop from v-navigation-drawer in all layouts
- Removed 'app' prop from v-app-bar in all layouts
- The 'app' prop is deprecated in Vuetify 3 and was preventing rail mode
- This was the root cause of sidebar not collapsing

Vuetify 3 automatically handles layout participation without the app prop.
The presence of this deprecated prop was causing Vuetify to ignore the
rail prop entirely, preventing the sidebar from collapsing.
2025-08-31 19:47:38 +02:00
185ac24067 Fix sidebar collapse functionality for all three layouts
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Remove manual width control that was conflicting with rail prop
- Use fixed width and rail-width props for proper Vuetify 3 behavior
- Simplify toggleDrawer function to just toggle miniVariant
- Add v-show directive to hide list groups in rail mode
- Applied fixes to admin.vue, member.vue, and board.vue layouts

The sidebar now properly collapses to 56px rail mode showing only icons,
and expands to 280px full width showing complete navigation items.
2025-08-31 19:36:28 +02:00
c99599f7a2 Fix sidebar collapse functionality in all layouts
All checks were successful
Build And Push Image / docker (push) Successful in 2m3s
- Add permanent prop to navigation drawer
- Add :expand-on-hover='false' to prevent hover expansion
- Adjust width from 64 to 56 for better collapsed view
- Ensures sidebar properly collapses across admin, member, and board portals
2025-08-31 19:22:28 +02:00
abd71445ab Fix registration API to use new group-based user creation method
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Replace deprecated createUserWithRoleRegistration with createUserWithGroupAssignment
- Update comments to reflect group-based system instead of role-based
- Resolves registration error when creating new users
2025-08-31 19:13:09 +02:00
cbf97254a2 Fix sidebar collapse functionality across all layouts
All checks were successful
Build And Push Image / docker (push) Successful in 2m2s
- Change from :mini-variant to :rail for Vue 3/Vuetify 3 compatibility
- Fix width prop to use numbers instead of strings
- Add missing loading ref in board members page
- Ensure consistent collapse behavior across admin, member, and board layouts
2025-08-31 19:06:07 +02:00
c9e181e8a8 Sort member lists by last name instead of first name
All checks were successful
Build And Push Image / docker (push) Successful in 2m11s
- Updated member-list.vue to sort by last name with new sort options
- Changed default sort to lastname-asc
- Added Last Name and First Name sort options in dropdown
- Updated board/members/index.vue to include name field and sort by last name
- Updated admin/members/index.vue to include name field and sort by last name
- All member lists now consistently sort alphabetically by last name
2025-08-31 18:58:16 +02:00
ce7d5af450 Restore fully collapsible sidebar with mini-variant mode
All checks were successful
Build And Push Image / docker (push) Successful in 1m57s
- Added three-state sidebar (closed, mini, full) across all layouts
- Positioned collapse toggle button in app bar
- Made logo and text responsive to collapsed state
- Added proper tooltips for mini mode navigation
- Consistent implementation across board, admin, and member portals
2025-08-31 18:53:21 +02:00
70e79d2618 Replace all mock data in admin and board pages with real data
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Admin members page now loads real member data from NocoDB API
- Admin users page fetches actual users from Keycloak with tier determination
- Board members page uses real member data with proper transformations
- Admin payments page generates payment records from dues tracking data
- Created new /api/admin/users endpoint for Keycloak user management
- All stats cards now calculate from real data instead of hardcoded values
- Removed all mock/placeholder data arrays from production pages

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:43:04 +02:00
1aef356d78 Replace all mock/placeholder data with real data systems
All checks were successful
Build And Push Image / docker (push) Successful in 2m13s
- Added getUserCount() method to Keycloak admin for real user statistics
- Replaced hardcoded userCount (25) with live Keycloak data in admin stats
- Fixed board meeting API to query real events, removed Jan 15 2025 fallback
- Updated board stats to count real events instead of hardcoded 3
- Created member-tiers service for proper tier determination
- Created dues-calculator service for accurate dues tracking
- Updated auth callback to use member-tiers service
- Updated overdue-count API to use dues-calculator
- Added data quality tracking with confidence levels
- Added proper error handling - returns null/0 instead of fake data
- Included source tracking for all data (live/calculated/fallback)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:28:38 +02:00
9d93f0ca84 Standardize avatar display across application
All checks were successful
Build And Push Image / docker (push) Successful in 2m6s
- Replace all hardcoded avatars with ProfileAvatar component
- Update admin and board layouts to use ProfileAvatar
- Update DuesActionCard and DuesOverdueBanner components
- Update admin users list to use ProfileAvatar
- Ensure consistent display of profile pictures with initials fallback
- All avatars now show either user's profile picture or initials

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:06:50 +02:00
bf75c51b32 Fix glass morphism and Bento grid production issues
All checks were successful
Build And Push Image / docker (push) Successful in 2m0s
- Added browser compatibility fallbacks with @supports for backdrop-filter
- Fixed SCSS syntax errors with mixins and !important
- Added global production overrides for glass morphism effects
- Enhanced Bento grid implementation with proper specificity
- Improved mobile responsiveness for small screens
- Added fallback colors for browsers without backdrop-filter support
- Fixed inset property for wider browser compatibility
- Ensured sidebar fixed width and removed hamburger menus globally
- Added animation classes and keyframes globally
2025-08-31 17:45:20 +02:00
a85be39ffa Remove rotation animations from circular buttons on hover
All checks were successful
Build And Push Image / docker (push) Successful in 1m54s
- Removed rotate(5deg) from icon buttons hover state
- Removed rotate(5deg) from FAB buttons hover state
- Removed icon-hover-rotate mixin from nav items
- Keep scale animations but remove tilt/rotation effects

Buttons now scale without rotating for cleaner interaction
2025-08-31 16:48:53 +02:00
363d3367fc Fix all build errors and complete design system implementation
All checks were successful
Build And Push Image / docker (push) Successful in 1m51s
- Fixed v-col and v-row closing tag mismatches in board dashboard
- Added missing SCSS import for enhanced-glass mixin in admin dashboard
- Verified build passes successfully with all changes
- Comprehensive audit completed to prevent future syntax errors

All Vue templates now properly structured with correct closing tags
Build tested locally and passes without errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 16:43:03 +02:00
1758dec6ac Fix syntax error in board dashboard - correct closing tags
Some checks failed
Build And Push Image / docker (push) Failing after 55s
2025-08-31 16:34:54 +02:00
1c4cf8ac19 Complete design system implementation with glass morphism
Some checks failed
Build And Push Image / docker (push) Failing after 1m0s
- Enhanced member dashboard with glass morphism effects (30px blur)
- Implemented Bento grid layout on member dashboard
- Added comprehensive animation system:
  - Shimmer animations for logos and badges
  - Pulse animations for notifications
  - Float animations for dashboard elements
  - Modal enter/leave transitions
- Applied glass effects to all form inputs with floating labels
- Created glass morphism styles for dialogs and modals
- Standardized all buttons with Monaco gradient styles
- Added hover effects and animations throughout
- Established consistent design patterns across all dashboards

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 16:31:34 +02:00
ae18ce1786 Apply glass morphism and Bento grid to admin and board dashboards
Some checks failed
Build And Push Image / docker (push) Failing after 1m1s
- Implemented enhanced glass morphism effects with 30px blur
- Added Bento grid layout system (12-column grid)
- Created gradient text effects for dashboard titles
- Added animated entrance effects for all cards
- Implemented stat cards with gradient values
- Added responsive breakpoints for mobile/tablet
- Unified button styles with hover animations
- Removed basic elevation cards in favor of glass cards
2025-08-31 16:07:17 +02:00
7685cd130f Remove hamburger buttons and implement enhanced sidebar design across all layouts
All checks were successful
Build And Push Image / docker (push) Successful in 2m6s
- Removed hamburger menu buttons from all layouts (member, admin, board, dashboard)
- Updated all sidebars to fixed 280px width (non-collapsible)
- Enhanced glass morphism effects with 30px blur
- Implemented vertical profile card with small avatar
- Added consistent animations and hover effects
- Updated design documentation with new specifications
2025-08-31 15:38:19 +02:00
e74f12eaa0 Fix profile card avatar size and menu button
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Reduced avatar size from 'large' to 'small' for better proportions
- Made menu button extra small and moved it inside profile-content
- Positioned menu button absolutely in top-right of content area
- Reduced online indicator size to match smaller avatar
- Added subtle opacity to menu button (visible on hover)
- Improved overall visual balance of profile card

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 15:16:15 +02:00
36b8f9d2d4 Improve profile card layout in sidebar
All checks were successful
Build And Push Image / docker (push) Successful in 2m6s
- Made profile card taller with min-height of 180px
- Stacked profile image above name and email vertically
- Centered profile content for better visual balance
- Increased profile avatar size to 'large' for better visibility
- Moved menu button to absolute position in top-right corner
- Adjusted font sizes slightly for better readability
- Fixed text overflow with proper ellipsis

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 15:08:54 +02:00
6e0e640f1d Remove sidebar collapse functionality entirely
All checks were successful
Build And Push Image / docker (push) Successful in 2m14s
- Removed all collapse/expand functionality from sidebar
- Removed collapse button and chevron icons
- Removed isCollapsed state and toggle logic
- Removed all CSS related to collapsed states
- Simplified navigation items without tooltips
- Fixed sidebar at 280px width permanently
- Cleaned up profile footer without conditional rendering

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 15:01:06 +02:00
5af0f2e4c0 Increase collapsed sidebar width to 140px
All checks were successful
Build And Push Image / docker (push) Successful in 1m57s
- Changed collapsed width from 100px to 140px for better icon and content spacing
- Prevents squishing of navigation icons and profile image

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 14:49:15 +02:00
ab706298a6 Fix sidebar styling issues
All checks were successful
Build And Push Image / docker (push) Successful in 1m54s
- Increased collapsed sidebar width from 80px to 100px for better spacing
- Removed rose/pink tint from sidebar header section
- Changed header background to neutral white/gray gradient
- Added subtle border and shadow to header section for better definition

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 14:44:52 +02:00
eb6efe7c90 Enhanced sidebar with beautiful glass morphism effects
All checks were successful
Build And Push Image / docker (push) Successful in 2m29s
- Added enhanced glass morphism mixins with gradients and inner glows
- Implemented collapsible/expandable sidebar with rail mode
- Added smooth animations for navigation items (shimmer, pulse, hover effects)
- Enhanced profile section with online indicator animation
- Implemented tooltip system for collapsed state
- Added localStorage persistence for sidebar state
- Improved transitions with fade effects and sliding indicators
- Updated SCSS with new animation mixins (ripple, icon rotation)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 14:32:27 +02:00
696b321373 Simplify member dashboard - remove points, payments, stats, and social features
All checks were successful
Build And Push Image / docker (push) Successful in 1m57s
2025-08-31 14:12:58 +02:00
41eeb8650c Fix SCSS syntax error in BentoGrid component
All checks were successful
Build And Push Image / docker (push) Successful in 1m55s
- Fixed :deep() selector usage with parent selectors
- Separated each modifier class into its own :deep() selector
- Resolves build error with Sass compilation

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 13:53:05 +02:00
4d0591ce7d Redesign member dashboard with modern bento grid layout and animations
Some checks failed
Build And Push Image / docker (push) Failing after 1m11s
- Updated design philosophy to v2.0 with focus on beauty and interactivity
- Added @vueuse/motion for advanced animations
- Created reusable dashboard components:
  - BentoGrid: Flexible grid layout system
  - StatsCard: Animated statistics with sparklines
  - ProfileCard: Premium profile display with progress
  - ActivityTimeline: Beautiful timeline with staggered animations
  - EventsCard: Upcoming events display
  - PaymentCard: Payment status and history
  - QuickActionCard: Animated action buttons
- Rebuilt member dashboard with bento grid layout
- Added glass morphism effects throughout
- Implemented micro-interactions and hover effects
- Added gradient text effects and decorative elements

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 13:50:07 +02:00
aba6c2ecac Implement comprehensive glass morphism UI redesign
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Created global SCSS architecture with Monaco design system
- Implemented glass morphism effects across all layouts
- Updated admin layout with premium glass effects and dark gradients
- Updated board layout with balanced glass effects and medium gradients
- Updated member layout with light glass effects and soft gradients
- Added floating logo animations and smooth transitions
- Implemented role-based visual hierarchy through gradient variations
- Created comprehensive documentation for glass morphism patterns
- Aligned all changes with established design philosophy in design-system.md

Key features:
- Glass navigation drawers with backdrop blur
- Gradient app bars with role-specific variations
- Glass icon buttons with hover effects
- Monaco red color spectrum integration
- Responsive design with mobile optimizations
- Performance-optimized blur effects

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 13:10:45 +02:00
9fa9db9b8a Complete infrastructure reorganization to role-based structure
All checks were successful
Build And Push Image / docker (push) Successful in 1m50s
- Created all missing admin pages (users, settings, events, members, payments)
- Created board pages (governance, meetings)
- Updated dashboard router to use new /admin, /board, /member structure
- Added isMember alias to useAuth composable for consistency
- All pages now use correct role-based layouts and middleware
- Build verified successfully

The platform now has a clean separation:
- /admin/* - Administrator dashboard and tools
- /board/* - Board member governance and meetings
- /member/* - Member portal and resources

Next steps: Complete remaining member pages and clean up old dashboard files
2025-08-30 22:44:04 +02:00
7c49b9db66 fix: Revert dashboard routing to use existing structure
All checks were successful
Build And Push Image / docker (push) Successful in 1m49s
- Created missing admin.ts middleware file
- Reverted dashboard router to use old /dashboard/{tier} structure
- Production deployment still uses the old structure
- New role-based structure (/admin/dashboard, etc.) will be enabled later
- Fixes dashboard display issue where nothing was showing

The new structure is ready but needs gradual deployment to avoid breaking production.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 22:24:27 +02:00
8b1dd4d083 fix: Replace logo.svg references with actual logo asset
All checks were successful
Build And Push Image / docker (push) Successful in 1m46s
- Fixed build error by replacing /logo.svg with /MONACOUSA-Flags_376x376.png
- Updated all auth mockup pages (login, signup, forgot-password)
- Ensured build succeeds without import errors
- Tested complete build process successfully

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 22:16:15 +02:00
1471f7d7b3 fix: Update admin dashboard and fix template errors
Some checks failed
Build And Push Image / docker (push) Failing after 1m1s
- Fixed missing closing tags in members mockup page
- Updated admin dashboard to use new admin layout
- Added comprehensive system monitoring interface
- Fixed template structure issues in both files
- Removed v-container wrapper from admin dashboard
- Added proper list view template structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 22:11:27 +02:00
d9d8627e97 feat: Reorganize platform into member, board, and admin sections
Some checks failed
Build And Push Image / docker (push) Failing after 55s
Major platform reorganization implementing role-based portal sections:

## Infrastructure Changes
- Created role-based middleware for member, board, and admin access
- Updated main dashboard router to redirect based on highest privilege
- Implemented access hierarchy: Admin > Board > Member

## New Layouts
- Member layout: Simplified navigation for regular members
- Board layout: Enhanced tools for board member management
- Admin layout: Full system administration capabilities

## Member Portal (/member/*)
- Dashboard: Profile overview, events, payments, activity tracking
- Events: Browse, register, and manage event participation
- Profile: Complete personal and professional information management
- Resources: Access to documents, guides, FAQs, and quick links

## Board Portal (/board/*)
- Dashboard: Statistics, dues management, board-specific tools
- Members: Comprehensive member management with filtering

## Admin Portal (/admin/*)
- Dashboard: System overview and administrative controls (existing)

## Design Implementation
- Monaco red (#dc2626) as primary accent color
- Modern card-based layouts with consistent spacing
- Responsive design for all screen sizes
- Glass morphism effects for enhanced visual appeal

This reorganization provides clear separation of concerns based on user privileges while maintaining a cohesive user experience across all sections.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 22:00:59 +02:00
27e38d98e5 Fix Icon component to use Lucide icons library
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Installed lucide-vue-next package
- Rewrote Icon.vue to use Lucide icons instead of non-existent icon files
- Added comprehensive icon mapping for all commonly used icons
- Build now succeeds without errors
2025-08-30 18:44:18 +02:00
c1f986bc07 Fix SCSS syntax error in FloatingInput component
Some checks failed
Build And Push Image / docker (push) Failing after 1m11s
- Fixed incorrect '&' selector placement causing build failure
- Created members page mockup with multiple view modes
- Added MemberCard component for member listings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 18:33:50 +02:00
d4ecd15914 Fix build error by adding sass dependency
Some checks failed
Build And Push Image / docker (push) Failing after 1m10s
- Added sass package as dev dependency for SCSS support
- Created MemberCard component for member listings
- Build should now succeed with SCSS compilation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 18:30:01 +02:00
c39936984b Implement MonacoUSA Portal redesign foundations
Some checks failed
Build And Push Image / docker (push) Failing after 1m11s
- Added VueUse Motion for animations with custom presets
- Created base UI component library with glass morphism effects:
  * GlassCard - Flexible card component with 4 variants
  * MonacoButton - Multi-variant button system
  * FloatingInput - Modern input with floating labels
  * StatsCard - Dashboard statistics display
  * AnimatedNumber - Smooth number animations
  * Icon system - Modular icon components
- Created comprehensive page mockups:
  * Dashboard mockup with stats, activity feed, and widgets
  * Events page with filtering, search, and calendar
- Established Monaco brand design system (red #dc2626)
- Configured spring animations and glass effects

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 18:25:21 +02:00
de75d2d764 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m36s
2025-08-15 16:23:37 +02:00
968fa6febb fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m47s
2025-08-15 16:15:55 +02:00
368293e0e2 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m28s
2025-08-15 16:10:12 +02:00
6f2c843cfd fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m33s
2025-08-15 16:04:03 +02:00
cdacb4a114 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m39s
2025-08-15 16:02:14 +02:00
136eb5229e fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m39s
2025-08-15 15:58:33 +02:00
031ed4fd9e fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m27s
2025-08-15 15:55:01 +02:00
4b78080f53 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m34s
2025-08-15 15:53:13 +02:00
c6edd6d25d fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m28s
2025-08-15 15:45:38 +02:00
cde48de9ff fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m22s
2025-08-15 15:24:41 +02:00
17b78b3514 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m36s
2025-08-15 15:22:24 +02:00
2cbef70d82 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m23s
2025-08-15 15:18:09 +02:00
df4b89a45a fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m24s
2025-08-15 15:13:47 +02:00
dc0a3c6a2f fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m36s
2025-08-15 15:06:39 +02:00
1f50c2adb7 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m42s
2025-08-15 15:02:56 +02:00
e02d6c3e33 updates
All checks were successful
Build And Push Image / docker (push) Successful in 1m43s
2025-08-15 14:59:48 +02:00
df6d549573 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m31s
2025-08-15 14:52:18 +02:00
a8022d8fb3 fixes
Some checks failed
Build And Push Image / docker (push) Failing after 1m6s
2025-08-15 14:48:19 +02:00
888059a612 Implement dues reminder system with monthly payment cycle
Some checks failed
Build And Push Image / docker (push) Failing after 1m10s
- Add API endpoint and email templates for dues reminders
- Change due date calculation from yearly to monthly billing
- Add visual status indicators for overdue and due-soon members
- Enhance member cards with status stripes and styling
2025-08-15 14:39:22 +02:00
7784fab23f fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m27s
2025-08-15 14:06:47 +02:00
4adbb0465a fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m29s
2025-08-14 22:05:12 +02:00
b059d81c21 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m31s
2025-08-14 16:03:47 +02:00
5d3518d256 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m37s
2025-08-14 15:44:18 +02:00
3da5a64dbb fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m39s
2025-08-14 15:39:30 +02:00
a0e9643880 Refactor date inputs in CreateEventDialog: replace VDateInput with v-text-field and consolidate validation
All checks were successful
Build And Push Image / docker (push) Successful in 1m39s
2025-08-14 15:30:58 +02:00
503d68cd2d Replace date-fns with native date formatting and remove unused code
All checks were successful
Build And Push Image / docker (push) Successful in 1m34s
Remove date-fns dependency in favor of native Intl.DateTimeFormat APIs, clean up obsolete admin endpoints, utility files, and archived documentation. Consolidate docs structure and remove unused plugins.
2025-08-14 15:08:40 +02:00
676bbc04f6 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m29s
2025-08-14 10:56:55 +02:00
615112b7e8 fixes
Some checks failed
Build And Push Image / docker (push) Failing after 59s
2025-08-14 10:51:26 +02:00
983361114c fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m35s
2025-08-14 10:46:12 +02:00
503d10f0ab fixes
Some checks failed
Build And Push Image / docker (push) Failing after 1m9s
2025-08-14 10:43:21 +02:00
2ff0c31bbd Add profile image system with MinIO storage
Some checks failed
Build And Push Image / docker (push) Failing after 1m5s
- Implement ProfileAvatar component for user avatars
- Integrate MinIO for profile image storage and management
- Add profile image fields to Member type definition
- Create server utilities and API endpoints for image handling
- Replace basic avatar icon with new ProfileAvatar in dashboard
- Update sharp dependency to v0.34.3
2025-08-14 10:28:40 +02:00
0952d6c381 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m42s
2025-08-14 09:36:17 +02:00
400f9cdd52 Add board-specific welcome email template and logic
All checks were successful
Build And Push Image / docker (push) Successful in 1m43s
- Create separate welcome email template for board members
- Add conditional logic to use board template based on membership tier
- Update email service to support sendWelcomeBoardEmail method
- Include board-specific subject line and template preloading
2025-08-14 09:25:56 +02:00
1ab45cf503 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m25s
2025-08-13 23:15:29 +02:00
198fbf3187 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m26s
2025-08-13 23:09:33 +02:00
1875fac7d4 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m29s
2025-08-13 23:03:49 +02:00
3d565e8185 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m24s
2025-08-13 22:53:14 +02:00
fc1d691950 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 1m26s
2025-08-13 22:43:40 +02:00
44aee8f2f9 Refactor event form to use separate date/time inputs with validation
All checks were successful
Build And Push Image / docker (push) Successful in 1m26s
- Split combined datetime pickers into separate date and time fields
- Add validation for past dates and time consistency
- Implement error message display with dismissible alerts
- Add watchers to combine date/time values into ISO strings
- Set minimum date constraints to prevent past date selection
- Add delete endpoint for events
2025-08-13 22:23:06 +02:00
9ee0b2f14e Clean up codebase and reorganize plugin architecture
All checks were successful
Build And Push Image / docker (push) Successful in 1m30s
- Archive documentation files to docs-archive/
- Remove numbered prefixes from plugin files for cleaner organization
- Remove unused dependencies (@nuxt/ui, @vuepic/vue-datepicker)
- Update event components and API endpoints
- Simplify plugin structure with descriptive names
2025-08-13 22:10:00 +02:00
b4e72ddf9a fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m42s
2025-08-13 21:48:05 +02:00
34fdf820fe fixes
All checks were successful
Build And Push Image / docker (push) Successful in 4m21s
2025-08-13 18:58:43 +02:00
b49148cf95 fixes
Some checks failed
Build And Push Image / docker (push) Has been cancelled
2025-08-13 18:55:49 +02:00
9b183b48cc fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m27s
2025-08-13 17:24:31 +02:00
e097fb746f fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m45s
2025-08-13 17:16:22 +02:00
b833826a1e fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m54s
2025-08-13 16:47:53 +02:00
4b1a77de90 Add Keycloak group management for member portal access control
All checks were successful
Build And Push Image / docker (push) Successful in 3m53s
- Add portal access control section to EditMemberDialog for admins
- Implement API endpoints for managing member Keycloak groups
- Add group selection UI with user/board/admin access levels
- Enhance admin config with reload functionality
- Support real-time group synchronization and status feedback
2025-08-13 16:31:54 +02:00
5371ad4fa2 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 4m16s
2025-08-13 15:57:34 +02:00
db19eb2708 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m41s
2025-08-13 15:35:53 +02:00
62fb84d25e Add guest support for events and RSVP system
All checks were successful
Build And Push Image / docker (push) Successful in 3m52s
- Add guest settings to event creation with configurable max guests per person
- Implement guest selection in RSVP form when guests are permitted
- Update API endpoints to handle guest count in RSVP requests
- Extend event and RSVP types to support guest-related fields
2025-08-13 15:14:43 +02:00
234c939dcd fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m47s
2025-08-13 14:30:26 +02:00
7205de22c9 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m45s
2025-08-13 14:14:58 +02:00
3620bd8b53 Fix RSVP API calls and compact MemberCard UI design
All checks were successful
Build And Push Image / docker (push) Successful in 4m2s
2025-08-13 14:02:29 +02:00
d215dfedc7 db updates and fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m59s
2025-08-13 13:51:27 +02:00
a0153a76a4 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m59s
2025-08-13 13:18:07 +02:00
d5647c3667 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m52s
2025-08-13 13:07:09 +02:00
5473555977 Add rich text editor and enhanced date picker to event dialogs
Some checks failed
Build And Push Image / docker (push) Failing after 3m11s
Replace basic textarea with VuetifyTiptap rich text editor for event descriptions, supporting formatting options like bold, italic, headings, and lists. Replace native datetime inputs with VueDatePicker components featuring timezone support (Monaco/UTC) and improved UX. Update dependencies and add necessary plugins to support the new components.
2025-08-13 13:02:12 +02:00
1553a39fa8 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m23s
2025-08-13 12:27:21 +02:00
ef01d2f22e fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
2025-08-12 17:31:03 +02:00
072acf95eb Fix type safety and data consistency in events system
All checks were successful
Build And Push Image / docker (push) Successful in 3m25s
- Add proper TypeScript type annotations and assertions
- Handle string/number conversion for attendee counts consistently
- Improve null/undefined checks for events array
- Make event handlers async for better error handling
- Fix data type inconsistencies between components and API
2025-08-12 17:23:42 +02:00
70b77fbe9f Resolve merge conflicts in events system
All checks were successful
Build And Push Image / docker (push) Successful in 3m13s
- Fixed conflicts in server/api/events/index.get.ts with improved logging and session management
- Fixed conflicts in server/api/events/index.post.ts with better validation and error handling
- Fixed conflicts in server/utils/nocodb-events.ts incorporating admin config integration and token validation
- Events system now uses proper session management and NocoDB v2 API patterns
- Maintains compatibility with existing admin configuration system
2025-08-12 17:05:33 +02:00
7d55468a21 Add comprehensive debug logging and refactor events API session handling
- Add extensive console logging throughout events GET/POST endpoints
- Replace getUserSession with createSessionManager for better session handling
- Temporarily disable complex filtering/sorting logic to isolate issues
- Enhance error handling with proper statusCode checking
2025-08-12 16:58:31 +02:00
287af29f6c feat(board): Add real data integration for board dashboard
All checks were successful
Build And Push Image / docker (push) Successful in 3m22s
- Create /api/board/stats endpoint for member statistics and overview data
- Create /api/board/next-meeting endpoint for upcoming meeting information
- Update board.vue to fetch real data instead of using mock data
- Add loading states and error handling with graceful fallbacks
- Board Overview now shows actual member counts and pending actions
- Next Meeting section displays real event data when available

The board dashboard now displays live data from the database while maintaining
fallback functionality if any data sources are unavailable.
2025-08-12 14:18:55 +02:00
1d5ecfddcd debug(events): Strip down to minimal query to isolate 422 errors
All checks were successful
Build And Push Image / docker (push) Successful in 3m22s
- Removed ALL filtering (dates, status, search, role-based)
- Removed ALL sorting
- Added comprehensive debug logging
- This will test if even basic field filtering causes 422 errors
- Goal: Isolate exactly what NocoDB query syntax works vs fails
2025-08-12 13:49:42 +02:00
c4789ec9df fix(events): Complete field name and query syntax fix based on debug findings
All checks were successful
Build And Push Image / docker (push) Successful in 3m34s
Root cause identified and fixed:
- Events table uses 'Id' (capital I) not 'id' (lowercase)
- Fixed all field references to use correct NocoDB field names
- Added proper filtering with gte/lte operators (btw didn't work)
- Fixed RSVP lookup to prevent undefined event ID errors
- Updated calendar transformation to use correct field names

 Debug findings showed:
- Basic table access works (mp3wigub1fzdo1b confirmed correct)
- Sample record revealed actual field structure
- Issue was field name mismatch causing undefined IDs in queries

 This should resolve all 422 errors and make events calendar functional
2025-08-12 13:41:59 +02:00
9c029eb510 debug(events): Enhanced field name discovery debugging
All checks were successful
Build And Push Image / docker (push) Successful in 3m20s
- Added comprehensive logging to identify exact table access issues
- Will show actual field names from events table if query succeeds
- Will show detailed error information if query fails
- This will help identify if issue is field names, permissions, or other factors
- Uses emojis for easy log scanning in production
2025-08-12 13:33:23 +02:00
d01758b947 debug(events): Remove ALL filtering and sorting to test basic NocoDB API
Some checks failed
Build And Push Image / docker (push) Has been cancelled
- Completely stripped down query to test if basic API call works
- No where clauses, no sorting, just limit/offset
- Added debug logging to identify the root cause
- This will help determine if issue is with query syntax or basic connectivity
2025-08-12 13:30:16 +02:00
2c3c64e7e3 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m12s
2025-08-12 13:24:16 +02:00
fd8767e56d fixes
Some checks failed
Build And Push Image / docker (push) Has been cancelled
2025-08-12 13:23:36 +02:00
c0c5ae6c44 fix(events): Update NocoDB query syntax to match official API documentation
Some checks are pending
Build And Push Image / docker (push) Waiting to run
- Use btw (between) operator for date ranges instead of gte/lte
- Use proper ~or and ~and logical operators for complex conditions
- Use like operator with %wildcards% for search functionality
- Role-based filtering with correct OR conditions for board visibility
- All query syntax now matches official NocoDB v2 API documentation

This should resolve the 422 Unprocessable Entity errors by using the
correct query parameter format that NocoDB expects.
2025-08-12 13:22:41 +02:00
54a4f05c2a fix(events): Simplify NocoDB query to test basic functionality
Some checks failed
Build And Push Image / docker (push) Has been cancelled
- Removed complex query conditions to isolate the issue
- Using simple (status,eq,active) query to test field names
- Will build up complexity once basic queries work
2025-08-12 13:21:27 +02:00
0688c23093 fix(events): Convert NocoDB query syntax from SQL-like to v2 API format
All checks were successful
Build And Push Image / docker (push) Successful in 3m20s
- Updated all where clauses to use NocoDB v2 syntax: (field,operator,value)
- Changed SQL-like syntax (field >= 'value' AND field = 'value') to v2 format
- Fixed date range queries: (start_datetime,gte,date) and (start_datetime,lte,date)
- Fixed equality queries: (status,eq,active) instead of (status = 'active')
- Fixed AND/OR logic: ~and() and ~or() syntax for complex conditions
- Updated findEventRSVPs, findUserRSVP, and findUserEvents methods
- Fixed RSVP queries to use proper v2 format for member and event matching

This should resolve the 422 Unprocessable Entity errors that were caused by
using deprecated SQL-like syntax with the v2 API endpoints.
2025-08-12 13:15:06 +02:00
122d6fdd26 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m34s
2025-08-12 13:07:43 +02:00
85e8a20f40 fix(events): Convert events NocoDB client from v1 to v2 API
All checks were successful
Build And Push Image / docker (push) Successful in 3m15s
- Updated all NocoDB API calls from v1/db/data/v1/ to v2/tables/ endpoints
- Fixed 422 Unprocessable Entity errors on events calendar page
- Ensures consistency with members system which already uses v2 API
- Updated methods: findAll, findOne, create, update, delete, createRSVP, findEventRSVPs, findUserRSVP, updateRSVP, updateAttendeeCount, findUserEvents
- Maintains same functionality while using correct API version

Resolves continuous 422 errors when loading events calendar.
2025-08-12 13:02:13 +02:00
e75579e3e4 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m6s
2025-08-12 12:53:05 +02:00
22dbbae150 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m12s
2025-08-12 12:43:54 +02:00
490cb57b66 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m23s
2025-08-12 12:29:59 +02:00
67d4d5236b Add NocoDB events/RSVPs table config and improve session handling
All checks were successful
Build And Push Image / docker (push) Successful in 3m24s
- Add events and rsvps table ID fields to NocoDB settings dialog
- Replace mock getUserSession with proper SessionManager integration
- Improve type safety with explicit type casting and error handling
- Add comprehensive events system bug analysis documentation
2025-08-12 12:24:16 +02:00
e06f639454 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
2025-08-12 04:31:21 +02:00
f096897129 Add event management system with calendar and CRUD operations
Some checks failed
Build And Push Image / docker (push) Failing after 2m37s
- Add EventCalendar component with FullCalendar integration
- Create event CRUD dialogs and upcoming event banner
- Implement server-side events API and database utilities
- Add events dashboard page and navigation
- Improve dues calculation with better overdue day logic
- Install FullCalendar and date-fns dependencies
2025-08-12 04:25:35 +02:00
a555584b2c Fix user dashboard data display issue
All checks were successful
Build And Push Image / docker (push) Successful in 3m15s
- Enhanced session endpoint to fetch complete member data from database
- Added getMemberByKeycloakId function to nocodb utils for member lookup by Keycloak ID
- Session endpoint now returns both user authentication data and complete member profile
- User dashboard will now display proper member information instead of 'Not provided'
- Handles cases where member record might not exist gracefully
2025-08-11 16:54:14 +02:00
6e68e42f28 Fix broken verification button URLs in emails
All checks were successful
Build And Push Image / docker (push) Successful in 3m11s
- Fix malformed verification links caused by config.public.domain
- Use absolute HTTPS URLs for verification links in both registration and verification emails
- Ensures verification buttons work correctly in all email clients
- Fixes the 'app://renderer/' URL prefix issue seen in email clients
2025-08-11 16:44:35 +02:00
13cf728ab2 Fix email template logo URLs
Some checks failed
Build And Push Image / docker (push) Has been cancelled
- Fix logo URLs in all email templates to use absolute HTTPS URL
- Ensures MonacoUSA logo displays correctly in all email clients
- Updated all email service methods to use https://portal.monacousa.org/MONACOUSA-Flags_376x376.png
2025-08-11 16:42:10 +02:00
ea6a722364 Fix email template compatibility and enhance user dashboard
All checks were successful
Build And Push Image / docker (push) Successful in 3m20s
- Convert all email templates to table-based layouts for compatibility with Outlook, Gmail, Apple Mail, etc.
- Use only inline CSS and email-safe properties
- Fix welcome.hbs, verification.hbs, password-reset.hbs, dues-reminder.hbs, test.hbs templates
- Enhanced user dashboard with complete member information (Member ID, registration date, dues status, etc.)
- Added proper loading states and error handling for member data
- Improved member profile page with comprehensive information display
- Fixed missing wiring information in user and profile screens as requested
2025-08-11 16:26:51 +02:00
c12b88072f fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m16s
2025-08-11 16:12:02 +02:00
f4044c4477 Add unique member ID generation to registration process
All checks were successful
Build And Push Image / docker (push) Successful in 3m25s
- Generate and store unique member IDs during user registration
- Update email templates to include formatted member ID and registration date
- Add member_id field mapping in NocoDB utility functions
- Enhance email service with better logging and template data handling
2025-08-11 16:05:24 +02:00
f1a462094a Add payment date selection for dues payments and improve member viewing
All checks were successful
Build And Push Image / docker (push) Successful in 3m23s
- Add payment date dialog when marking dues as paid with date validation
- Replace direct member view emission with dedicated ViewMemberDialog component
- Add backend support for custom payment dates in mark-dues-paid endpoint
- Prevent future date selection for payment records
- Improve user workflow for viewing and editing member details
2025-08-11 15:41:33 +02:00
abf6ade8cd Add comprehensive dues tracking with overdue calculations and enhanced UI
All checks were successful
Build And Push Image / docker (push) Successful in 3m17s
2025-08-11 15:29:42 +02:00
7a8c88c341 Implement grace period system for member dues management
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
- Add grace period logic for new members (1 month from join date)
- Enhance dues status computation with actual payment date validation
- Update member card to show grace period status with warning indicators
- Remove overdue dues banner from member list
- Add payment due date calculation in registration process
- Update email templates for improved member communication
2025-08-11 15:03:44 +02:00
d9ef5bbdeb feat: Enhanced dues overdue system with detailed time tracking
All checks were successful
Build And Push Image / docker (push) Successful in 2m42s
- Enhanced update-overdue-statuses API to calculate and return specific overdue durations (years/months)
- Updated overdue-count API to provide detailed member information with overdue durations
- Enhanced DuesOverdueBanner component to display expandable list of overdue members with their specific overdue time
- Added automatic marking of members as inactive when dues are over 1 year overdue
- Improved UI to show 'Dues Overdue - X Members Affected' with detailed breakdown
- Members with overdue dues now display exact time overdue (e.g., '2 years 3 months overdue')
- Added proper TypeScript interfaces for overdue member data
- Enhanced banner shows inactive status and overdue duration for each affected member
2025-08-10 23:42:17 +02:00
ff85d1c722 Add overdue dues management system with notifications and status updates
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
- Create DuesOverdueBanner component for displaying overdue member counts
- Add API endpoints for overdue count retrieval and status updates
- Integrate overdue dues banner into admin and member list dashboards
- Implement handlers for viewing overdue members and updating statuses
- Add dues management section to admin dashboard
2025-08-10 23:29:48 +02:00
d3c3a865ba Add dues management system with UI improvements
All checks were successful
Build And Push Image / docker (push) Successful in 3m3s
- Add BoardDuesManagement and DuesActionCard components
- Create API endpoints for dues status tracking and payment marking
- Integrate dues management section into board dashboard
- Move create portal account button to member card action buttons
- Add edit button to member view dialog
- Implement member update handlers and navigation between views
2025-08-10 23:19:48 +02:00
91dea9910d Move MonacoUSA logo from global overlay to dashboard nav toggle
All checks were successful
Build And Push Image / docker (push) Successful in 3m3s
Remove global fixed-position logo from app.vue and integrate it as the navigation drawer toggle button in the dashboard layout's app bar. This improves the layout by eliminating overlay positioning issues while maintaining logo visibility and functionality.
2025-08-10 16:54:50 +02:00
ecae3795ee Add global branding and implement member ID system
All checks were successful
Build And Push Image / docker (push) Successful in 3m2s
- Add MonacoUSA logo component with global header placement
- Implement member ID generation and migration system
- Create profile page and improve dashboard navigation
- Add member ID as payment reference in dues banner
- Enable support contact functionality with pre-filled email
2025-08-10 16:49:23 +02:00
e33f32e15a Disable PWA temporarily to test mobile Safari reload loops
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
Temporarily comment out PWA module configuration and service worker
unregistration to debug reload loop issues on mobile Safari. Added
test documentation and console logging for debugging purposes.
2025-08-10 16:25:05 +02:00
8b05fdd3d7 Add mobile Safari reload loop prevention for auth pages
All checks were successful
Build And Push Image / docker (push) Successful in 3m2s
- Implement comprehensive reload loop prevention utility
- Add initialization checks to setup-password, verify, and signup pages
- Include timeout protection and error handling for config loading
- Add fallback defaults to prevent page failures on mobile devices
- Document mobile reload loop prevention system
2025-08-10 16:21:54 +02:00
86977ca92a Fix mobile Safari reload loop by persisting config cache in window object
All checks were successful
Build And Push Image / docker (push) Successful in 2m56s
- Store config cache in window.__configCache instead of module-level variable to maintain persistence across Vue reactivity cycles
- Fix cardClasses ref to store computed value instead of function
- Add client plugin for config cache initialization
- Add documentation for mobile Safari reload loop fix
2025-08-10 16:09:15 +02:00
0774e16fb2 Fix mobile browser reload loops by making query parameters static
All checks were successful
Build And Push Image / docker (push) Successful in 3m31s
Convert reactive computed() query parameters to static ref() values in auth pages to prevent infinite reload loops on mobile browsers. Affects setup-password, verify-expired, and verify-success pages.
2025-08-10 15:58:42 +02:00
62be77ec34 Add circuit breaker pattern to email verification system
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
Implement rate limiting and attempt tracking to prevent verification abuse and infinite reload loops. Add temporary blocking with clear user feedback, enhanced error states, and retry logic. Includes new verification state utilities and improved UI components for better user experience during blocked states.
2025-08-10 15:48:11 +02:00
c4379f0813 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m14s
2025-08-10 15:32:15 +02:00
0d6a82f03c fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
2025-08-10 15:24:52 +02:00
9572291299 fixes
Some checks failed
Build And Push Image / docker (push) Has been cancelled
2025-08-10 15:21:43 +02:00
30136117ce Fix Safari iOS reload loop with static device detection and caching
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s
- Replace reactive device detection with static utilities to prevent
  infinite reload loops on mobile Safari
- Add static-device-detection.ts for one-time device info computation
- Add config-cache.ts for improved configuration loading performance
- Apply mobile Safari viewport and CSS optimizations across auth pages
- Remove reactive dependencies that caused rendering issues on iOS
2025-08-10 15:18:34 +02:00
4e53e7ea10 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s
2025-08-10 14:54:37 +02:00
524ecc6941 fixes
Some checks failed
Build And Push Image / docker (push) Failing after 2m33s
2025-08-10 14:47:40 +02:00
21bc4909b1 fixes
Some checks failed
Build And Push Image / docker (push) Failing after 2m41s
2025-08-10 14:41:50 +02:00
2eaf9cda95 Refactor mobile detection to use built-in Nuxt device module
Some checks failed
Build And Push Image / docker (push) Failing after 2m27s
Replace custom useMobileDetection composable with Nuxt's useDevice(),
removing reactive mobile detection in favor of static detection to
prevent reload loops and simplify viewport handling
2025-08-10 14:38:02 +02:00
fd08c38ade fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m0s
2025-08-09 20:20:26 +02:00
86a315f24c Fix mobile password input issues and prevent iOS auto-zoom
All checks were successful
Build And Push Image / docker (push) Successful in 3m21s
- Add togglePasswordVisibility function with mobile-specific handling
- Set font-size to 16px on password inputs to prevent iOS zoom
- Add autocomplete="new-password" attribute to password fields
- Increase tap target size for password visibility toggle on mobile
- Configure viewport meta tag to disable zoom on iOS devices
- Disable transitions on mobile for better performance
2025-08-09 20:08:51 +02:00
006d4cf1ff Improve mobile keyboard handling and viewport detection
All checks were successful
Build And Push Image / docker (push) Successful in 3m3s
- Disable transitions and make menu persistent on mobile devices
- Add keyboard open/close detection to prevent unwanted viewport updates
- Differentiate between keyboard events and actual viewport changes
- Improve resize debouncing with longer delays for mobile
- Handle orientation changes separately from keyboard events
2025-08-09 19:49:12 +02:00
623ad9c3fd Refactor email verification to use JSON responses instead of redirects
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
- Replace server-side redirects with JSON API responses for better error handling
- Add support for partial success when Keycloak update fails but token is valid
- Improve error messages with specific status codes (410 for expired, 409 for already used)
- Extract email from API response instead of URL query parameters
- Enable client-side navigation with proper error state management
2025-08-09 19:40:04 +02:00
2b2cd5891f Refactor mobile detection to use unified composable
All checks were successful
Build And Push Image / docker (push) Successful in 2m52s
- Add useMobileDetection composable to centralize device detection logic
- Replace direct utility imports with composable usage across components
- Update MultipleNationalityInput, PhoneInputWrapper, and auth pages
- Simplify mobile-specific styling and behavior handling
- Improve code maintainability by consolidating detection logic
2025-08-09 19:27:15 +02:00
d14008efd4 Add password setup flow with server-side validation
All checks were successful
Build And Push Image / docker (push) Successful in 3m2s
- Replace external password setup link with internal navigation
- Add comprehensive password validation utility with strength requirements
- Create dedicated password setup page and API endpoint
- Streamline user flow from email verification to password creation
2025-08-09 19:11:54 +02:00
30b7e23319 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 2m57s
2025-08-09 19:01:44 +02:00
09773f9571 Looking at the changes, this commit primarily focuses on fixing a critical mobile Safari issue with the country dropdown selector. Here's my suggested commit message:
All checks were successful
Build And Push Image / docker (push) Successful in 3m0s
```
Fix mobile Safari country dropdown with touch-optimized dialog interface

- Replace broken v-select with mobile-friendly dialog for Safari
- Add device detection to switch between desktop and mobile interfaces
- Implement full-screen country selection with search functionality
- Add touch-optimized UI with larger targets and smooth scrolling
- Update documentation with fix details and implementation notes
```

The main change is addressing a completely broken country selection dropdown on mobile Safari by implementing a mobile-specific dialog interface while maintaining the original desktop experience.
2025-08-09 18:53:26 +02:00
d55f253222 Fix mobile Safari compatibility and correct Keycloak account URL
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
- Add mobile Safari utilities and viewport optimizations
- Fix Keycloak setup password URL structure (remove hash fragment causing 404s)
- Implement performance mode and hardware acceleration fixes
- Add responsive CSS optimizations for mobile Safari
- Configure keycloakIssuer in Nuxt config for proper URL generation
2025-08-09 18:44:33 +02:00
44cdc988ee Add Mobile Safari optimizations and fixes to signup page
All checks were successful
Build And Push Image / docker (push) Successful in 3m10s
- Implement device detection and performance optimization flags
- Add dynamic CSS classes based on device capabilities
- Create mobile safari utility functions and client plugin
- Optimize backdrop filters and hardware acceleration for iOS
- Fix viewport height issues with mobile Safari fallbacks
- Update membership fee config and add IBAN payment details
- Prevent horizontal scrolling and improve mobile UX
2025-08-09 18:36:10 +02:00
358e9c0ad1 Add Keycloak integration support
All checks were successful
Build And Push Image / docker (push) Successful in 2m59s
- Update domain configuration to portal subdomain with HTTPS
- Add keycloak_id field to member creation and update operations
- Add API endpoint for linking Keycloak accounts to existing members
2025-08-09 18:22:34 +02:00
794b6a09f0 Fix email verification domain and membership fee amount
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s
- Update domain config to use portal.monacousa.org for verification links
- Correct membership fee from €50 to €150/year in welcome emails
- Add password setup link to verification success page
- Include registration date in welcome email template
2025-08-09 18:10:33 +02:00
a4e8231a3b fixes
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
2025-08-09 17:48:03 +02:00
c13ef30f69 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
2025-08-09 17:43:15 +02:00
df1ff15975 Enhance member deletion and implement template-based email system
All checks were successful
Build And Push Image / docker (push) Successful in 2m50s
- Add Keycloak user deletion to member removal process
- Implement Handlebars email templates for verification, password reset, and dues reminders
- Improve JWT secret configuration with multiple fallbacks
- Add getMemberById function and enhance error handling
- Update Dockerfile to include email templates in production build
2025-08-09 17:36:35 +02:00
bff89bd89d Add JWT configuration and improve email error handling
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
- Add jwtSecret to runtime config with fallback to sessionSecret
- Enhance email error tracking in portal account creation API
- Fix jsonwebtoken imports and improve type safety
- Include detailed email error information in API responses
2025-08-09 16:55:59 +02:00
97653b7307 Preserve masked credentials in admin config updates
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
Handle masked sensitive fields (passwords/secrets) in reCAPTCHA and SMTP
configuration endpoints. When the frontend sends masked values (bullets),
preserve existing credentials instead of overwriting them. This prevents
accidental credential loss when updating other configuration fields.

- Check for masked placeholder values ('••••••••••••••••')
- Preserve existing secretKey/password when masked
- Add logging to track credential update actions
2025-08-09 16:41:33 +02:00
c4a0230f42 Fix portal account creation and improve email handling
All checks were successful
Build And Push Image / docker (push) Successful in 2m56s
- Add explicit POST method to portal account creation API call
- Improve error handling with specific messages for different failure cases
- Remove SMTP verification step that was causing issues with some servers
- Make email sending non-critical to portal account creation success
- Add better response data handling for keycloak_id
- Add integration review documentation
2025-08-09 16:13:52 +02:00
8d872f9a04 Refactor admin dashboard and fix mobile phone input dropdown
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
- Simplify admin dashboard by consolidating configuration sections
- Fix mobile dropdown display issues in PhoneInputWrapper component
- Add proper flex layout and minimum height for mobile country list
- Update email configuration and testing functionality
- Remove redundant configuration cards in favor of unified portal settings
2025-08-09 16:05:00 +02:00
dcb7840825 Make email service initialization asynchronous
All checks were successful
Build And Push Image / docker (push) Successful in 3m10s
Convert getEmailService() to async function and update all callers to use await.
Replace synchronous require() with dynamic import() for admin-config module.
Add SMTP config loading to admin configuration dialog.
2025-08-09 15:50:54 +02:00
97a0b5eea6 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
2025-08-08 23:47:21 +02:00
4ec05e29dc Add email verification system for user registration
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
- Add SMTP configuration UI in admin panel with test functionality
- Implement email verification workflow with tokens and templates
- Add verification success/expired pages for user feedback
- Include nodemailer, handlebars, and JWT dependencies
- Create API endpoints for email config, testing, and verification
2025-08-08 22:51:14 +02:00
7b72d7a565 fixed auth
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
2025-08-08 22:10:08 +02:00
7d9f895ca6 fully functional, production-ready member registration system that works flawlessly across all platforms and provides a professional user experience
All checks were successful
Build And Push Image / docker (push) Successful in 3m8s
2025-08-08 22:04:53 +02:00
15dd090d44 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s
2025-08-08 21:52:21 +02:00
aed8dc68fc fixes recaptcha
All checks were successful
Build And Push Image / docker (push) Successful in 3m12s
2025-08-08 21:10:00 +02:00
3951ce1d4e fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m33s
2025-08-08 20:59:06 +02:00
cb73b239a8 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s
2025-08-08 20:49:50 +02:00
3894295569 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
2025-08-08 20:41:39 +02:00
42d1d012c1 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m4s
2025-08-08 20:27:54 +02:00
72492fb754 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
2025-08-08 20:07:47 +02:00
4365cc53ff fixes
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
2025-08-08 19:55:32 +02:00
5535b7905d #### __1. Role-Based Security Architecture__
All checks were successful
Build And Push Image / docker (push) Successful in 2m58s
- Replaces group-based tiers with proper Keycloak realm roles
- `monaco-user`, `monaco-board`, `monaco-admin` roles
- Backward compatibility with existing group system

#### __2. Advanced User Management__

- Comprehensive user profile synchronization
- Membership data stored in Keycloak user attributes
- Bidirectional sync between NocoDB and Keycloak

#### __3. Session Security & Monitoring__

- Real-time session tracking and management
- Administrative session control capabilities
- Enhanced security analytics foundation

#### __4. Email Workflow System__

- Multiple email types: DUES_REMINDER, MEMBERSHIP_RENEWAL, WELCOME, VERIFICATION
- Customizable email parameters and lifespans
- Advanced email template support

#### __5. Seamless Migration Path__

- All existing functionality continues to work
- New users automatically get realm roles
- Gradual migration from groups to roles
- Zero breaking changes

### 🔧 __What You Can Do Now__

#### __For New Users:__

- Public registrations automatically assign `monaco-user` role
- Portal account creation syncs member data to Keycloak attributes
- Enhanced email verification and welcome workflows

#### __For Administrators:__

- Session management and monitoring capabilities
- Advanced user profile management with member data sync
- Comprehensive role assignment and management
- Enhanced email communication workflows

#### __For Developers:__

- Use `hasRole('monaco-admin')` for role-based checks
- Access `getAllRoles()` for debugging and analytics
- Enhanced `useAuth()` composable with backward compatibility
- Comprehensive TypeScript support throughout

### 🛡️ __Security & Reliability__

- __Backward Compatibility__: Existing users continue to work seamlessly
- __Enhanced Security__: Proper realm role-based authorization
- __Error Handling__: Comprehensive error handling and fallbacks
- __Type Safety__: Full TypeScript support throughout the system
2025-08-08 19:40:13 +02:00
b308b8272c updates to database schema
All checks were successful
Build And Push Image / docker (push) Successful in 2m50s
2025-08-08 18:32:46 +02:00
28fa779dae fixes and cleanup
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
2025-08-08 13:50:01 +02:00
0545f7e9c4 phone fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m9s
2025-08-08 13:23:18 +02:00
59bb4ca714 fixed phone display
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
2025-08-08 13:02:48 +02:00
497e0134d8 mobile optimizations
All checks were successful
Build And Push Image / docker (push) Successful in 3m13s
2025-08-08 12:59:27 +02:00
d2057cc878 fixed phone flags
All checks were successful
Build And Push Image / docker (push) Successful in 2m59s
2025-08-08 12:54:05 +02:00
65bda25c8f successfully replaced your oversized, clunky phone input with a professional vue-tel-input library
All checks were successful
Build And Push Image / docker (push) Successful in 2m46s
2025-08-08 00:35:57 +02:00
0193269749 Created a beautiful, modern phone input
All checks were successful
Build And Push Image / docker (push) Successful in 2m41s
2025-08-08 00:25:44 +02:00
e6371d02b9 phone updates
All checks were successful
Build And Push Image / docker (push) Successful in 2m46s
2025-08-08 00:19:16 +02:00
9f9cb7db53 comprehensive diagnostic and fix system for the "undefined" member display issue
All checks were successful
Build And Push Image / docker (push) Successful in 2m49s
2025-08-07 23:57:18 +02:00
3f81d0dd86 fixed the member creation functionality that was failing with validation errors. Here's what I accomplished
All checks were successful
Build And Push Image / docker (push) Successful in 2m52s
2025-08-07 23:44:28 +02:00
dcce2050ee resolved all member management issues, including the critical member creation bug.
Some checks failed
Build And Push Image / docker (push) Failing after 2m36s
2025-08-07 23:13:31 +02:00
863ad9abe7 fixed both the member edit display issue and the flag positioning problems in the nationality input
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
2025-08-07 23:05:46 +02:00
f6bc81cb01 implemented comprehensive member card enhancements with complete multiple nationality support and dues management features.
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
2025-08-07 22:53:45 +02:00
9202509c9c updated member list with improved filtering system and enhanced editing capabilities.
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
2025-08-07 22:34:51 +02:00
024eca02ac telephone updates
All checks were successful
Build And Push Image / docker (push) Successful in 2m52s
2025-08-07 22:13:05 +02:00
13fa95a9a2 fixes
All checks were successful
Build And Push Image / docker (push) Successful in 3m9s
2025-08-07 22:01:02 +02:00
d36209818a Migrate member fields to snake_case naming convention
All checks were successful
Build And Push Image / docker (push) Successful in 3m11s
Convert field names from space-separated format to snake_case across
member API endpoints and validation logic. Add migration guide for
reference.
2025-08-07 21:50:02 +02:00
f096a22824 Enhance CountryFlag to accept country names and codes
All checks were successful
Build And Push Image / docker (push) Successful in 3m20s
- Add actualCountryCode computed property to handle both 2-letter codes and country names
- Extend parseCountryInput with common country name variations (USA, UK, etc.)
- Import parseCountryInput utility in CountryFlag component
- Update template to use actualCountryCode instead of direct countryCode prop
2025-08-07 21:30:47 +02:00
b043648db6 Refactor CountryFlag component to use ClientOnly wrapper and improve placeholder styling
All checks were successful
Build And Push Image / docker (push) Successful in 3m14s
2025-08-07 21:22:01 +02:00
5fe015af51 Replace flag-icons with vue-country-flag-next and improve UI styling
Some checks failed
Build And Push Image / docker (push) Failing after 2m52s
- Replace flag-icons CSS library with vue-country-flag-next component
- Update CountryFlag component to use new library API
- Improve avatar colors with high-contrast color palette
- Adjust dashboard layout column sizing
- Add new EditMemberDialog component
- Remove unused flag-icons CSS dependency
2025-08-07 21:09:00 +02:00
22a74c6b33 Refactor NocoDB settings to support dynamic table configuration and update related validation
All checks were successful
Build And Push Image / docker (push) Successful in 3m13s
2025-08-07 20:43:39 +02:00
d0d7a34ae7 Enhance encryption methods in admin configuration to use AES-256-GCM with key derivation
All checks were successful
Build And Push Image / docker (push) Successful in 3m6s
2025-08-07 20:23:18 +02:00
ce0cbdc980 Implement dynamic admin configuration system for NocoDB settings
All checks were successful
Build And Push Image / docker (push) Successful in 3m15s
- Add new admin-config utility for persistent configuration management
- Replace hardcoded runtime config with dynamic configuration retrieval
- Enable admin panel to save and apply NocoDB settings immediately
- Add dynamic table ID resolution with fallback to defaults
- Update configuration endpoints to use new persistence system
2025-08-07 20:18:28 +02:00
676420c3fa Refactor admin dashboard layout and improve NocoDB settings dialog handling
All checks were successful
Build And Push Image / docker (push) Successful in 3m14s
2025-08-07 19:57:03 +02:00
6f2037e01c Add NocoDB configuration settings and restructure dashboard navigation
All checks were successful
Build And Push Image / docker (push) Successful in 3m2s
- Add NocoDBSettingsDialog component with API endpoints for config management
- Update dashboard navigation routes and menu structure
- Integrate external user management via auth portal
- Add NocoDB settings dialog to admin panel
2025-08-07 19:46:27 +02:00
af99ea48e2 Add member management system with NocoDB integration
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
- Add member CRUD operations with API endpoints
- Implement member list page with card-based layout
- Add member creation and viewing dialogs
- Support multiple nationalities with country flags
- Include phone number input with international formatting
- Integrate NocoDB as backend database
- Add comprehensive member data types and utilities
2025-08-07 19:20:29 +02:00
c84442433f Refactor password reset to use dedicated Keycloak admin client
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
- Add Keycloak admin credentials configuration to environment variables
- Extract Keycloak admin operations into reusable utility module
- Refactor forgot-password endpoint to use new admin client utility
- Add documentation for Keycloak custom login implementation
- Add password reset fix summary documentation

This improves code organization by separating admin operations from
business logic and provides proper admin credentials for Keycloak
API operations instead of using regular client credentials.
2025-08-07 17:50:09 +02:00
c6a57c7922 Fix redirect loops and SSR hydration issues in auth flow
All checks were successful
Build And Push Image / docker (push) Successful in 2m59s
- Replace ref with useState in useAuth for SSR compatibility
- Move navigation logic from top-level to onMounted hooks
- Add guest middleware to login page to prevent auth conflicts
- Simplify dashboard auth checks by relying on middleware
- Add loading state to index page during auth resolution

This prevents infinite redirect loops and hydration mismatches that
occurred during server-side rendering when navigating between
authenticated and unauthenticated states.
2025-08-07 17:21:18 +02:00
423d8c3aa1 Simplify auth system by removing throttling and mobile workarounds
All checks were successful
Build And Push Image / docker (push) Successful in 3m0s
- Remove session check throttling mechanism from useAuth composable
- Eliminate forced auth check parameters throughout codebase
- Replace window.location redirects with standard navigateTo()
- Remove mobile-specific authentication handling and diagnostics
- Move auth check to onMounted hook in login page
- Clean up console logging for auth operations
2025-08-07 17:12:05 +02:00
616490dfef Fix auth throttling causing login loops by adding forced session checks
All checks were successful
Build And Push Image / docker (push) Successful in 3m27s
Add optional force parameter to checkAuth() to bypass throttling during
critical authentication flows like login, middleware, and initial auth
verification. This prevents iOS Safari login loops while maintaining
throttling for regular session checks.
2025-08-07 17:01:01 +02:00
2843bcf4f5 Fix iOS Safari auth loops and simplify admin dashboard
All checks were successful
Build And Push Image / docker (push) Successful in 3m4s
- Add session check throttling in useAuth to prevent iOS Safari authentication loops
- Simplify admin dashboard by removing complex system metrics and stats
- Remove system-metrics utility and streamline stats API endpoint
- Update admin interface to focus on core user and role management
2025-08-07 16:20:05 +02:00
146b3c9400 feat: enhance mobile compatibility and debugging across authentication and system metrics
All checks were successful
Build And Push Image / docker (push) Successful in 3m18s
2025-08-07 16:08:39 +02:00
ec6958375c Add system monitoring with real-time metrics to admin dashboard
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
- Add systeminformation package for system metrics collection
- Create system-metrics utility for CPU, memory, disk monitoring
- Update admin stats API to return real system health data
- Replace mock data with live system metrics in admin dashboard
- Update @vite-pwa/nuxt to v0.10.8
2025-08-07 15:51:16 +02:00
d0c9c02bf9 Add PWA support with install banner and app icons
All checks were successful
Build And Push Image / docker (push) Successful in 2m56s
- Configure @vite-pwa/nuxt module with manifest and service worker
- Add PWA install banner component to login page
- Include app icons (192x192, 512x512) and favicon assets
- Update admin dashboard layout and remove backup section
- Add PWA-related API endpoints and utility scripts
2025-08-07 15:46:17 +02:00
91cbffe189 Clean up authentication troubleshooting artifacts
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
- Remove debug files: debug-login.js, LOGIN_FIX_SUMMARY.md, CUSTOM_LOGIN_IMPLEMENTATION.md
- Remove sequential-thinking directory (temporary MCP setup)
- Clean up verbose console logging in auth middleware
- Reduce debug output in direct login API while keeping essential logs
- Streamline session management logging
- Update .gitignore to prevent future debug file commits
- Maintain essential error logging and security logs

All authentication functionality remains intact and working.
2025-08-07 15:14:02 +02:00
99772ab62c feat: implement sequential thinking MCP server with tool handling and logging
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
2025-08-07 14:58:08 +02:00
5c8bf15956 fix: streamline authentication check on dashboard mount and improve routing logic
All checks were successful
Build And Push Image / docker (push) Successful in 2m42s
2025-08-07 14:42:05 +02:00
aa541fcc5c fix: improve login redirect reliability by using window.location for navigation
All checks were successful
Build And Push Image / docker (push) Successful in 2m54s
2025-08-07 14:31:46 +02:00
789ecd7eab Refactor login flow to return redirect URL instead of direct navigation
All checks were successful
Build And Push Image / docker (push) Successful in 2m43s
Move navigation responsibility from useAuth composable to login page
component for better separation of concerns and component control.
2025-08-07 14:27:08 +02:00
af4fae6378 feat: implement server-side session management with session ID storage and cleanup
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
2025-08-07 14:16:54 +02:00
fe5aed075f fix: update SameSite cookie attribute to 'none' for cross-site requests
All checks were successful
Build And Push Image / docker (push) Successful in 2m45s
2025-08-07 14:10:33 +02:00
eef81d7409 fix: enforce secure cookie setting for session management
All checks were successful
Build And Push Image / docker (push) Successful in 2m47s
2025-08-07 14:05:14 +02:00
98ef466022 feat: improve login process with enhanced session handling and error management
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s
2025-08-07 13:51:13 +02:00
1b2ce79919 feat: enhance login process with session data retrieval and role extraction
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
2025-08-07 13:37:54 +02:00
05b8d97e22 feat: enhance login method with detailed logging for request and response
All checks were successful
Build And Push Image / docker (push) Successful in 2m52s
2025-08-07 13:13:11 +02:00
989c56acbf feat: add plugin to unregister existing service workers on client
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
2025-08-07 13:07:51 +02:00
cbaedeb0a8 fix: resolve login redirect failures by removing cookie domain and implementing session data encryption
All checks were successful
Build And Push Image / docker (push) Successful in 3m9s
2025-08-07 13:01:39 +02:00
2c545dcaaa Fix login authentication flow and improve proxy configuration
All checks were successful
Build And Push Image / docker (push) Successful in 2m50s
- Refactor login page to use auth composable for better state management
- Update nginx proxy settings with proper timeouts and buffering
- Improve PWA service worker caching strategy for API calls
- Add debug files and documentation for login troubleshooting
2025-08-07 12:55:15 +02:00
57428f437c feat: enhance session cookie handling with domain configuration and expiration settings
All checks were successful
Build And Push Image / docker (push) Successful in 2m52s
2025-08-07 12:45:14 +02:00
c2c9f2fb8e fix: ensure session cookie is properly read by forcing page reload on login success
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
2025-08-07 12:35:14 +02:00
cd29123e23 Refactor authentication system with tier-based access control
All checks were successful
Build And Push Image / docker (push) Successful in 2m59s
- Replace group-based auth with user/board/admin tier system
- Add direct login functionality alongside OAuth
- Implement role-based middleware for route protection
- Create dashboard pages and admin API endpoints
- Add error handling page and improved user management
- Maintain backward compatibility with legacy role methods
2025-08-07 12:28:41 +02:00
2c2c0f5c33 feat: implement custom login system with direct authentication
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
- Add custom login page with username/password form and SSO fallback
- Implement direct login API endpoint with security features
- Add forgot password functionality and email notifications
- Create guest middleware for authentication routing
- Update Keycloak configuration and add cookie domain settings
- Add security utilities for rate limiting and validation
- Include comprehensive documentation for custom login implementation
2025-08-07 03:43:25 +02:00
308c58e924 fix: add missing newline at end of .env.example file 2025-08-07 03:24:58 +02:00
206 changed files with 100591 additions and 4078 deletions

View File

@@ -0,0 +1,66 @@
{
"mcpServers": {
"serena": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/oraios/serena",
"serena",
"start-mcp-server",
"--context",
"ide-assistant",
"--project",
"${workspaceFolder}"
],
"env": {}
},
"zen": {
"type": "stdio",
"command": "pwsh",
"args": [
"-NoLogo",
"-NoProfile",
"-Command",
"$p=(Get-Command uvx -ErrorAction SilentlyContinue).Source; if(-not $p){$c=@(\"$HOME\\.local\\bin\\uvx.exe\",\"C:\\\\Program Files\\\\uv\\\\bin\\\\uvx.exe\"); foreach($i in $c){ if(Test-Path $i){$p=$i; break}}}; if($p){ & $p --from git+https://github.com/BeehiveInnovations/zen-mcp-server.git zen-mcp-server } else { Write-Error 'uvx not found'; exit 1 }"
],
"env": {
"GEMINI_API_KEY": "your_gemini_key",
"OPENAI_API_KEY": "your_openai_key"
}
},
"playwright": {
"type": "stdio",
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@playwright/mcp@latest"
],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp@latest"
],
"env": {}
},
"@21st-dev/magic": {
"type": "stdio",
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@21st-dev/magic@latest"
],
"env": {
"API_KEY": "adb246737aabae0b2f124fc85dc03737a0f65d9660b786732c31578649da10e5"
}
}
},
}

101
.claude/settings.local.json Normal file
View File

@@ -0,0 +1,101 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Read(C:\\Users\\mpcia/**)",
"Read(C:\\Users\\mpcia/**)",
"mcp__serena__activate_project",
"mcp__serena__list_dir",
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_take_screenshot",
"mcp__playwright__browser_snapshot",
"mcp__playwright__browser_type",
"mcp__playwright__browser_click",
"mcp__playwright__browser_press_key",
"mcp__playwright__browser_wait_for",
"mcp__serena__find_symbol",
"mcp__serena__search_for_pattern",
"mcp___21st-dev_magic__21st_magic_component_inspiration",
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
"Bash(npm install:*)",
"Bash(git add:*)",
"Bash(git push:*)",
"Bash(git commit:*)",
"Bash(npm run build:*)",
"mcp__serena__find_file",
"mcp___21st-dev_magic__21st_magic_component_builder",
"Bash(npm run dev:*)",
"Bash(New-Item -Path \"Z:\\Repos\\monacousa-portal\\pages\\admin\\dashboard\\index.vue\" -ItemType File -Force)",
"Bash(grep:*)",
"Bash(findstr:*)",
"mcp__playwright__browser_close",
"Bash(dir:*)",
"mcp__playwright__browser_evaluate",
"mcp__playwright__browser_hover",
"mcp__playwright__browser_resize",
"mcp__playwright__browser_console_messages",
"mcp__serena__check_onboarding_performed",
"mcp__serena__get_symbols_overview",
"mcp__serena__find_referencing_symbols",
"mcp__zen__thinkdeep",
"mcp__serena__insert_after_symbol",
"mcp__serena__replace_symbol_body",
"mcp__playwright__browser_fill_form",
"mcp__zen__debug",
"Bash(Copy-Item -Path \"Z:\\Repos\\monacousa-portal\\design-mockups\\pages\\auth\\ProfessionalLogin.vue\" -Destination \"Z:\\Repos\\monacousa-portal\\pages\\mockups\\login.vue\")",
"Bash(Remove-Item -Path \"Z:\\Repos\\monacousa-portal\\pages\\mockups\" -Recurse -Force)",
"mcp__zen__analyze",
"Read(/Z:\\Repos\\monacousa-portal\\.playwright-mcp/**)",
"Read(/Z:\\Repos\\monacousa-portal\\.playwright-mcp/**)",
"Read(/Z:\\Repos\\monacousa-portal\\assets\\scss/**)",
"Bash(New-Item -Path \"Z:\\Repos\\monacousa-portal\\assets\\scss\\design-system-v2.scss\" -ItemType File -Force)",
"Read(/Z:\\Repos\\monacousa-portal\\assets\\scss/**)",
"Read(/Z:\\Repos\\monacousa-portal/**)",
"Read(/Z:\\Repos\\monacousa-portal/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components\\ui/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components\\ui/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\dashboard/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\board/**)",
"Read(/Z:\\Repos\\monacousa-portal\\assets\\scss/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\dashboard/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\settings/**)",
"Bash(gh run list:*)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\layouts/**)",
"Read(/Z:\\Repos\\monacousa-portal\\layouts/**)",
"Read(/Z:\\Repos\\monacousa-portal\\layouts/**)",
"Read(/Z:\\Repos\\monacousa-portal\\utils/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\api\\members\\[id]/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\utils/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\utils/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\api/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Bash(git pull:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -5,10 +5,17 @@ NUXT_PORT=6060
NUXT_HOST=0.0.0.0
# Keycloak Configuration
NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa-portal
NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa
NUXT_KEYCLOAK_CLIENT_ID=monacousa-portal
NUXT_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
NUXT_KEYCLOAK_CALLBACK_URL=https://monacousa.org/auth/callback
NUXT_KEYCLOAK_CALLBACK_URL=https://portal.monacousa.org/auth/callback
# Keycloak Admin Configuration (for password reset and admin operations)
NUXT_KEYCLOAK_ADMIN_CLIENT_ID=admin-cli
NUXT_KEYCLOAK_ADMIN_CLIENT_SECRET=your-admin-cli-client-secret
# Cookie Configuration
COOKIE_DOMAIN=.monacousa.org
# NocoDB Configuration
NUXT_NOCODB_URL=https://db.monacousa.org
@@ -28,4 +35,5 @@ NUXT_SESSION_SECRET=your-48-character-session-secret-key-here
NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here
# Public Configuration
NUXT_PUBLIC_DOMAIN=monacousa.org
NUXT_PUBLIC_DOMAIN=https://portal.monacousa.org
#

8
.gitignore vendored
View File

@@ -34,3 +34,11 @@ node_modules
# Local data directories
data/
logs/
# Debug files and troubleshooting artifacts
debug-*.js
*.debug.log
LOGIN_FIX_*.md
CUSTOM_*_IMPLEMENTATION.md
troubleshooting/
sequential-thinking/

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

Binary file not shown.

68
.serena/project.yml Normal file
View File

@@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "monacousa-portal"

View File

@@ -17,6 +17,7 @@ RUN npm prune
FROM base as production
ENV PORT=$PORT
COPY --from=build /app/.output /app/.output
COPY --from=build /app/server/templates /app/server/templates
# Copy debug entrypoint script
COPY docker-entrypoint-debug.sh /usr/local/bin/

102
ENVIRONMENT_VARIABLES.md Normal file
View File

@@ -0,0 +1,102 @@
# Environment Variables Configuration
## NocoDB Configuration (Required)
To fix API key issues and improve container deployment, set these environment variables in your Docker container:
### Required Variables
```bash
# NocoDB Database Connection
NUXT_NOCODB_URL=https://database.monacousa.org
NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here
NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here
```
### Alternative Variable Names (also supported)
```bash
# Alternative formats that also work
NOCODB_URL=https://database.monacousa.org
NOCODB_TOKEN=your_actual_nocodb_api_token_here
NOCODB_API_TOKEN=your_actual_nocodb_api_token_here
NOCODB_BASE_ID=your_nocodb_base_id_here
```
## How to Set in Docker
### Option 1: Docker Compose (Recommended)
Add to your `docker-compose.yml`:
```yaml
services:
monacousa-portal:
image: your-image
environment:
- NUXT_NOCODB_URL=https://database.monacousa.org
- NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here
- NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here
# ... rest of your config
```
### Option 2: Docker Run Command
```bash
docker run -d \
-e NUXT_NOCODB_URL=https://database.monacousa.org \
-e NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here \
-e NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here \
your-image
```
### Option 3: Environment File
Create `.env` file:
```bash
NUXT_NOCODB_URL=https://database.monacousa.org
NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here
NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here
```
Then use:
```bash
docker run --env-file .env your-image
```
## Priority Order
The system will check configuration in this order:
1. **Environment Variables** (highest priority)
2. Admin Panel Configuration (fallback)
3. Runtime Config (last resort)
## Benefits
**Container-Friendly**: No need to configure through web UI
**Secure**: API tokens stored as environment variables
**Reliable**: No Unicode/formatting issues
**Version Control**: Can be managed in deployment configs
**Scalable**: Same config across multiple containers
## Getting Your Values
### NocoDB API Token
1. Go to your NocoDB instance
2. Click your profile → API Tokens
3. Create new token or copy existing one
4. Use the raw token without any formatting
### NocoDB Base ID
1. In NocoDB, go to your base
2. Check the URL: `https://your-nocodb.com/dashboard/#/nc/base/BASE_ID_HERE`
3. Copy the BASE_ID part
## Testing Configuration
After setting environment variables, check the logs:
-`[nocodb] ✅ Using environment variables - URL: https://database.monacousa.org`
-`[nocodb] ✅ Configuration validated successfully`
If you see fallback messages, the environment variables aren't being read correctly.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,474 @@
// ============================================
// Dashboard Component Styles
// Professional enhancements for all dashboards
// ============================================
// Dashboard Container
.admin-dashboard,
.board-dashboard,
.member-dashboard {
padding: 2rem;
min-height: 100vh;
background-color: #fafafa; // Fallback for browsers that don't support gradients
background-image: linear-gradient(135deg, #fafafa 0%, #f4f4f5 100%);
background: linear-gradient(135deg, #fafafa 0%, #f4f4f5 100%);
@media (max-width: 768px) {
padding: 1rem;
}
}
// Enhanced Dashboard Header
.dashboard-header {
text-align: center;
padding: 3rem 2rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
&.glass-header {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(220, 38, 38, 0.03) 0%,
transparent 70%
);
animation: float 20s ease-in-out infinite;
}
}
.dashboard-title {
font-size: 3rem;
font-weight: 800;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
&.text-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@media (max-width: 768px) {
font-size: 2rem;
}
}
.dashboard-subtitle {
font-size: 1.125rem;
color: #64748b;
font-weight: 500;
}
}
// Enhanced Stat Cards
.stat-card {
height: 100%;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
margin: 0.5rem 0;
@media (max-width: 768px) {
font-size: 2rem;
}
}
&:hover {
transform: translateY(-6px) scale(1.02);
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.15),
0 10px 30px rgba(220, 38, 38, 0.1);
}
.v-avatar {
background: linear-gradient(135deg,
rgba(var(--v-theme-on-surface), 0.05) 0%,
rgba(var(--v-theme-on-surface), 0.02) 100%);
}
}
// Enhanced Glass Cards
.glass-card {
background: rgba(255, 255, 255, 0.88) !important;
backdrop-filter: blur(16px) saturate(180%) !important;
-webkit-backdrop-filter: blur(16px) saturate(180%) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6) !important;
&:hover {
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8) !important;
}
}
// Enhanced Bento Grid
.bento-grid {
display: grid !important;
grid-template-columns: repeat(12, 1fr) !important;
gap: 1.5rem !important;
margin-bottom: 2rem;
.bento-item {
position: relative;
&--small {
grid-column: span 3 !important;
@media (max-width: 1280px) {
grid-column: span 6 !important;
}
@media (max-width: 768px) {
grid-column: span 12 !important;
}
}
&--medium {
grid-column: span 4 !important;
@media (max-width: 1280px) {
grid-column: span 6 !important;
}
@media (max-width: 768px) {
grid-column: span 12 !important;
}
}
&--large {
grid-column: span 6 !important;
@media (max-width: 768px) {
grid-column: span 12 !important;
}
}
&--xlarge {
grid-column: span 8 !important;
@media (max-width: 1280px) {
grid-column: span 12 !important;
}
}
&--full {
grid-column: span 12 !important;
}
}
}
// Enhanced Data Tables
.v-data-table {
background: transparent !important;
.v-data-table__wrapper {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 16px;
overflow: hidden;
}
thead {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.03) 0%,
rgba(185, 28, 28, 0.01) 100%);
th {
font-weight: 600 !important;
font-size: 0.75rem !important;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b !important;
padding: 1rem !important;
}
}
tbody {
tr {
transition: all 0.2s ease;
&:hover {
background: rgba(220, 38, 38, 0.02) !important;
td {
color: #1f2937 !important;
}
}
td {
padding: 1rem !important;
font-size: 0.875rem;
color: #475569;
}
}
}
}
// Enhanced Buttons in Dashboards
.dashboard-action-btn {
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.2);
&::before {
width: 300px;
height: 300px;
}
}
}
// Activity Timeline Enhancement
.activity-timeline {
.v-timeline-item {
&::before {
background: linear-gradient(180deg,
rgba(220, 38, 38, 0.1) 0%,
transparent 100%);
}
.v-timeline-item__dot {
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.2);
}
}
}
// Quick Actions Enhancement
.quick-actions-card {
.v-btn {
margin: 0.25rem;
&:hover {
transform: translateY(-2px);
}
}
}
// Enhanced Loading States
.skeleton-loader {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.5) 25%,
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0.5) 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
}
33% {
transform: translate(30px, -30px) rotate(120deg);
}
66% {
transform: translate(-20px, 20px) rotate(240deg);
}
}
// Animated Entrance
.animated-entrance {
animation: slideInUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Professional Typography in Dashboards
.dashboard-section-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1.5rem;
position: relative;
padding-left: 1rem;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 24px;
background: linear-gradient(180deg, #dc2626 0%, #b91c1c 100%);
border-radius: 2px;
}
}
// Status Badges Enhancement
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--active {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.1) 0%,
rgba(34, 197, 94, 0.05) 100%);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.2);
}
&--pending {
background: linear-gradient(135deg,
rgba(245, 158, 11, 0.1) 0%,
rgba(245, 158, 11, 0.05) 100%);
color: #ca8a04;
border: 1px solid rgba(245, 158, 11, 0.2);
}
&--inactive {
background: linear-gradient(135deg,
rgba(107, 114, 128, 0.1) 0%,
rgba(107, 114, 128, 0.05) 100%);
color: #6b7280;
border: 1px solid rgba(107, 114, 128, 0.2);
}
}
// Chart Card Enhancement
.chart-card {
.chart-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
.chart-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.chart-subtitle {
font-size: 0.875rem;
color: #64748b;
margin-top: 0.25rem;
}
}
.chart-body {
padding: 1.5rem;
position: relative;
}
}
// Responsive Improvements
@media (max-width: 768px) {
.dashboard-header {
padding: 2rem 1rem;
.dashboard-title {
font-size: 1.75rem;
}
.dashboard-subtitle {
font-size: 1rem;
}
}
.bento-grid {
gap: 1rem !important;
}
.stat-card {
.stat-value {
font-size: 1.75rem;
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.admin-dashboard,
.board-dashboard,
.member-dashboard {
background: linear-gradient(135deg, #18181b 0%, #27272a 100%);
}
.dashboard-header.glass-header {
background: rgba(30, 30, 30, 0.9);
border-color: rgba(255, 255, 255, 0.1);
}
.glass-card {
background: rgba(30, 30, 30, 0.88) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.dashboard-title.text-gradient {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-value,
.dashboard-section-title {
color: #f4f4f5;
}
}

View File

@@ -0,0 +1,652 @@
// Monaco USA Portal - Design System v2.0
// Addressing critical issues from visual audit
// ============================================
// 1. COLOR PALETTE - Standardized
// ============================================
// Brand Colors
$monaco-red: #DC143C;
$monaco-red-dark: #B91C3C;
$monaco-red-light: #FF6B8A;
$monaco-white: #FFFFFF;
$monaco-gold: #FFD700;
// Primary color variations (for dashboard-v2 compatibility)
$primary-600: #dc2626; // Same as refined Monaco red
$primary-700: #b91c1c; // Same as monaco-red-dark
$primary-800: #991b1b; // Darker shade
// Semantic Colors
$color-success: #10B981;
$color-warning: #F59E0B;
$color-error: #EF4444;
$color-info: #3B82F6;
// Semantic color variations (for dashboard-v2 compatibility)
$success-500: #10B981;
$warning-500: #F59E0B;
$error-500: #EF4444;
$info-500: #3B82F6;
$blue-500: #3B82F6; // Same as info color
$blue-600: #2563EB; // Slightly darker blue
// Neutral Palette
$neutral-900: #0F172A;
$neutral-800: #1E293B;
$neutral-700: #334155;
$neutral-600: #475569;
$neutral-500: #64748B;
$neutral-400: #94A3B8;
$neutral-300: #CBD5E1;
$neutral-200: #E2E8F0;
$neutral-100: #F1F5F9;
$neutral-50: #F8FAFC;
// Glass Morphism
$glass-white: rgba(255, 255, 255, 0.1);
$glass-white-hover: rgba(255, 255, 255, 0.15);
$glass-border: rgba(255, 255, 255, 0.2);
$glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
// ============================================
// 2. TYPOGRAPHY - Consistent Hierarchy
// ============================================
// Font Family
$font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-mono: 'Fira Code', 'Monaco', monospace;
// Font Sizes - Using rem for accessibility
$text-xs: 0.75rem; // 12px
$text-sm: 0.875rem; // 14px
$text-base: 1rem; // 16px
$text-lg: 1.125rem; // 18px
$text-xl: 1.25rem; // 20px
$text-2xl: 1.5rem; // 24px
$text-3xl: 1.875rem; // 30px
$text-4xl: 2.25rem; // 36px
$text-4xl: 2.25rem; // 36px
$text-5xl: 3rem; // 48px
// Line Heights
$leading-none: 1;
$leading-tight: 1.2;
$leading-snug: 1.375;
$leading-normal: 1.6;
$leading-relaxed: 1.75;
$leading-loose: 2;
// Font Weights
$font-light: 300;
$font-regular: 400;
$font-medium: 500;
$font-semibold: 600;
$font-bold: 700;
$font-extrabold: 800;
// ============================================
// 3. SPACING SYSTEM - 8px Grid
// ============================================
$space-px: 1px;
$space-0: 0;
$space-1: 0.25rem; // 4px
$space-2: 0.5rem; // 8px
$space-3: 0.75rem; // 12px
$space-4: 1rem; // 16px
$space-5: 1.25rem; // 20px
$space-6: 1.5rem; // 24px
$space-7: 1.75rem; // 28px
$space-8: 2rem; // 32px
$space-10: 2.5rem; // 40px
$space-12: 3rem; // 48px
$space-16: 4rem; // 64px
$space-20: 5rem; // 80px
$space-24: 6rem; // 96px
// ============================================
// 4. BORDER RADIUS - Consistent Curves
// ============================================
$radius-none: 0;
$radius-sm: 0.25rem; // 4px
$radius-md: 0.5rem; // 8px
$radius-lg: 0.75rem; // 12px
$radius-xl: 1rem; // 16px
$radius-2xl: 1.5rem; // 24px
$radius-full: 9999px;
// ============================================
// 5. SHADOWS - Depth System
// ============================================
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
$shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
$shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
$shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.25);
$shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.06);
$shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.1);
// Additional shadows for dashboard-v2 compatibility
$shadow-inset-sm: inset 0 1px 3px rgba(0, 0, 0, 0.08);
$shadow-soft-md: 0 4px 12px rgba(0, 0, 0, 0.05);
// ============================================
// 6. BREAKPOINTS - Mobile First
// ============================================
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
@mixin sm {
@media (min-width: $breakpoint-sm) { @content; }
}
@mixin md {
@media (min-width: $breakpoint-md) { @content; }
}
@mixin lg {
@media (min-width: $breakpoint-lg) { @content; }
}
@mixin xl {
@media (min-width: $breakpoint-xl) { @content; }
}
@mixin xxl {
@media (min-width: $breakpoint-2xl) { @content; }
}
// ============================================
// 7. TRANSITIONS - Smooth Interactions
// ============================================
$ease-linear: linear;
$ease-in: cubic-bezier(0.4, 0, 1, 1);
$ease-out: cubic-bezier(0, 0, 0.2, 1);
$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
$ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
// Additional easing for dashboard-v2 compatibility
$spring-smooth: cubic-bezier(0.34, 1.56, 0.64, 1);
$duration-fast: 150ms;
$duration-normal: 250ms;
$duration-slow: 350ms;
$duration-slower: 500ms;
// Common transition for dashboard-v2 compatibility
$transition-base: all $duration-normal $ease-out;
$transition-fast: all $duration-fast $ease-out;
// ============================================
// 8. Z-INDEX SCALE - Layering System
// ============================================
$z-negative: -1;
$z-0: 0;
$z-10: 10;
$z-20: 20;
$z-30: 30;
$z-40: 40;
$z-50: 50;
$z-dropdown: 1000;
$z-sticky: 1020;
$z-fixed: 1030;
$z-modal-backdrop: 1040;
$z-modal: 1050;
$z-popover: 1060;
$z-tooltip: 1070;
$z-notification: 1080;
// ============================================
// 9. IMPROVED GLASS EFFECT MIXIN
// ============================================
@mixin glass-effect(
$blur: 10px,
$opacity: 0.1,
$border-opacity: 0.2,
$shadow: true
) {
background: rgba(255, 255, 255, $opacity);
@supports (backdrop-filter: blur($blur)) or (-webkit-backdrop-filter: blur($blur)) {
backdrop-filter: blur($blur);
-webkit-backdrop-filter: blur($blur);
}
border: 1px solid rgba(255, 255, 255, $border-opacity);
@if $shadow {
box-shadow: $shadow-glass;
}
transition: all $duration-normal $ease-out;
&:hover {
background: rgba(255, 255, 255, $opacity + 0.05);
border-color: rgba(255, 255, 255, $border-opacity + 0.1);
@if $shadow {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
}
}
// ============================================
// 10. NEUMORPHIC & MORPHING MIXINS - Dashboard V2 Compatibility
// ============================================
@mixin neumorphic-card($size: 'md') {
$depth: 6px;
$blur: 12px;
@if $size == 'sm' {
$depth: 4px;
$blur: 8px;
} @else if $size == 'lg' {
$depth: 8px;
$blur: 16px;
}
background: linear-gradient(145deg, #ffffff, #f5f5f5);
box-shadow:
$depth $depth $blur rgba(0, 0, 0, 0.1),
(-$depth) (-$depth) $blur rgba(255, 255, 255, 0.7);
border-radius: $radius-xl;
border: 1px solid rgba(255, 255, 255, 0.5);
transition: all $duration-normal $ease-out;
&:hover {
transform: translateY(-2px);
box-shadow:
($depth + 2px) ($depth + 2px) ($blur + 4px) rgba(0, 0, 0, 0.15),
(-$depth - 2px) (-$depth - 2px) ($blur + 4px) rgba(255, 255, 255, 0.8);
}
}
@mixin morphing-dropdown() {
position: relative;
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-radius: $radius-lg;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.05),
inset -2px -2px 5px rgba(255, 255, 255, 0.9);
transition: all $duration-normal $ease-out;
&:focus-within {
box-shadow:
inset 3px 3px 8px rgba(0, 0, 0, 0.1),
inset -3px -3px 8px rgba(255, 255, 255, 0.95);
}
}
@mixin neumorphic-button() {
background: linear-gradient(145deg, #ffffff, #f5f5f5);
box-shadow:
4px 4px 8px rgba(0, 0, 0, 0.1),
-4px -4px 8px rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: $radius-lg;
transition: all $duration-fast $ease-out;
&:hover {
transform: translateY(-1px);
box-shadow:
6px 6px 12px rgba(0, 0, 0, 0.12),
-6px -6px 12px rgba(255, 255, 255, 0.8);
}
&:active {
transform: translateY(0);
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.1),
inset -2px -2px 5px rgba(255, 255, 255, 0.9);
}
}
@mixin responsive($breakpoint) {
@media (min-width: $breakpoint) {
@content;
}
}
// ============================================
// 11. COMPONENT CLASSES - Reusable Styles
// ============================================
// Cards
.card-base {
@include glass-effect(12px, 0.08, 0.18, true);
border-radius: $radius-xl;
padding: $space-6;
margin-bottom: $space-4;
@include md {
padding: $space-8;
}
}
// Buttons
@mixin button-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-3 $space-6;
border-radius: $radius-lg;
font-weight: $font-medium;
transition: all $duration-normal $ease-out;
cursor: pointer;
border: 1px solid transparent;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $monaco-red;
outline-offset: 2px;
}
}
.btn-primary {
@include button-base;
background: linear-gradient(135deg, $monaco-red 0%, $monaco-red-dark 100%);
color: white;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba($monaco-red, 0.3);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-secondary {
@include button-base;
background: $neutral-100;
color: $neutral-800;
border-color: $neutral-300;
&:hover:not(:disabled) {
background: $neutral-200;
border-color: $neutral-400;
}
}
.btn-ghost {
@include button-base;
background: transparent;
color: $neutral-600;
&:hover:not(:disabled) {
background: $neutral-100;
color: $neutral-800;
}
}
// Typography Classes
.heading-1 {
font-size: $text-4xl;
font-weight: $font-bold;
line-height: $leading-tight;
color: $neutral-900;
@include md {
font-size: $text-5xl;
}
}
.heading-2 {
font-size: $text-3xl;
font-weight: $font-semibold;
line-height: $leading-tight;
color: $neutral-900;
@include md {
font-size: $text-4xl;
}
}
.heading-3 {
font-size: $text-2xl;
font-weight: $font-semibold;
line-height: $leading-snug;
color: $neutral-800;
}
.heading-4 {
font-size: $text-xl;
font-weight: $font-medium;
line-height: $leading-snug;
color: $neutral-800;
}
.body-text {
font-size: $text-base;
line-height: $leading-normal;
color: $neutral-700;
}
.small-text {
font-size: $text-sm;
line-height: $leading-normal;
color: $neutral-600;
}
// ============================================
// 12. LAYOUT UTILITIES
// ============================================
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 $space-4;
@include md {
padding: 0 $space-6;
}
@include lg {
padding: 0 $space-8;
}
}
.grid {
display: grid;
gap: $space-4;
&.grid-cols-1 {
grid-template-columns: repeat(1, 1fr);
}
@include md {
&.md\:grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
@include lg {
&.lg\:grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
&.lg\:grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
}
}
.flex {
display: flex;
&.flex-col {
flex-direction: column;
}
&.items-center {
align-items: center;
}
&.justify-between {
justify-content: space-between;
}
&.gap-2 {
gap: $space-2;
}
&.gap-4 {
gap: $space-4;
}
}
// ============================================
// 13. ANIMATION CLASSES
// ============================================
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-fadeIn {
animation: fadeIn $duration-normal $ease-out;
}
.animate-slideIn {
animation: slideIn $duration-slow $ease-out;
}
.animate-pulse {
animation: pulse 2s $ease-in-out infinite;
}
// ============================================
// 14. ACCESSIBILITY UTILITIES
// ============================================
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.focus-visible {
&:focus-visible {
outline: 2px solid $monaco-red;
outline-offset: 2px;
border-radius: $radius-sm;
}
}
// ============================================
// 15. STATUS INDICATORS
// ============================================
.status-badge {
display: inline-flex;
align-items: center;
padding: $space-1 $space-3;
border-radius: $radius-full;
font-size: $text-xs;
font-weight: $font-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
&.status-overdue {
background: rgba($color-error, 0.1);
color: $color-error;
border: 1px solid rgba($color-error, 0.2);
}
&.status-pending {
background: rgba($color-warning, 0.1);
color: $color-warning;
border: 1px solid rgba($color-warning, 0.2);
}
&.status-paid {
background: rgba($color-success, 0.1);
color: $color-success;
border: 1px solid rgba($color-success, 0.2);
}
}
// ============================================
// 16. LOADING STATES
// ============================================
.skeleton {
background: linear-gradient(
90deg,
$neutral-200 25%,
$neutral-100 50%,
$neutral-200 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: $radius-md;
&.skeleton-text {
height: $space-4;
margin-bottom: $space-2;
}
&.skeleton-card {
height: 120px;
margin-bottom: $space-4;
}
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

1046
assets/scss/main.scss Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<v-icon class="mr-3 text-white">mdi-account-plus</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
Add New Member
</h2>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-form ref="formRef" v-model="formValid" @submit.prevent="handleSubmit">
<v-row>
<!-- Personal Information Section -->
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Personal Information</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['First Name']"
label="First Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('First Name')"
:error-messages="getFieldError('First Name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['Last Name']"
label="Last Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('Last Name')"
:error-messages="getFieldError('Last Name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.Email"
label="Email Address"
type="email"
variant="outlined"
:rules="[rules.required, rules.email]"
required
:error="hasFieldError('Email')"
:error-messages="getFieldError('Email')"
/>
</v-col>
<v-col cols="12" md="6">
<PhoneInputWrapper
v-model="form.Phone"
label="Phone Number"
placeholder="Enter phone number"
:error="hasFieldError('Phone')"
:error-message="getFieldError('Phone')"
@phone-data="handlePhoneData"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['Date of Birth']"
label="Date of Birth"
type="date"
variant="outlined"
:error="hasFieldError('Date of Birth')"
:error-messages="getFieldError('Date of Birth')"
/>
</v-col>
<v-col cols="12" md="6">
<MultipleNationalityInput
v-model="form.Nationality"
label="Nationality"
:error="hasFieldError('Nationality')"
:error-message="getFieldError('Nationality')"
:max-nationalities="3"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.Address"
label="Address"
variant="outlined"
rows="2"
:error="hasFieldError('Address')"
:error-messages="getFieldError('Address')"
/>
</v-col>
<!-- Membership Information Section -->
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Membership Information</h3>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="form['Membership Status']"
:items="membershipStatusOptions"
label="Membership Status"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('Membership Status')"
:error-messages="getFieldError('Membership Status')"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="form['Member Since']"
label="Member Since"
type="date"
variant="outlined"
:error="hasFieldError('Member Since')"
:error-messages="getFieldError('Member Since')"
/>
</v-col>
<v-col cols="12" md="4">
<v-switch
v-model="duesPaid"
label="Current Year Dues Paid"
color="success"
inset
:error="hasFieldError('Current Year Dues Paid')"
:error-messages="getFieldError('Current Year Dues Paid')"
/>
</v-col>
<v-col cols="12" md="6" v-if="duesPaid">
<v-text-field
v-model="form['Membership Date Paid']"
label="Payment Date"
type="date"
variant="outlined"
:error="hasFieldError('Membership Date Paid')"
:error-messages="getFieldError('Membership Date Paid')"
hint="Enter the actual date when dues were paid (can be historical)"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6" v-if="!duesPaid">
<v-text-field
v-model="form['Payment Due Date']"
label="Payment Due Date"
type="date"
variant="outlined"
:error="hasFieldError('Payment Due Date')"
:error-messages="getFieldError('Payment Due Date')"
hint="Enter when payment is due (for new members in grace period)"
persistent-hint
/>
</v-col>
<!-- Dues Status Preview -->
<v-col cols="12" v-if="duesPaid && form['Membership Date Paid']">
<v-card variant="tonal" :color="calculatedDuesStatus.color" class="pa-3">
<div class="d-flex align-center">
<v-icon :color="calculatedDuesStatus.color" class="mr-2">
{{ calculatedDuesStatus.icon }}
</v-icon>
<div>
<div class="text-subtitle-2 font-weight-bold">
Calculated Dues Status: {{ calculatedDuesStatus.text }}
</div>
<div class="text-caption">
{{ calculatedDuesStatus.message }}
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-account-plus</v-icon>
Add Member
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { formatBooleanAsString } from '~/utils/client-utils';
import { isPaymentOverOneYear, isDuesActuallyCurrent, calculateOverdueDays } from '~/utils/dues-calculations';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'member-created', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
// Form data
const form = ref({
'First Name': '',
'Last Name': '',
Email: '',
Phone: '',
'Date of Birth': '',
Nationality: '',
Address: '',
'Membership Status': 'Active',
'Member Since': new Date().toISOString().split('T')[0], // Today's date
'Current Year Dues Paid': 'false',
'Membership Date Paid': '',
'Payment Due Date': ''
});
// Additional form state
const duesPaid = ref(false);
const phoneData = ref(null);
// Error handling
const fieldErrors = ref<Record<string, string>>({});
// Computed dues status calculation
const calculatedDuesStatus = computed(() => {
if (!duesPaid.value || !form.value['Membership Date Paid']) {
return {
color: 'grey',
icon: 'mdi-help',
text: 'Unknown',
message: 'Please enter payment date to calculate status'
};
}
// Create a mock member object with form data to use calculation functions
const mockMember = {
current_year_dues_paid: 'true',
membership_date_paid: form.value['Membership Date Paid'],
payment_due_date: form.value['Payment Due Date'],
member_since: form.value['Member Since']
} as Member;
const isOverdue = !isDuesActuallyCurrent(mockMember);
const paymentTooOld = isPaymentOverOneYear(mockMember);
if (isOverdue && paymentTooOld) {
const overdueDays = calculateOverdueDays(mockMember);
return {
color: 'error',
icon: 'mdi-alert-circle',
text: 'Overdue',
message: `Payment is ${overdueDays} days overdue (more than 1 year since payment)`
};
} else if (isOverdue) {
return {
color: 'warning',
icon: 'mdi-clock-alert',
text: 'Due Soon',
message: 'Dues will be due soon based on payment date'
};
} else {
const paymentDate = new Date(form.value['Membership Date Paid']);
const nextDue = new Date(paymentDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
const nextDueFormatted = nextDue.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {
color: 'success',
icon: 'mdi-check-circle',
text: 'Current',
message: `Dues are current. Next payment due: ${nextDueFormatted}`
};
}
});
// Watch dues paid switch
watch(duesPaid, (newValue) => {
form.value['Current Year Dues Paid'] = formatBooleanAsString(newValue);
if (newValue) {
form.value['Payment Due Date'] = '';
} else {
form.value['Membership Date Paid'] = '';
}
});
// Membership status options
const membershipStatusOptions = [
{ title: 'Active', value: 'Active' },
{ title: 'Inactive', value: 'Inactive' },
{ title: 'Pending', value: 'Pending' },
{ title: 'Expired', value: 'Expired' }
];
// Validation rules
const rules = {
required: (value: any) => {
if (typeof value === 'string') {
return !!value?.trim() || 'This field is required';
}
return !!value || 'This field is required';
},
email: (value: string) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !value || pattern.test(value) || 'Please enter a valid email address';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const clearFieldErrors = () => {
fieldErrors.value = {};
};
// Phone data handler
const handlePhoneData = (data: any) => {
phoneData.value = data;
};
// Form submission
const handleSubmit = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
// Debug: Log the current form state
console.log('[AddMemberDialog] Form validation passed');
console.log('[AddMemberDialog] Current form.value:', JSON.stringify(form.value, null, 2));
console.log('[AddMemberDialog] Form keys:', Object.keys(form.value));
console.log('[AddMemberDialog] duesPaid switch value:', duesPaid.value);
// Get current form values
const currentForm = unref(form);
console.log('[AddMemberDialog] Unref form access test:');
console.log(' - First Name:', currentForm['First Name']);
console.log(' - Last Name:', currentForm['Last Name']);
console.log(' - Email:', currentForm.Email);
console.log(' - Phone:', currentForm.Phone);
// Simple approach - send the form data as-is with display names
// Let the server handle field normalization
const memberData = {
'First Name': currentForm['First Name']?.trim(),
'Last Name': currentForm['Last Name']?.trim(),
'Email': currentForm.Email?.trim(),
'Phone': currentForm.Phone?.trim() || '',
'Date of Birth': currentForm['Date of Birth'] || '',
'Nationality': currentForm.Nationality?.trim() || '',
'Address': currentForm.Address?.trim() || '',
'Membership Status': currentForm['Membership Status'],
'Member Since': currentForm['Member Since'] || '',
'Current Year Dues Paid': currentForm['Current Year Dues Paid'],
'Membership Date Paid': currentForm['Membership Date Paid'] || '',
'Payment Due Date': currentForm['Payment Due Date'] || ''
};
// Ensure required fields are not empty
if (!memberData['First Name']) {
console.error('[AddMemberDialog] First Name is empty. Raw value:', currentForm['First Name']);
throw new Error('First Name is required');
}
if (!memberData['Last Name']) {
console.error('[AddMemberDialog] Last Name is empty. Raw value:', currentForm['Last Name']);
throw new Error('Last Name is required');
}
if (!memberData['Email']) {
console.error('[AddMemberDialog] Email is empty. Raw value:', currentForm.Email);
throw new Error('Email is required');
}
console.log('[AddMemberDialog] Final memberData:', JSON.stringify(memberData, null, 2));
console.log('[AddMemberDialog] About to submit to API...');
const response = await $fetch<{ success: boolean; data: Member; message?: string }>('/api/members', {
method: 'POST',
body: memberData
});
if (response.success && response.data) {
console.log('[AddMemberDialog] Member created successfully:', response.data);
emit('member-created', response.data);
closeDialog();
resetForm();
} else {
throw new Error(response.message || 'Failed to create member');
}
} catch (error: any) {
console.error('[AddMemberDialog] Error creating member:', error);
// Handle validation errors
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
// Show general error
fieldErrors.value = {
general: error.message || 'Failed to create member. Please try again.'
};
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
const resetForm = () => {
form.value = {
'First Name': '',
'Last Name': '',
Email: '',
Phone: '',
'Date of Birth': '',
Nationality: '',
Address: '',
'Membership Status': 'Active',
'Member Since': new Date().toISOString().split('T')[0],
'Current Year Dues Paid': 'false',
'Membership Date Paid': '',
'Payment Due Date': ''
};
duesPaid.value = false;
phoneData.value = null;
clearFieldErrors();
// Reset form validation
nextTick(() => {
formRef.value?.resetValidation();
});
};
// Watch for dialog open/close
watch(() => props.modelValue, (newValue) => {
if (newValue) {
// Dialog opened - reset form
resetForm();
}
});
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section spacing */
.v-card-text .v-row .v-col:first-child h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Error message styling */
.field-error {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Switch styling */
.v-switch {
flex: none;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.v-dialog {
margin: 16px;
}
}
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,238 @@
<template>
<v-card elevation="4" class="dues-management-card" style="border: 2px solid #dc2626; border-radius: 16px;">
<v-card-title class="pa-4 bg-warning-lighten-5">
<v-icon class="mr-3" color="warning" size="28">mdi-cash-multiple</v-icon>
<span class="text-h6 font-weight-bold">Dues Management</span>
<v-spacer />
<v-chip color="warning" size="small">
{{ overdueMembers.length + upcomingMembers.length }} Action Items
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
<v-tabs v-model="activeTab" color="primary" class="mb-4">
<v-tab value="overdue">
<v-icon start>mdi-alert-circle</v-icon>
Overdue ({{ overdueMembers.length }})
</v-tab>
<v-tab value="upcoming">
<v-icon start>mdi-clock-alert</v-icon>
Due Soon ({{ upcomingMembers.length }})
</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab">
<!-- Overdue Dues Tab -->
<v-tabs-window-item value="overdue">
<div v-if="overdueMembers.length === 0" class="text-center py-6">
<v-icon size="48" color="success" class="mb-2">mdi-check-circle</v-icon>
<p class="text-h6 text-success">All caught up!</p>
<p class="text-body-2">No members have overdue dues.</p>
</div>
<v-row v-else>
<v-col
v-for="member in overdueMembers"
:key="member.Id"
cols="12"
md="6"
lg="4"
>
<DuesActionCard
:member="member"
status="overdue"
@mark-paid="handleMarkPaid"
@view-member="handleViewMember"
:loading="loading[member.Id]"
/>
</v-col>
</v-row>
</v-tabs-window-item>
<!-- Upcoming Dues Tab -->
<v-tabs-window-item value="upcoming">
<div v-if="upcomingMembers.length === 0" class="text-center py-6">
<v-icon size="48" color="info" class="mb-2">mdi-calendar-check</v-icon>
<p class="text-h6 text-info">All up to date!</p>
<p class="text-body-2">No upcoming dues in the next 30 days.</p>
</div>
<v-row v-else>
<v-col
v-for="member in upcomingMembers"
:key="member.Id"
cols="12"
md="6"
lg="4"
>
<DuesActionCard
:member="member"
status="upcoming"
@mark-paid="handleMarkPaid"
@view-member="handleViewMember"
:loading="loading[member.Id]"
/>
</v-col>
</v-row>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
<!-- Refresh Button -->
<v-card-actions class="pa-4">
<v-btn
color="primary"
variant="outlined"
:loading="refreshLoading"
@click="refreshData"
>
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="text"
@click="$emit('view-all-members')"
>
<v-icon start>mdi-account-group</v-icon>
View All Members
</v-btn>
</v-card-actions>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
interface Props {
refreshTrigger?: number;
}
interface Emits {
(e: 'view-member', member: Member): void;
(e: 'view-all-members'): void;
(e: 'member-updated', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// State
const activeTab = ref('overdue');
const overdueMembers = ref<Member[]>([]);
const upcomingMembers = ref<Member[]>([]);
const loading = ref<Record<string, boolean>>({});
const refreshLoading = ref(false);
// View member dialog state
const showViewDialog = ref(false);
const selectedMember = ref<Member | null>(null);
// Load dues data
const loadDuesData = async () => {
refreshLoading.value = true;
try {
const response = await $fetch<{
success: boolean;
data: {
overdue: Member[];
upcoming: Member[];
};
}>('/api/members/dues-status');
if (response.success) {
// Sort members alphabetically by last name, then first name
const sortByName = (a: Member, b: Member) => {
const aLastName = (a.last_name || '').toLowerCase();
const bLastName = (b.last_name || '').toLowerCase();
const aFirstName = (a.first_name || '').toLowerCase();
const bFirstName = (b.first_name || '').toLowerCase();
const lastNameCompare = aLastName.localeCompare(bLastName);
if (lastNameCompare !== 0) return lastNameCompare;
return aFirstName.localeCompare(bFirstName);
};
overdueMembers.value = (response.data.overdue || []).sort(sortByName);
upcomingMembers.value = (response.data.upcoming || []).sort(sortByName);
}
} catch (error) {
console.error('Error loading dues data:', error);
// Show error notification
} finally {
refreshLoading.value = false;
}
};
// Handle mark as paid - let DuesActionCard handle the date picker and API call
const handleMarkPaid = async (member: Member) => {
// Remove member from current lists since they've been marked as paid
overdueMembers.value = overdueMembers.value.filter(m => m.Id !== member.Id);
upcomingMembers.value = upcomingMembers.value.filter(m => m.Id !== member.Id);
// Emit update event
emit('member-updated', member);
// Show success message
console.log('Dues marked as paid successfully');
};
// Handle view member
const handleViewMember = (member: Member) => {
selectedMember.value = member;
showViewDialog.value = true;
};
// Handle edit member (from the view dialog)
const handleEditMember = (member: Member) => {
// Close the view dialog first
showViewDialog.value = false;
// Emit the view-member event which should trigger the edit dialog in the parent component
emit('view-member', member);
};
// Refresh data
const refreshData = () => {
loadDuesData();
};
// Watch for refresh trigger
watch(() => props.refreshTrigger, () => {
if (props.refreshTrigger) {
loadDuesData();
}
});
// Load data on mount
onMounted(() => {
loadDuesData();
});
</script>
<style scoped>
.dues-management-card {
border-radius: 12px !important;
}
.bg-warning-lighten-5 {
background-color: rgb(var(--v-theme-warning-lighten-5)) !important;
}
.v-tab {
text-transform: none !important;
}
.v-card-title {
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
}
</style>

116
components/CountryFlag.vue Normal file
View File

@@ -0,0 +1,116 @@
<template>
<span class="country-flag" :class="{ 'country-flag--small': size === 'small' }">
<ClientOnly>
<VueCountryFlag
v-if="actualCountryCode"
:country="actualCountryCode"
:size="flagSize"
:title="getCountryName(actualCountryCode)"
/>
<template #fallback>
<span class="flag-placeholder" :style="placeholderStyle">🏳</span>
</template>
</ClientOnly>
<span v-if="showName && actualCountryCode" class="country-name">
{{ getCountryName(actualCountryCode) }}
</span>
</span>
</template>
<script setup lang="ts">
import VueCountryFlag from 'vue-country-flag-next';
import { getCountryName, parseCountryInput } from '~/utils/countries';
interface Props {
countryCode?: string;
showName?: boolean;
size?: 'small' | 'medium' | 'large';
square?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
countryCode: '',
showName: true,
size: 'medium',
square: false
});
// Convert country name to country code if needed
const actualCountryCode = computed(() => {
if (!props.countryCode) return '';
// If it's already a 2-letter code, use it
if (props.countryCode.length === 2) {
return props.countryCode.toUpperCase();
}
// Try to parse country name to get the code
const parsed = parseCountryInput(props.countryCode);
return parsed || '';
});
const flagSize = computed(() => {
const sizeMap = {
small: 'sm',
medium: 'md',
large: 'lg'
};
return sizeMap[props.size];
});
const placeholderStyle = computed(() => {
const sizeMap = {
small: '1rem',
medium: '1.5rem',
large: '2rem'
};
return {
width: sizeMap[props.size],
height: props.square ? sizeMap[props.size] : `calc(${sizeMap[props.size]} * 0.75)`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
backgroundColor: '#f5f5f5',
fontSize: '0.75rem'
};
});
</script>
<style scoped>
.country-flag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
vertical-align: middle;
}
.country-flag--small {
gap: 0.25rem;
}
.country-name {
font-size: 0.875rem;
color: inherit;
white-space: nowrap;
}
.country-flag--small .country-name {
font-size: 0.75rem;
}
.flag-placeholder {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* Ensure proper flag display */
:deep(.vue-country-flag) {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,778 @@
<template>
<v-dialog v-model="show" max-width="800" persistent>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2">mdi-calendar-plus</v-icon>
<span>Create New Event</span>
</div>
<v-btn
@click="close"
icon
variant="text"
size="small"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="valid" @submit.prevent="handleSubmit">
<v-row>
<!-- Basic Information -->
<v-col cols="12">
<v-text-field
v-model="eventData.title"
label="Event Title*"
:rules="[v => !!v || 'Title is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<VuetifyTiptap
v-model="eventData.description"
label="Description"
:toolbar="[
'bold',
'italic',
'underline',
'|',
'heading',
'|',
'bulletList',
'orderedList',
'|',
'link',
'|',
'undo',
'redo'
]"
:max-height="200"
placeholder="Enter event description with formatting..."
outlined
/>
</v-col>
<!-- Event Type and Visibility -->
<v-col cols="12" md="6">
<v-select
v-model="eventData.event_type"
:items="eventTypes"
label="Event Type*"
:rules="[v => !!v || 'Event type is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventData.visibility"
:items="visibilityOptions"
label="Visibility*"
:rules="[v => !!v || 'Visibility is required']"
variant="outlined"
required
/>
</v-col>
<!-- Date and Time -->
<v-col cols="12" md="6">
<v-text-field
v-model="startDate"
label="Start Date*"
type="date"
:rules="dateValidationRules.startDate"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:min="new Date().toISOString().split('T')[0]"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="startTime"
label="Start Time*"
type="time"
:rules="dateValidationRules.startTime"
variant="outlined"
prepend-inner-icon="mdi-clock"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="endDate"
label="End Date*"
type="date"
:rules="dateValidationRules.endDate"
variant="outlined"
prepend-inner-icon="mdi-calendar"
:min="startDate || new Date().toISOString().split('T')[0]"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="endTime"
label="End Time*"
type="time"
:rules="dateValidationRules.endTime"
variant="outlined"
prepend-inner-icon="mdi-clock"
required
/>
</v-col>
<!-- Location -->
<v-col cols="12">
<v-text-field
v-model="eventData.location"
label="Location"
variant="outlined"
/>
</v-col>
<!-- Capacity Settings -->
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.max_attendees"
label="Maximum Attendees"
type="number"
variant="outlined"
hint="Leave empty for unlimited capacity"
persistent-hint
/>
</v-col>
<!-- Guest Settings -->
<v-col cols="12" md="6">
<v-switch
v-model="allowGuests"
label="Allow Guests"
color="primary"
inset
hint="Members can bring additional guests"
persistent-hint
/>
</v-col>
<!-- Max Guests Per Person (shown when guests allowed) -->
<v-col v-if="allowGuests" cols="12" md="6">
<v-text-field
v-model="maxGuestsPerPerson"
label="Max Guests Per Person"
type="number"
variant="outlined"
:rules="allowGuests ? [v => v && parseInt(v) > 0 || 'Must allow at least 1 guest'] : []"
hint="Maximum additional guests each member can bring"
persistent-hint
/>
</v-col>
<!-- Payment Settings -->
<v-col cols="12" :md="allowGuests ? 6 : 6">
<v-switch
v-model="isPaidEvent"
label="Paid Event"
color="primary"
inset
/>
</v-col>
<!-- Payment Details (shown when paid event) -->
<template v-if="isPaidEvent">
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.cost_members"
label="Cost for Members (€)"
type="number"
step="0.01"
variant="outlined"
:rules="isPaidEvent ? [v => !!v || 'Member cost is required'] : []"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.cost_non_members"
label="Cost for Non-Members (€)"
type="number"
step="0.01"
variant="outlined"
:rules="isPaidEvent ? [v => !!v || 'Non-member cost is required'] : []"
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="memberPricingEnabled"
label="Enable Member Pricing"
color="primary"
inset
hint="Allow current members to pay member rates"
persistent-hint
/>
</v-col>
</template>
<!-- Advanced Options -->
<v-col cols="12">
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
Advanced Options
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-switch
v-model="isRecurring"
label="Recurring Event"
color="primary"
inset
hint="Create a series of events"
persistent-hint
/>
</v-col>
<v-col v-if="isRecurring" cols="12" md="6">
<v-select
v-model="recurrenceFrequency"
:items="recurrenceOptions"
label="Frequency"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventData.status"
:items="statusOptions"
label="Status"
variant="outlined"
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-form>
</v-card-text>
<!-- Error message display -->
<v-card-text v-if="errorMessage" class="pt-0">
<v-alert
type="error"
variant="tonal"
closable
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="close"
variant="outlined"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
@click="handleSubmit"
color="primary"
:loading="loading"
:disabled="!valid"
>
Create Event
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { EventCreateRequest } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
import { useEvents } from '~/composables/useEvents';
interface Props {
modelValue: boolean;
prefilledDate?: string;
prefilledEndDate?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
prefilledDate: undefined,
prefilledEndDate: undefined
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'event-created': [event: any];
}>();
const { isAdmin } = useAuth();
const { createEvent } = useEvents();
// Reactive state
const form = ref();
const valid = ref(false);
const loading = ref(false);
const isPaidEvent = ref(false);
const memberPricingEnabled = ref(true);
const isRecurring = ref(false);
const recurrenceFrequency = ref('weekly');
// Date and time picker state
const startDate = ref<string>('');
const startTime = ref<string>('');
const endDate = ref<string>('');
const endTime = ref<string>('');
// Form data
const eventData = reactive<EventCreateRequest>({
title: '',
description: '',
event_type: 'social',
start_datetime: '',
end_datetime: '',
location: '',
max_attendees: '',
is_paid: 'false',
cost_members: '',
cost_non_members: '',
member_pricing_enabled: 'true',
visibility: 'public',
status: 'active',
guests_permitted: 'false',
max_guests_permitted: '0'
});
// Guest settings
const allowGuests = ref(false);
const maxGuestsPerPerson = ref(1);
// Computed
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Options
const eventTypes = [
{ title: 'Social Event', value: 'social' },
{ title: 'Meeting', value: 'meeting' },
{ title: 'Fundraiser', value: 'fundraiser' },
{ title: 'Workshop', value: 'workshop' },
{ title: 'Board Only', value: 'board-only' }
];
const visibilityOptions = computed(() => {
const options = [
{ title: 'Public', value: 'public' },
{ title: 'Board Only', value: 'board-only' }
];
if (isAdmin.value) {
options.push({ title: 'Admin Only', value: 'admin-only' });
}
return options;
});
const statusOptions = [
{ title: 'Active', value: 'active' },
{ title: 'Draft', value: 'draft' }
];
const recurrenceOptions = [
{ title: 'Weekly', value: 'weekly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Yearly', value: 'yearly' }
];
// Watchers
watch(isPaidEvent, (newValue) => {
eventData.is_paid = newValue ? 'true' : 'false';
});
watch(memberPricingEnabled, (newValue) => {
eventData.member_pricing_enabled = newValue ? 'true' : 'false';
});
watch(allowGuests, (newValue) => {
eventData.guests_permitted = newValue ? 'true' : 'false';
if (!newValue) {
eventData.max_guests_permitted = '0';
maxGuestsPerPerson.value = 1;
}
});
watch(maxGuestsPerPerson, (newValue) => {
if (allowGuests.value) {
eventData.max_guests_permitted = newValue.toString();
}
});
watch(isRecurring, (newValue) => {
eventData.is_recurring = newValue ? 'true' : 'false';
if (newValue) {
eventData.recurrence_pattern = JSON.stringify({
frequency: recurrenceFrequency.value,
interval: 1,
end_date: null
});
} else {
eventData.recurrence_pattern = '';
}
});
watch(recurrenceFrequency, (newValue) => {
if (isRecurring.value) {
eventData.recurrence_pattern = JSON.stringify({
frequency: newValue,
interval: 1,
end_date: null
});
}
});
// Auto-fill end date when start date is selected (most events are same day)
watch(startDate, (newStartDate) => {
if (newStartDate && !endDate.value) {
// Auto-fill end date to same as start date for same-day events
endDate.value = newStartDate;
console.log('[CreateEventDialog] Auto-filled end date to match start date:', newStartDate);
}
});
// Consolidated watcher for all date/time changes
watch([startDate, startTime, endDate, endTime], ([newStartDate, newStartTime, newEndDate, newEndTime]) => {
// Update start datetime
if (newStartDate && newStartTime) {
const startDateTime = createDateTime(newStartDate, newStartTime);
if (startDateTime) {
eventData.start_datetime = startDateTime.toISOString();
console.log('[CreateEventDialog] Updated start datetime:', eventData.start_datetime);
}
}
// Update end datetime
if (newEndDate && newEndTime) {
const endDateTime = createDateTime(newEndDate, newEndTime);
if (endDateTime) {
eventData.end_datetime = endDateTime.toISOString();
console.log('[CreateEventDialog] Updated end datetime:', eventData.end_datetime);
}
}
}, { deep: true });
// Watch for prefilled dates
watch(() => props.prefilledDate, (newDate) => {
if (newDate) {
const prefillDate = new Date(newDate);
startDate.value = prefillDate.toISOString().split('T')[0];
startTime.value = prefillDate.toTimeString().substring(0, 5);
// Set end date 2 hours later if not provided
if (!props.prefilledEndDate) {
const endDateTime = new Date(prefillDate);
endDateTime.setHours(endDateTime.getHours() + 2);
endDate.value = endDateTime.toISOString().split('T')[0];
endTime.value = endDateTime.toTimeString().substring(0, 5);
}
}
}, { immediate: true });
watch(() => props.prefilledEndDate, (newEndDate) => {
if (newEndDate) {
const prefillEndDate = new Date(newEndDate);
endDate.value = prefillEndDate.toISOString().split('T')[0];
endTime.value = prefillEndDate.toTimeString().substring(0, 5);
}
}, { immediate: true });
// Simple date/time functions
const createDateTime = (dateStr: string, timeStr: string): Date | null => {
if (!dateStr || !timeStr) return null;
const combined = new Date(`${dateStr}T${timeStr}:00`);
return isNaN(combined.getTime()) ? null : combined;
};
const isValidDateTime = (date: Date | null): boolean => {
return date !== null && !isNaN(date.getTime());
};
// Simple end time validation
const validateEndTime = (endTimeValue: string): boolean => {
if (!startDate.value || !endDate.value || !startTime.value || !endTimeValue) return true;
if (startDate.value !== endDate.value) return true;
const startDateTime = createDateTime(startDate.value, startTime.value);
const endDateTime = createDateTime(endDate.value, endTimeValue);
if (!startDateTime || !endDateTime) return false;
return endDateTime > startDateTime;
};
// Validation rules
const dateValidationRules = {
startDate: [
(v: string) => !!v || 'Start date is required',
(v: string) => !v || new Date(v).getTime() >= new Date().setHours(0,0,0,0) || 'Start date cannot be in the past'
],
startTime: [
(v: string) => !!v || 'Start time is required'
],
endDate: [
(v: string) => !!v || 'End date is required',
(v: string) => !v || !startDate.value || new Date(v).getTime() >= new Date(startDate.value).getTime() || 'End date must be same or after start date'
],
endTime: [
(v: string) => !!v || 'End time is required',
(v: string) => validateEndTime(v) || 'End time must be after start time when on same date'
]
};
// Methods
const resetForm = () => {
eventData.title = '';
eventData.description = '';
eventData.event_type = 'social';
eventData.start_datetime = '';
eventData.end_datetime = '';
eventData.location = '';
eventData.max_attendees = '';
eventData.is_paid = 'false';
eventData.cost_members = '';
eventData.cost_non_members = '';
eventData.member_pricing_enabled = 'true';
eventData.guests_permitted = 'false';
eventData.max_guests_permitted = '0';
eventData.visibility = 'public';
eventData.status = 'active';
eventData.is_recurring = 'false';
eventData.recurrence_pattern = '';
// Reset date/time fields
startDate.value = '';
startTime.value = '';
endDate.value = '';
endTime.value = '';
// Reset UI state
isPaidEvent.value = false;
memberPricingEnabled.value = true;
isRecurring.value = false;
recurrenceFrequency.value = 'weekly';
allowGuests.value = false;
maxGuestsPerPerson.value = 1;
form.value?.resetValidation();
};
const close = () => {
show.value = false;
resetForm();
};
// Error handling
const errorMessage = ref<string | null>(null);
const handleSubmit = async () => {
if (!form.value) return;
const isValid = await form.value.validate();
if (!isValid.valid) return;
// Clear previous errors
errorMessage.value = null;
// Validate that we have proper date/time combination
if (!startDate.value || !startTime.value) {
errorMessage.value = 'Start date and time are required';
return;
}
if (!endDate.value || !endTime.value) {
errorMessage.value = 'End date and time are required';
return;
}
loading.value = true;
try {
// Simple date validation using our new function
const startDateTime = createDateTime(startDate.value, startTime.value);
const endDateTime = createDateTime(endDate.value, endTime.value);
if (!startDateTime) {
errorMessage.value = 'Please enter a valid start date and time';
loading.value = false;
return;
}
if (!endDateTime) {
errorMessage.value = 'Please enter a valid end date and time';
loading.value = false;
return;
}
// Validate start is not in the past
if (startDateTime < new Date()) {
errorMessage.value = 'Event start time cannot be in the past';
loading.value = false;
return;
}
// Validate end is after start (using getTime() for precise comparison)
if (endDateTime.getTime() <= startDateTime.getTime()) {
errorMessage.value = 'Event end time must be after start time';
loading.value = false;
return;
}
const formattedEventData = {
...eventData,
start_datetime: startDateTime.toISOString(),
end_datetime: endDateTime.toISOString()
};
console.log('[CreateEventDialog] Creating event with data:', formattedEventData);
const newEvent = await createEvent(formattedEventData);
emit('event-created', newEvent);
close();
} catch (error: any) {
console.error('Error creating event:', error);
// Parse error message for better UX
let userErrorMessage = 'Failed to create event';
if (error?.data?.message) {
userErrorMessage = error.data.message;
} else if (error?.message) {
if (error.message.includes('past')) {
userErrorMessage = 'Event date cannot be in the past';
} else if (error.message.includes('validation')) {
userErrorMessage = 'Please check all required fields';
} else {
userErrorMessage = error.message;
}
}
errorMessage.value = userErrorMessage;
} finally {
loading.value = false;
}
};
// Removed duplicate prefilled date logic - handled by watchers above
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.v-expansion-panel-title {
font-weight: 500;
}
.v-switch {
flex: 0 0 auto;
}
.v-text-field :deep(.v-field__input) {
min-height: 56px;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<v-dialog
v-model="isOpen"
max-width="500"
persistent
@keydown.esc="cancel"
>
<v-card class="rounded-lg">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-account-plus</v-icon>
<span class="text-h6">Create Portal Account</span>
</v-card-title>
<v-card-text class="pb-2">
<div class="mb-4">
<p class="text-body-1 mb-2">
Create a portal account for <strong>{{ member?.FullName }}</strong>
</p>
<p class="text-body-2 text-medium-emphasis">
{{ member?.email }}
</p>
</div>
<div class="mb-4">
<v-alert
type="info"
variant="tonal"
class="mb-4"
icon="mdi-information"
>
<template #text>
<div class="text-body-2">
The user will receive an email to set up their password and complete registration.
</div>
</template>
</v-alert>
</div>
<v-form ref="formRef" v-model="formValid">
<v-select
v-model="selectedGroup"
:items="groupOptions"
label="Assign to Group"
variant="outlined"
density="comfortable"
:rules="groupRules"
prepend-inner-icon="mdi-account-group"
class="mb-3"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<v-icon :color="item.raw.color">{{ item.raw.icon }}</v-icon>
</template>
<v-list-item-title>{{ item.raw.title }}</v-list-item-title>
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
</v-list-item>
</template>
<template #selection="{ item }">
<div class="d-flex align-center">
<v-icon :color="item.raw.color" class="mr-2">{{ item.raw.icon }}</v-icon>
{{ item.raw.title }}
</div>
</template>
</v-select>
<!-- Group Description -->
<v-card
v-if="selectedGroup"
variant="tonal"
color="primary"
class="mb-3"
>
<v-card-text class="py-3">
<div class="d-flex align-center mb-2">
<v-icon :color="selectedGroupInfo?.color" class="mr-2">
{{ selectedGroupInfo?.icon }}
</v-icon>
<span class="font-weight-medium">{{ selectedGroupInfo?.title }}</span>
</div>
<p class="text-body-2 mb-2">{{ selectedGroupInfo?.description }}</p>
<div class="text-caption">
<strong>Permissions:</strong>
<ul class="mt-1 ml-4">
<li v-for="permission in selectedGroupInfo?.permissions" :key="permission">
{{ permission }}
</li>
</ul>
</div>
</v-card-text>
</v-card>
<!-- Error Alert -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-3"
closable
@click:close="errorMessage = ''"
>
{{ errorMessage }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-6">
<v-spacer />
<v-btn
variant="text"
@click="cancel"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="createAccount"
:loading="loading"
:disabled="!formValid || loading"
>
<v-icon start>mdi-account-plus</v-icon>
Create Account
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
interface Props {
modelValue: boolean;
member: Member | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'account-created', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Reactive state
const loading = ref(false);
const errorMessage = ref('');
const formValid = ref(false);
const selectedGroup = ref('user');
const formRef = ref();
// Computed
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Group options with detailed information
const groupOptions = [
{
title: 'User',
value: 'user',
description: 'Standard member access',
icon: 'mdi-account',
color: 'primary',
permissions: [
'View own profile and update personal information',
'View events and RSVP',
'Access member directory (if enabled)',
'View dues status and payment history'
]
},
{
title: 'Board Member',
value: 'board',
description: 'Board member privileges',
icon: 'mdi-account-tie',
color: 'warning',
permissions: [
'All user permissions',
'Create and manage members',
'Create and manage events',
'View member statistics',
'Access board tools and reports'
]
},
{
title: 'Administrator',
value: 'admin',
description: 'Full system access',
icon: 'mdi-shield-crown',
color: 'error',
permissions: [
'All board member permissions',
'System configuration and settings',
'User and group management',
'Delete members and sensitive operations',
'Access admin panel and logs'
]
}
];
const selectedGroupInfo = computed(() => {
return groupOptions.find(group => group.value === selectedGroup.value);
});
// Validation rules
const groupRules = [
(v: string) => !!v || 'Please select a group'
];
// Methods
const cancel = () => {
errorMessage.value = '';
selectedGroup.value = 'user';
isOpen.value = false;
};
const createAccount = async () => {
if (!formValid.value || !props.member) return;
try {
loading.value = true;
errorMessage.value = '';
console.log('[CreatePortalAccountDialog] Creating portal account for:', props.member.email, 'Group:', selectedGroup.value);
const response = await $fetch(`/api/members/${props.member.Id}/create-portal-account`, {
method: 'POST',
body: {
membershipTier: selectedGroup.value
}
});
if (response?.success) {
console.log('[CreatePortalAccountDialog] Portal account created successfully');
// Update the member object with the keycloak_id
const updatedMember = {
...props.member,
keycloak_id: response.data?.keycloak_id
};
emit('account-created', updatedMember);
isOpen.value = false;
// Reset form
selectedGroup.value = 'user';
} else {
throw new Error(response?.message || 'Failed to create portal account');
}
} catch (err: any) {
console.error('[CreatePortalAccountDialog] Error creating portal account:', err);
// Better error handling
let message = 'Failed to create portal account. Please try again.';
if (err.statusCode === 409) {
message = 'This member already has a portal account or a user with this email already exists.';
} else if (err.statusCode === 400) {
message = 'Member must have email, first name, and last name to create a portal account.';
} else if (err.data?.message) {
message = err.data.message;
} else if (err.message) {
message = err.message;
}
errorMessage.value = message;
} finally {
loading.value = false;
}
};
// Reset form when dialog opens
watch(() => props.modelValue, (newValue) => {
if (newValue) {
selectedGroup.value = 'user';
errorMessage.value = '';
}
});
</script>
<style scoped>
.v-card {
border-radius: 12px !important;
}
.v-list-item-subtitle {
opacity: 0.7;
}
/* Improve list styling */
ul {
list-style-type: disc;
margin-left: 0;
}
li {
margin-bottom: 2px;
}
/* Better mobile spacing */
@media (max-width: 600px) {
.v-card-actions {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
}
</style>

View File

@@ -0,0 +1,593 @@
<template>
<v-card
:class="[
'dues-action-card',
status === 'overdue' ? 'dues-action-card--overdue' : 'dues-action-card--upcoming'
]"
elevation="2"
>
<!-- Status Badge -->
<div class="status-badge">
<v-chip
:color="statusColor"
size="small"
variant="flat"
>
<v-icon start size="12">{{ statusIcon }}</v-icon>
{{ statusText }}
</v-chip>
</div>
<v-card-text class="pa-4">
<!-- Member Info Header -->
<div class="d-flex align-center mb-3">
<ProfileAvatar
:member-id="member.member_id || member.Id"
:first-name="member.first_name"
:last-name="member.last_name"
:member-name="member.FullName"
size="small"
class="mr-3"
/>
<div class="flex-grow-1">
<h4 class="text-subtitle-1 font-weight-bold mb-1">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h4>
<div class="d-flex align-center">
<v-chip size="x-small" color="grey" variant="text" class="pa-0 mr-2">
ID: {{ member.member_id || 'Pending' }}
</v-chip>
<MultipleCountryFlags
v-if="member.nationality"
:country-codes="member.nationality"
:show-name="false"
size="small"
/>
</div>
</div>
</div>
<!-- Dues Information -->
<div class="dues-info mb-3">
<div v-if="status === 'overdue'">
<!-- Overdue Information -->
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock-alert</v-icon>
Days Overdue
</span>
<span class="text-body-2 font-weight-bold text-error">
{{ calculateDisplayOverdueDays(member) }} days
</span>
</div>
<div v-if="member.overdueReason" class="overdue-reason">
<span class="text-caption text-error">
<v-icon size="12" class="mr-1">mdi-information</v-icon>
{{ member.overdueReason }}
</span>
</div>
<div v-if="member.membership_date_paid" class="d-flex justify-space-between align-center mt-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-calendar-check</v-icon>
Last Payment
</span>
<span class="text-body-2">
{{ formatDate(member.membership_date_paid) }}
</span>
</div>
</div>
<div v-else>
<!-- Upcoming Information -->
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-calendar</v-icon>
Due Date
</span>
<span class="text-body-2 font-weight-bold text-warning">
{{ formatDate(member.nextDueDate || member.payment_due_date || '') }}
</span>
</div>
<div class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock</v-icon>
Days Until Due
</span>
<span class="text-body-2 font-weight-bold text-warning">
{{ member.daysUntilDue || 0 }} days
</span>
</div>
</div>
</div>
<!-- Contact Info -->
<div class="contact-info mb-3">
<div v-if="member.email" class="d-flex align-center mb-1">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-body-2 text-truncate">{{ member.email }}</span>
</div>
<div v-if="member.phone" class="d-flex align-center">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
<span class="text-body-2">{{ member.FormattedPhone || member.phone }}</span>
</div>
</div>
</v-card-text>
<!-- Payment Date Selection Dialog -->
<v-dialog v-model="showPaymentDateDialog" max-width="400">
<v-card>
<v-card-title class="text-h6 pa-4">
<v-icon left color="success">mdi-calendar-check</v-icon>
Mark Dues as Paid
</v-card-title>
<v-card-text class="pa-4">
<div class="mb-4">
<h4 class="text-subtitle-1 mb-2">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h4>
<p class="text-body-2 text-medium-emphasis">
Select the date when the dues payment was received:
</p>
</div>
<v-text-field
v-model="selectedPaymentDate"
label="Payment Date*"
type="date"
:rules="[
v => !!v || 'Payment date is required',
v => !v || new Date(v).getTime() <= new Date().setHours(23,59,59,999) || 'Payment date cannot be in the future'
]"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:max="new Date().toISOString().split('T')[0]"
hint="Select the date when the payment was received"
persistent-hint
/>
<v-alert
v-if="selectedPaymentDate && isDateInFuture"
type="warning"
variant="tonal"
class="mt-2"
density="compact"
>
<v-icon start>mdi-information</v-icon>
Future dates are not allowed. Please select today or an earlier date.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="cancelPaymentDialog"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="elevated"
:disabled="!selectedPaymentDate || isDateInFuture"
:loading="loading"
@click="confirmMarkAsPaid"
>
<v-icon start>mdi-check-circle</v-icon>
Confirm Payment
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Quick Actions -->
<v-card-actions class="pa-4 pt-0 d-flex justify-space-between">
<div class="d-flex gap-1">
<v-btn
variant="text"
size="small"
@click="$emit('view-member', member)"
>
<v-icon start size="16">mdi-account</v-icon>
View Details
</v-btn>
<v-btn
variant="text"
size="small"
:loading="emailLoading"
:disabled="!member.email"
@click="sendDuesReminder"
v-if="member.email"
>
<v-icon start size="16">mdi-email</v-icon>
Email
</v-btn>
</div>
<v-btn
color="success"
variant="elevated"
size="small"
:loading="loading"
@click="showPaymentDateDialog = true"
>
<v-icon start size="16">mdi-check-circle</v-icon>
Mark as Paid
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
import MultipleCountryFlags from '~/components/MultipleCountryFlags.vue';
// Extended member type for dues management
interface DuesMember {
Id: string;
first_name: string;
last_name: string;
email: string;
phone: string;
nationality?: string;
member_id?: string;
FullName?: string;
FormattedPhone?: string;
overdueDays?: number;
overdueReason?: string;
daysUntilDue?: number;
nextDueDate?: string;
membership_date_paid?: string;
payment_due_date?: string;
current_year_dues_paid?: string;
}
interface Props {
member: DuesMember;
status: 'overdue' | 'upcoming';
loading?: boolean;
}
interface Emits {
(e: 'mark-paid', member: Member): void;
(e: 'view-member', member: DuesMember): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false
});
const emit = defineEmits<Emits>();
// Reactive state for payment date dialog
const showPaymentDateDialog = ref(false);
const selectedPaymentDate = ref('');
const selectedPaymentModel = ref<Date | null>(null);
// Reactive state for email sending
const emailLoading = ref(false);
// Initialize with today's date when dialog opens
watch(showPaymentDateDialog, (isOpen) => {
if (isOpen) {
const today = new Date();
selectedPaymentModel.value = today;
selectedPaymentDate.value = todayDate.value;
}
});
// Date picker handler
const handleDateUpdate = (date: Date | null) => {
if (date) {
selectedPaymentDate.value = date.toISOString().split('T')[0];
}
};
// Computed properties
const memberInitials = computed(() => {
const firstName = props.member.first_name || '';
const lastName = props.member.last_name || '';
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
});
const todayDate = computed(() => {
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
});
const isDateInFuture = computed(() => {
if (!selectedPaymentDate.value) return false;
const selectedDate = new Date(selectedPaymentDate.value);
const today = new Date();
today.setHours(0, 0, 0, 0); // Reset time to start of day
selectedDate.setHours(0, 0, 0, 0); // Reset time to start of day
return selectedDate > today;
});
const avatarColor = computed(() => {
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink'];
const idNumber = parseInt(props.member.Id) || 0;
return colors[idNumber % colors.length];
});
const statusColor = computed(() => {
return props.status === 'overdue' ? 'error' : 'warning';
});
const statusIcon = computed(() => {
return props.status === 'overdue' ? 'mdi-alert-circle' : 'mdi-clock-alert';
});
const statusText = computed(() => {
return props.status === 'overdue' ? 'Overdue' : 'Due Soon';
});
const daysDifference = computed(() => {
if (!props.member.payment_due_date) return null;
const today = new Date();
const dueDate = new Date(props.member.payment_due_date);
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
});
// Methods
const calculateDisplayOverdueDays = (member: DuesMember): number => {
// First try to use the pre-calculated overdue days from the API
if (member.overdueDays !== undefined && member.overdueDays > 0) {
return member.overdueDays;
}
// Fallback calculation if not provided
const today = new Date();
const DAYS_IN_YEAR = 365;
// Check if payment is over 1 year old
if (member.membership_date_paid) {
try {
const lastPaidDate = new Date(member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
if (today > oneYearFromPayment) {
const daysSincePayment = Math.floor((today.getTime() - lastPaidDate.getTime()) / (1000 * 60 * 60 * 24));
return Math.max(0, daysSincePayment - DAYS_IN_YEAR);
}
} catch {
// Fall through to due date check
}
}
// Check if past due date
if (member.payment_due_date) {
try {
const dueDate = new Date(member.payment_due_date);
if (today > dueDate) {
return Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
}
} catch {
// Invalid date
}
}
return 0;
};
const formatDate = (dateString: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateString;
}
};
const cancelPaymentDialog = () => {
showPaymentDateDialog.value = false;
selectedPaymentDate.value = '';
};
const confirmMarkAsPaid = async () => {
if (!selectedPaymentDate.value || isDateInFuture.value) return;
try {
// Call the API with the selected payment date
const response = await $fetch<{
success: boolean;
data: Member;
message?: string;
}>(`/api/members/${props.member.Id}/mark-dues-paid`, {
method: 'post',
body: {
paymentDate: selectedPaymentDate.value
}
});
if (response?.success && response.data) {
// Emit the mark-paid event with the updated member data
emit('mark-paid', response.data);
// Close the dialog and reset
showPaymentDateDialog.value = false;
selectedPaymentDate.value = '';
}
} catch (error: any) {
console.error('Error marking dues as paid:', error);
// You could show an error message here if needed
}
};
const sendDuesReminder = async () => {
if (!props.member.email || emailLoading.value) return;
emailLoading.value = true;
try {
// Determine the reminder type based on the member's status
const reminderType = props.status === 'overdue' ? 'overdue' : 'due-soon';
const response = await $fetch<{
success: boolean;
message: string;
data: any;
}>(`/api/members/${props.member.Id}/send-dues-reminder`, {
method: 'post',
body: {
reminderType
}
});
if (response?.success) {
console.log(`Dues reminder sent successfully to ${props.member.email}`);
// You could show a success toast here if needed
}
} catch (error: any) {
console.error('Error sending dues reminder:', error);
// You could show an error toast here if needed
} finally {
emailLoading.value = false;
}
};
</script>
<style scoped>
.dues-action-card {
border-radius: 12px !important;
transition: all 0.3s ease;
position: relative;
height: 100%;
}
.dues-action-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.dues-action-card--overdue {
border-left: 4px solid rgb(var(--v-theme-error));
}
.dues-action-card--upcoming {
border-left: 4px solid rgb(var(--v-theme-warning));
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
}
.dues-info {
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 12px;
}
.contact-info {
border-radius: 6px;
padding: 8px;
background: rgba(var(--v-theme-surface-variant), 0.05);
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
/* Mobile responsive */
@media (max-width: 600px) {
.dues-action-card {
margin-bottom: 12px;
}
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<v-alert
v-if="overdueCount > 0 && !dismissed"
type="warning"
variant="elevated"
class="dues-overdue-banner mb-6"
prominent
border="start"
>
<template #prepend>
<v-icon size="32">mdi-alert-circle</v-icon>
</template>
<template #title>
<span class="text-h6 font-weight-bold">
Dues Overdue - {{ overdueCount }} Member{{ overdueCount > 1 ? 's' : '' }} Affected
</span>
</template>
<div class="mt-2">
<p class="mb-3">
{{ overdueCount }} member{{ overdueCount > 1 ? 's have' : ' has' }} dues that are more than 1 year overdue.
These accounts have been automatically marked as inactive.
</p>
<!-- Detailed Overdue List -->
<v-expansion-panels
v-if="overdueMembers && overdueMembers.length > 0"
class="mb-4"
variant="accordion"
>
<v-expansion-panel
title="View Overdue Details"
:text="`Click to see all ${overdueCount} overdue members and their specific overdue durations`"
>
<template #text>
<v-list class="pa-0">
<v-list-item
v-for="member in overdueMembers"
:key="member.id"
class="overdue-member-item"
>
<template #prepend>
<ProfileAvatar
:member-id="member.memberId"
:member-name="member.name"
size="small"
class="mr-3"
/>
</template>
<v-list-item-title class="font-weight-medium">
{{ member.name }}
</v-list-item-title>
<v-list-item-subtitle>
{{ member.email }}
</v-list-item-subtitle>
<template #append>
<div class="text-right">
<v-chip
:color="member.isInactive ? 'grey' : 'error'"
size="small"
variant="flat"
class="mb-1"
>
<v-icon start size="12">mdi-clock-alert</v-icon>
{{ member.overdueDuration }}
</v-chip>
<br>
<v-chip
:color="member.isInactive ? 'grey' : 'warning'"
size="x-small"
variant="tonal"
>
{{ member.isInactive ? 'Inactive' : member.status }}
</v-chip>
</div>
</template>
</v-list-item>
</v-list>
</template>
</v-expansion-panel>
</v-expansion-panels>
<div class="d-flex flex-wrap gap-2 align-center">
<v-btn
color="warning"
variant="elevated"
size="small"
@click="$emit('view-overdue')"
>
<v-icon start>mdi-eye</v-icon>
View Overdue Members
</v-btn>
<v-btn
v-if="canUpdateStatuses"
color="primary"
variant="outlined"
size="small"
:loading="updatingStatuses"
@click="updateOverdueStatuses"
>
<v-icon start>mdi-refresh</v-icon>
Update Member Statuses
</v-btn>
<v-btn
v-if="canSendReminders"
color="secondary"
variant="outlined"
size="small"
@click="$emit('send-reminders')"
>
<v-icon start>mdi-email-multiple</v-icon>
Send Reminders
</v-btn>
<v-spacer />
<v-btn
icon
size="small"
variant="text"
@click="dismissed = true"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</div>
</v-alert>
</template>
<script setup lang="ts">
import ProfileAvatar from '~/components/ProfileAvatar.vue';
interface OverdueMember {
id: string;
name: string;
email: string;
status: string;
overdueDuration: string;
totalMonthsOverdue: number;
isInactive: boolean;
}
interface Props {
overdueCount: number;
canUpdateStatuses?: boolean;
canSendReminders?: boolean;
refreshTrigger?: number;
}
interface Emits {
(e: 'view-overdue'): void;
(e: 'send-reminders'): void;
(e: 'statuses-updated', count: number): void;
}
const props = withDefaults(defineProps<Props>(), {
canUpdateStatuses: false,
canSendReminders: false,
refreshTrigger: 0
});
const emit = defineEmits<Emits>();
// State
const dismissed = ref(false);
const updatingStatuses = ref(false);
const overdueMembers = ref<OverdueMember[]>([]);
// Load overdue member details
const loadOverdueDetails = async () => {
try {
const response = await $fetch<{
success: boolean;
data: {
count: number;
overdueMembers: OverdueMember[];
};
}>('/api/members/overdue-count');
if (response.success) {
overdueMembers.value = response.data.overdueMembers || [];
}
} catch (error: any) {
console.error('Error loading overdue details:', error);
overdueMembers.value = [];
}
};
// Update overdue member statuses
const updateOverdueStatuses = async () => {
updatingStatuses.value = true;
try {
const response = await $fetch<{
success: boolean;
data: { updatedCount: number };
message?: string;
}>('/api/members/update-overdue-statuses', {
method: 'POST'
});
if (response.success) {
emit('statuses-updated', response.data.updatedCount);
console.log(`Updated ${response.data.updatedCount} overdue member statuses`);
// Refresh overdue details after update
await loadOverdueDetails();
} else {
throw new Error(response.message || 'Failed to update statuses');
}
} catch (error: any) {
console.error('Error updating overdue statuses:', error);
// Show error notification if needed
} finally {
updatingStatuses.value = false;
}
};
// Reset dismissed state when refresh trigger changes
watch(() => props.refreshTrigger, () => {
dismissed.value = false;
loadOverdueDetails(); // Refresh data
});
// Watch for overdueCount changes and reset dismissed
watch(() => props.overdueCount, (newCount, oldCount) => {
if (newCount > oldCount) {
dismissed.value = false;
loadOverdueDetails(); // Load details when count changes
}
});
// Load details on component mount
onMounted(() => {
if (props.overdueCount > 0) {
loadOverdueDetails();
}
});
</script>
<style scoped>
.dues-overdue-banner {
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.2) !important;
}
.dues-overdue-banner :deep(.v-alert__content) {
width: 100%;
}
.gap-2 {
gap: 8px;
}
/* Mobile responsive */
@media (max-width: 600px) {
.d-flex.flex-wrap {
flex-direction: column;
align-items: stretch !important;
}
.d-flex.flex-wrap .v-btn {
margin-bottom: 8px;
}
.v-spacer {
display: none;
}
}
</style>

View File

@@ -0,0 +1,622 @@
<template>
<v-banner
v-if="showBanner"
:color="isOverdue ? 'error' : 'warning'"
:icon="isOverdue ? 'mdi-alert-octagon' : 'mdi-alert-circle'"
:class="['dues-payment-banner', { 'overdue-banner': isOverdue }]"
>
<template #text>
<div class="banner-content">
<div class="text-h6 font-weight-bold mb-2">
<v-icon left>{{ isOverdue ? 'mdi-alert-octagon' : 'mdi-credit-card-alert' }}</v-icon>
{{ isOverdue ? '🚨 URGENT: Overdue Dues Payment' : 'Membership Dues Payment Required' }}
</div>
<div class="text-body-1 mb-3">
{{ paymentMessage }}
</div>
<v-card
class="payment-details-card pa-3"
color="rgba(255,255,255,0.95)"
variant="outlined"
>
<div class="text-subtitle-1 font-weight-bold mb-2 text-black">
<v-icon left size="small" class="text-black">mdi-bank</v-icon>
Payment Details
</div>
<v-row dense>
<v-col cols="12" sm="4" md="3">
<div class="text-caption font-weight-bold text-black">Amount:</div>
<div class="text-body-2 text-black">{{ config.membershipFee }}/year</div>
</v-col>
<v-col cols="12" sm="8" md="5" v-if="config.iban">
<div class="text-caption font-weight-bold text-black">IBAN:</div>
<div class="text-body-2 font-family-monospace text-black">{{ config.iban }}</div>
</v-col>
<v-col cols="12" sm="12" md="4" v-if="config.accountHolder">
<div class="text-caption font-weight-bold text-black">Account Holder:</div>
<div class="text-body-2 text-black">{{ config.accountHolder }}</div>
</v-col>
</v-row>
<v-divider class="my-2 border-opacity-50" />
<v-row dense>
<v-col cols="12">
<div class="text-caption font-weight-bold text-black">Payment Reference:</div>
<div class="text-body-2 font-family-monospace text-black" style="background-color: rgba(0, 0, 0, 0.1); padding: 8px; border-radius: 4px; border-left: 4px solid #000000;">
{{ memberData?.member_id || 'Member ID pending' }}
</div>
<div class="text-caption text-black mt-1">
<v-icon size="small" class="mr-1 text-black">mdi-information-outline</v-icon>
Please include your member ID in the wire transfer reference for identification
</div>
</v-col>
</v-row>
<v-divider class="my-2 border-opacity-50" />
<div class="text-caption d-flex align-center text-black">
<v-icon size="small" class="mr-1 text-black">mdi-information-outline</v-icon>
{{ daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Payment overdue' }}
before account suspension
</div>
</v-card>
</div>
</template>
<template #actions>
<v-btn
v-if="isAdmin"
color="white"
variant="outlined"
size="small"
@click="markAsPaidDialog = true"
class="mr-2"
>
<v-icon left size="small">mdi-check-circle</v-icon>
Mark as Paid
</v-btn>
<v-btn
color="white"
variant="text"
size="small"
@click="dismissBanner"
>
<v-icon left size="small">mdi-close</v-icon>
Dismiss
</v-btn>
</template>
</v-banner>
<!-- Mark as Paid Dialog -->
<v-dialog v-model="markAsPaidDialog" max-width="400">
<v-card>
<v-card-title class="text-h6 pa-4">
<v-icon left color="success">mdi-calendar-check</v-icon>
Mark Dues as Paid
</v-card-title>
<v-card-text class="pa-4">
<div class="mb-4">
<h4 class="text-subtitle-1 mb-2">
{{ memberData?.FullName || `${memberData?.first_name || ''} ${memberData?.last_name || ''}`.trim() }}
</h4>
<p class="text-body-2 text-medium-emphasis">
Select the date when the dues payment was received:
</p>
</div>
<v-text-field
v-model="selectedPaymentDate"
label="Payment Date*"
type="date"
:rules="[
v => !!v || 'Payment date is required',
v => !v || new Date(v).getTime() <= new Date().setHours(23,59,59,999) || 'Payment date cannot be in the future'
]"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:max="new Date().toISOString().split('T')[0]"
hint="Select the date when the payment was received"
persistent-hint
/>
<v-alert
v-if="selectedPaymentDate && isDateInFuture"
type="warning"
variant="tonal"
class="mt-2"
density="compact"
>
<v-icon start>mdi-information</v-icon>
Future dates are not allowed. Please select today or an earlier date.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="cancelPaymentDialog"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="elevated"
:disabled="!selectedPaymentDate || isDateInFuture"
:loading="updating"
@click="markDuesAsPaid"
>
<v-icon start>mdi-check-circle</v-icon>
Confirm Payment
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="4000"
>
{{ snackbar.message }}
<template #actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</template>
<script setup lang="ts">
import type { RegistrationConfig, Member } from '~/utils/types';
import {
isPaymentOverOneYear as checkPaymentOverOneYear,
isDuesActuallyCurrent as checkDuesActuallyCurrent,
calculateOverdueDays
} from '~/utils/dues-calculations';
// Get auth state
const { user, isAdmin } = useAuth();
// Reactive state
const showBanner = ref(false);
const dismissed = ref(false);
const markAsPaidDialog = ref(false);
const updating = ref(false);
const memberData = ref<Member | null>(null);
const config = ref<RegistrationConfig>({
membershipFee: 50,
iban: '',
accountHolder: ''
});
// Reactive state for payment date dialog
const selectedPaymentDate = ref('');
const selectedPaymentModel = ref<Date | null>(null);
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
/**
* Check if a member is in their grace period
* Uses the same logic as dues-status API
*/
const isInGracePeriod = computed(() => {
if (!memberData.value?.payment_due_date) return false;
try {
const dueDate = new Date(memberData.value.payment_due_date);
const today = new Date();
return dueDate > today;
} catch {
return false;
}
});
/**
* Check if a member's last payment is over 1 year old
* Uses standardized dues calculation function
*/
const isPaymentOverOneYear = computed(() => {
if (!memberData.value) return false;
return checkPaymentOverOneYear(memberData.value);
});
/**
* Calculate next dues date (1 year from when they last paid or joined)
*/
const nextDuesDate = computed(() => {
if (!memberData.value) return null;
// If dues are paid, calculate 1 year from payment date
if (memberData.value.current_year_dues_paid === 'true' && memberData.value.membership_date_paid) {
const lastPaidDate = new Date(memberData.value.membership_date_paid);
const nextDue = new Date(lastPaidDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue;
}
// If not paid but has a due date, use that
if (memberData.value.payment_due_date) {
return new Date(memberData.value.payment_due_date);
}
// Fallback: 1 year from member since date
if (memberData.value.member_since) {
const memberSince = new Date(memberData.value.member_since);
const nextDue = new Date(memberSince);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue;
}
return null;
});
/**
* Check if dues are coming due within 30 days (for paid members)
*/
const isDueSoon = computed(() => {
if (!memberData.value || !nextDuesDate.value) return false;
// Only show warning if dues are currently paid
if (memberData.value.current_year_dues_paid !== 'true') return false;
const today = new Date();
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
// Show banner if due date is within the next 30 days
return nextDuesDate.value <= thirtyDaysFromNow && nextDuesDate.value > today;
});
/**
* Check if dues are overdue
* Uses standardized dues calculation function
*/
const isDuesOverdue = computed(() => {
if (!memberData.value) return false;
// Use the standardized function - if not current, then overdue
return !checkDuesActuallyCurrent(memberData.value);
});
/**
* Check if dues need to be paid (either coming due soon or overdue)
*/
const needsPayment = computed(() => {
if (!memberData.value) return false;
// Show banner if dues are coming due soon OR overdue
return isDueSoon.value || isDuesOverdue.value;
});
// Computed properties
const shouldShowBanner = computed(() => {
if (!user.value || !memberData.value) return false;
if (dismissed.value) return false;
// Show banner when payment is needed
return needsPayment.value;
});
const daysRemaining = computed(() => {
if (!nextDuesDate.value) return 0;
const dueDate = nextDuesDate.value;
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays; // Allow negative values for overdue
});
const isOverdue = computed(() => {
return isDuesOverdue.value;
});
const paymentMessage = computed(() => {
if (isDuesOverdue.value) {
const overdueDays = Math.abs(daysRemaining.value);
return `Your annual membership dues of €${config.value.membershipFee} are ${overdueDays > 0 ? overdueDays + ' day' + (overdueDays !== 1 ? 's' : '') + ' ' : ''}overdue. Immediate payment is required to avoid account suspension.`;
} else if (isDueSoon.value) {
const dueDays = daysRemaining.value;
if (dueDays <= 7) {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${dueDays} day${dueDays !== 1 ? 's' : ''}. Please pay immediately to avoid late fees.`;
} else {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${dueDays} day${dueDays !== 1 ? 's' : ''}. Please pay soon to avoid account suspension.`;
}
} else {
return `Your annual membership dues of €${config.value.membershipFee} require attention.`;
}
});
const todayDate = computed(() => {
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
});
const isDateInFuture = computed(() => {
if (!selectedPaymentDate.value) return false;
const selectedDate = new Date(selectedPaymentDate.value);
const today = new Date();
today.setHours(0, 0, 0, 0); // Reset time to start of day
selectedDate.setHours(0, 0, 0, 0); // Reset time to start of day
return selectedDate > today;
});
// Methods
function dismissBanner() {
dismissed.value = true;
showBanner.value = false;
// Store dismissal in localStorage (expires after 24 hours)
const dismissalData = {
timestamp: Date.now(),
userId: user.value?.id
};
localStorage.setItem('dues-banner-dismissed', JSON.stringify(dismissalData));
}
async function markDuesAsPaid() {
if (!memberData.value?.Id || !selectedPaymentDate.value || isDateInFuture.value) return;
updating.value = true;
try {
// Call the API with the selected payment date using the correct endpoint
const response = await $fetch<{
success: boolean;
data: any;
message?: string;
}>(`/api/members/${memberData.value.Id}/mark-dues-paid`, {
method: 'post',
body: {
paymentDate: selectedPaymentDate.value
}
});
if (response?.success && response.data) {
// Update local member state
if (memberData.value) {
memberData.value.current_year_dues_paid = 'true';
memberData.value.membership_date_paid = selectedPaymentDate.value;
}
// Hide banner and reset
showBanner.value = false;
markAsPaidDialog.value = false;
selectedPaymentDate.value = '';
selectedPaymentModel.value = null;
// Show success message
snackbar.value = {
show: true,
message: 'Dues marked as paid successfully!',
color: 'success'
};
}
} catch (error: any) {
console.error('Failed to mark dues as paid:', error);
snackbar.value = {
show: true,
message: 'Failed to update payment status. Please try again.',
color: 'error'
};
} finally {
updating.value = false;
}
}
// Initialize with today's date when dialog opens
watch(markAsPaidDialog, (isOpen) => {
if (isOpen) {
const today = new Date();
selectedPaymentModel.value = today;
selectedPaymentDate.value = todayDate.value;
}
});
// Date picker handler
const handleDateUpdate = (date: Date | null) => {
if (date) {
selectedPaymentDate.value = date.toISOString().split('T')[0];
}
};
const cancelPaymentDialog = () => {
markAsPaidDialog.value = false;
selectedPaymentDate.value = '';
selectedPaymentModel.value = null;
};
// Load member data for the current user from session
async function loadMemberData() {
if (!user.value) return;
try {
const response = await $fetch('/api/auth/session') as any;
if (response?.success && response?.member) {
memberData.value = response.member;
}
} catch (error) {
console.warn('Failed to load member data:', error);
}
}
// Load configuration and check banner visibility
async function loadConfig() {
try {
const response = await $fetch('/api/registration-config') as any;
if (response?.success) {
config.value = response.data;
}
} catch (error) {
console.warn('Failed to load registration config:', error);
}
}
// Check if banner was recently dismissed
function checkDismissalStatus() {
try {
const stored = localStorage.getItem('dues-banner-dismissed');
if (stored) {
const dismissalData = JSON.parse(stored);
const hoursSinceDismissal = (Date.now() - dismissalData.timestamp) / (1000 * 60 * 60);
// Reset dismissal after 24 hours or if different user
if (hoursSinceDismissal > 24 || dismissalData.userId !== user.value?.id) {
localStorage.removeItem('dues-banner-dismissed');
dismissed.value = false;
} else {
dismissed.value = true;
}
}
} catch (error) {
console.warn('Failed to check dismissal status:', error);
dismissed.value = false;
}
}
// Watchers
watch(shouldShowBanner, (newVal) => {
showBanner.value = newVal;
}, { immediate: true });
watch(user, () => {
checkDismissalStatus();
loadMemberData();
}, { immediate: true });
// Initialize
onMounted(() => {
loadConfig();
checkDismissalStatus();
loadMemberData();
});
</script>
<style scoped>
.dues-payment-banner {
border-left: 4px solid #ff9800;
}
.dues-payment-banner.overdue-banner {
border-left: 4px solid #f44336;
animation: pulse-border 2s infinite;
}
@keyframes pulse-border {
0% { border-left-color: #f44336; }
50% { border-left-color: #ff5252; }
100% { border-left-color: #f44336; }
}
.banner-content {
width: 100%;
}
.payment-details-card {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
/* Mobile responsiveness */
@media (max-width: 600px) {
.banner-content .text-h6 {
font-size: 1.1rem !important;
}
.payment-details-card {
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,732 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<ProfileAvatar
v-if="member"
:member-id="member.member_id"
:member-name="member.FullName || `${member.first_name} ${member.last_name}`"
:first-name="member.first_name"
:last-name="member.last_name"
size="large"
class="mr-4"
clickable
show-border
@click="openImageLightbox"
/>
<div class="flex-grow-1">
<h2 class="text-h5 text-white font-weight-bold">
Edit Member: {{ member?.FullName || `${member?.first_name} ${member?.last_name}` }}
</h2>
</div>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-form ref="formRef" v-model="formValid" @submit.prevent="handleSubmit">
<v-row>
<!-- Personal Information Section -->
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Personal Information</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.first_name"
label="First Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('first_name')"
:error-messages="getFieldError('first_name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.last_name"
label="Last Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('last_name')"
:error-messages="getFieldError('last_name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.email"
label="Email Address"
type="email"
variant="outlined"
:rules="[rules.required, rules.email]"
required
:error="hasFieldError('email')"
:error-messages="getFieldError('email')"
/>
</v-col>
<v-col cols="12" md="6">
<PhoneInputWrapper
v-model="form.phone"
label="Phone Number"
placeholder="Enter phone number"
:error="hasFieldError('phone')"
:error-message="getFieldError('phone')"
@phone-data="handlePhoneData"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.date_of_birth"
label="Date of Birth"
type="date"
variant="outlined"
:error="hasFieldError('date_of_birth')"
:error-messages="getFieldError('date_of_birth')"
/>
</v-col>
<v-col cols="12" md="6">
<MultipleNationalityInput
v-model="form.nationality"
label="Nationality"
:error="hasFieldError('nationality')"
:error-message="getFieldError('nationality')"
:max-nationalities="3"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.address"
label="Address"
variant="outlined"
rows="2"
:error="hasFieldError('address')"
:error-messages="getFieldError('address')"
/>
</v-col>
<!-- Membership Information Section -->
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Membership Information</h3>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="form.membership_status"
:items="membershipStatusOptions"
label="Membership Status"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('membership_status')"
:error-messages="getFieldError('membership_status')"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="form.member_since"
label="Member Since"
type="date"
variant="outlined"
:error="hasFieldError('member_since')"
:error-messages="getFieldError('member_since')"
/>
</v-col>
<v-col cols="12" md="4">
<v-switch
v-model="duesPaid"
label="Current Year Dues Paid"
color="success"
inset
:error="hasFieldError('current_year_dues_paid')"
:error-messages="getFieldError('current_year_dues_paid')"
/>
</v-col>
<v-col cols="12" md="6" v-if="duesPaid">
<v-text-field
v-model="form.membership_date_paid"
label="Payment Date"
type="date"
variant="outlined"
:error="hasFieldError('membership_date_paid')"
:error-messages="getFieldError('membership_date_paid')"
hint="Enter the actual date when dues were paid (can be historical)"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6" v-if="!duesPaid">
<v-text-field
v-model="form.payment_due_date"
label="Payment Due Date"
type="date"
variant="outlined"
:error="hasFieldError('payment_due_date')"
:error-messages="getFieldError('payment_due_date')"
hint="Enter when payment is due (for members in grace period)"
persistent-hint
/>
</v-col>
<!-- Dues Status Preview -->
<v-col cols="12" v-if="duesPaid && form.membership_date_paid">
<v-card variant="tonal" :color="calculatedDuesStatus.color" class="pa-3">
<div class="d-flex align-center">
<v-icon :color="calculatedDuesStatus.color" class="mr-2">
{{ calculatedDuesStatus.icon }}
</v-icon>
<div>
<div class="text-subtitle-2 font-weight-bold">
Calculated Dues Status: {{ calculatedDuesStatus.text }}
</div>
<div class="text-caption">
{{ calculatedDuesStatus.message }}
</div>
</div>
</div>
</v-card>
</v-col>
<!-- Portal Access Control Section (Admin Only) -->
<template v-if="isAdmin && member?.keycloak_id">
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Portal Access Control</h3>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="form.portal_group"
:items="portalGroupOptions"
label="Portal Access Level"
variant="outlined"
hint="Controls user's access level in the portal"
persistent-hint
:loading="groupLoading"
:disabled="groupLoading"
:error="hasFieldError('portal_group')"
:error-messages="getFieldError('portal_group')"
>
<template #prepend-inner>
<v-icon color="primary">mdi-shield-account</v-icon>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-alert
v-if="groupSyncStatus"
:type="groupSyncStatus.type"
:text="groupSyncStatus.message"
density="compact"
class="mb-0"
/>
<v-chip
v-else-if="member.keycloak_id"
color="success"
size="small"
class="mt-2"
>
<v-icon start size="small">mdi-check-circle</v-icon>
Portal Account Active
</v-chip>
</v-col>
</template>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-content-save</v-icon>
Save Changes
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Image Lightbox -->
<v-dialog
v-model="showImageLightbox"
max-width="800"
@click:outside="showImageLightbox = false"
>
<v-card class="pa-0" v-if="member && lightboxImageUrl">
<v-card-title class="d-flex align-center pa-4">
<span class="text-h6">{{ member.FullName || `${member.first_name} ${member.last_name}` }} - Profile Photo</span>
<v-spacer />
<v-btn
icon
variant="text"
@click="showImageLightbox = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<div class="text-center">
<v-img
:src="lightboxImageUrl"
:alt="`${member.FullName || `${member.first_name} ${member.last_name}`} profile photo`"
max-height="500"
contain
class="mx-auto"
/>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { isPaymentOverOneYear, isDuesActuallyCurrent, calculateOverdueDays } from '~/utils/dues-calculations';
interface Props {
modelValue: boolean;
member: Member | null;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'member-updated', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
// Lightbox state
const showImageLightbox = ref(false);
const lightboxImageUrl = ref<string | null>(null);
// Form data - using snake_case field names
const form = ref({
first_name: '',
last_name: '',
email: '',
phone: '',
date_of_birth: '',
nationality: '',
address: '',
membership_status: 'Active',
member_since: '',
current_year_dues_paid: 'false',
membership_date_paid: '',
payment_due_date: '',
portal_group: 'user'
});
// Additional form state
const duesPaid = ref(false);
const phoneData = ref(null);
// Error handling
const fieldErrors = ref<Record<string, string>>({});
// Computed dues status calculation
const calculatedDuesStatus = computed(() => {
if (!duesPaid.value || !form.value.membership_date_paid) {
return {
color: 'grey',
icon: 'mdi-help',
text: 'Unknown',
message: 'Please enter payment date to calculate status'
};
}
// Create a mock member object with form data to use calculation functions
const mockMember = {
current_year_dues_paid: 'true',
membership_date_paid: form.value.membership_date_paid,
payment_due_date: form.value.payment_due_date,
member_since: form.value.member_since
} as Member;
const isOverdue = !isDuesActuallyCurrent(mockMember);
const paymentTooOld = isPaymentOverOneYear(mockMember);
if (isOverdue && paymentTooOld) {
const overdueDays = calculateOverdueDays(mockMember);
return {
color: 'error',
icon: 'mdi-alert-circle',
text: 'Overdue',
message: `Payment is ${overdueDays} days overdue (more than 1 year since payment)`
};
} else if (isOverdue) {
return {
color: 'warning',
icon: 'mdi-clock-alert',
text: 'Due Soon',
message: 'Dues will be due soon based on payment date'
};
} else {
const paymentDate = new Date(form.value.membership_date_paid);
const nextDue = new Date(paymentDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
const nextDueFormatted = nextDue.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {
color: 'success',
icon: 'mdi-check-circle',
text: 'Current',
message: `Dues are current. Next payment due: ${nextDueFormatted}`
};
}
});
// Auth state
const { user, isAdmin } = useAuth();
// Portal group management
const groupLoading = ref(false);
const groupSyncStatus = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
const originalPortalGroup = ref<string>('user');
const portalGroupOptions = [
{ title: 'User - Basic Access', value: 'user' },
{ title: 'Board Member - Extended Access', value: 'board' },
{ title: 'Administrator - Full Access', value: 'admin' }
];
// Watch for portal group changes and sync with Keycloak
watch(() => form.value.portal_group, async (newGroup, oldGroup) => {
if (!props.member?.keycloak_id || !isAdmin || newGroup === oldGroup || newGroup === originalPortalGroup.value) {
return;
}
console.log('[EditMemberDialog] Portal group changed:', oldGroup, '->', newGroup);
groupLoading.value = true;
groupSyncStatus.value = null;
try {
console.log('[EditMemberDialog] Updating Keycloak groups for member:', props.member.Id);
const response = await $fetch(`/api/members/${props.member.Id}/keycloak-groups`, {
method: 'PUT',
body: { newGroup }
});
if (response.success) {
groupSyncStatus.value = {
type: 'success',
message: `Successfully changed access level to ${newGroup}`
};
originalPortalGroup.value = newGroup; // Update original to prevent re-trigger
console.log('[EditMemberDialog] Group change successful:', response.data);
} else {
throw new Error(response.message || 'Failed to update access level');
}
} catch (error: any) {
console.error('[EditMemberDialog] Failed to update Keycloak groups:', error);
groupSyncStatus.value = {
type: 'error',
message: error.data?.message || error.message || 'Failed to update access level'
};
// Revert the form value on error
form.value.portal_group = oldGroup || 'user';
} finally {
groupLoading.value = false;
// Clear status after 5 seconds
setTimeout(() => {
groupSyncStatus.value = null;
}, 5000);
}
});
// Watch dues paid switch
watch(duesPaid, (newValue) => {
form.value.current_year_dues_paid = newValue ? 'true' : 'false';
if (newValue) {
form.value.payment_due_date = '';
} else {
form.value.membership_date_paid = '';
}
});
// Membership status options
const membershipStatusOptions = [
{ title: 'Active', value: 'Active' },
{ title: 'Inactive', value: 'Inactive' },
{ title: 'Pending', value: 'Pending' },
{ title: 'Expired', value: 'Expired' }
];
// Validation rules
const rules = {
required: (value: any) => {
if (typeof value === 'string') {
return !!value?.trim() || 'This field is required';
}
return !!value || 'This field is required';
},
email: (value: string) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !value || pattern.test(value) || 'Please enter a valid email address';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const clearFieldErrors = () => {
fieldErrors.value = {};
};
// Phone data handler
const handlePhoneData = (data: any) => {
phoneData.value = data;
};
// Form pre-population - Updated to use snake_case field names
const populateForm = () => {
if (!props.member) return;
console.log('[EditMemberDialog] Populating form with member data:', props.member);
const member = props.member;
// Convert date fields to proper format for input[type="date"]
const formatDateForInput = (dateString: string) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toISOString().split('T')[0];
} catch {
return dateString;
}
};
form.value = {
first_name: member.first_name || '',
last_name: member.last_name || '',
email: member.email || '',
phone: member.phone || '',
date_of_birth: formatDateForInput(member.date_of_birth || ''),
nationality: member.nationality || '',
address: member.address || '',
membership_status: member.membership_status || 'Active',
member_since: formatDateForInput(member.member_since || ''),
current_year_dues_paid: member.current_year_dues_paid || 'false',
membership_date_paid: formatDateForInput(member.membership_date_paid || ''),
payment_due_date: formatDateForInput(member.payment_due_date || ''),
portal_group: member.portal_group || 'user'
};
// Set dues paid switch based on the string value
duesPaid.value = member.current_year_dues_paid === 'true';
console.log('[EditMemberDialog] Form populated:', form.value);
};
// Form submission
const handleSubmit = async () => {
if (!formRef.value || !props.member) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
// Prepare the data for submission
const memberData = { ...form.value };
// Ensure required fields are not empty
if (!memberData.first_name?.trim()) {
throw new Error('First Name is required');
}
if (!memberData.last_name?.trim()) {
throw new Error('Last Name is required');
}
if (!memberData.email?.trim()) {
throw new Error('Email is required');
}
console.log('[EditMemberDialog] Updating member data:', memberData);
const response = await $fetch<{ success: boolean; data: Member; message?: string }>(`/api/members/${props.member.Id}`, {
method: 'PUT',
body: memberData
});
if (response.success && response.data) {
console.log('[EditMemberDialog] Member updated successfully:', response.data);
emit('member-updated', response.data);
closeDialog();
} else {
throw new Error(response.message || 'Failed to update member');
}
} catch (error: any) {
console.error('[EditMemberDialog] Error updating member:', error);
// Handle validation errors
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
// Show general error
fieldErrors.value = {
general: error.message || 'Failed to update member. Please try again.'
};
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
// Watch for dialog open/close and member changes
watch(() => props.modelValue, (newValue) => {
if (newValue && props.member) {
// Dialog opened - populate form with member data
populateForm();
clearFieldErrors();
// Reset form validation
nextTick(() => {
formRef.value?.resetValidation();
});
}
});
watch(() => props.member, (newMember) => {
if (newMember && props.modelValue) {
// Member changed while dialog is open
populateForm();
}
});
// Lightbox functionality
const openImageLightbox = async () => {
if (!props.member?.member_id) return;
try {
// Fetch the original sized image for the lightbox
const response = await $fetch(`/api/profile/image/${props.member.member_id}/medium`) as any;
if (response?.success && response?.imageUrl) {
lightboxImageUrl.value = response.imageUrl;
showImageLightbox.value = true;
}
} catch (error) {
console.warn('Could not load image for lightbox:', error);
// Could show a snackbar here if needed
}
};
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section spacing */
.v-card-text .v-row .v-col:first-child h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Error message styling */
.field-error {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Switch styling */
.v-switch {
flex: none;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.v-dialog {
margin: 16px;
}
}
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,507 @@
<template>
<v-card elevation="2" class="event-calendar">
<v-card-title v-if="!compact" class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2">mdi-calendar</v-icon>
<span>Events Calendar</span>
</div>
<div v-if="showCreateButton && (isBoard || isAdmin)" class="d-flex gap-2">
<v-btn
@click="$emit('create-event')"
color="primary"
size="small"
prepend-icon="mdi-plus"
>
Create Event
</v-btn>
</div>
</v-card-title>
<v-card-text>
<!-- Mobile view selector -->
<v-row v-if="$vuetify.display.mobile && !compact" class="mb-4">
<v-col cols="12">
<v-btn-toggle
v-model="mobileView"
color="primary"
variant="outlined"
density="comfortable"
mandatory
class="w-100"
>
<v-btn value="week" class="flex-grow-1">
<v-icon start>mdi-calendar-week</v-icon>
Week
</v-btn>
<v-btn value="month" class="flex-grow-1">
<v-icon start>mdi-calendar-month</v-icon>
Month
</v-btn>
<v-btn value="list" class="flex-grow-1">
<v-icon start>mdi-format-list-bulleted</v-icon>
Agenda
</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<!-- Loading state -->
<v-skeleton-loader
v-if="loading"
type="image"
:height="calendarHeight"
class="rounded"
/>
<!-- FullCalendar component -->
<FullCalendar
v-else
ref="fullCalendar"
:options="calendarOptions"
class="fc-theme-monacousa"
/>
<!-- No events message -->
<v-alert
v-if="!loading && (!events || events.length === 0)"
type="info"
variant="tonal"
class="mt-4"
>
<v-alert-title>No Events</v-alert-title>
No events found for the current time period.
</v-alert>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import listPlugin from '@fullcalendar/list';
import type { Event, FullCalendarEvent } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
interface Props {
events?: Event[];
loading?: boolean;
compact?: boolean;
height?: number | string;
showCreateButton?: boolean;
initialView?: string;
}
const props = withDefaults(defineProps<Props>(), {
events: () => [],
loading: false,
compact: false,
height: 600,
showCreateButton: true,
initialView: 'dayGridMonth'
});
const emit = defineEmits<{
'event-click': [event: any];
'date-click': [date: any];
'view-change': [view: any];
'date-range-change': [start: string, end: string];
'create-event': [];
}>();
const { isBoard, isAdmin } = useAuth();
// Reactive state
const fullCalendar = ref<InstanceType<typeof FullCalendar>>();
const mobileView = ref('week'); // Default to week view on mobile
// Computed properties
const calendarHeight = computed(() => {
if (props.compact) return props.height || 300;
if (typeof props.height === 'number') return props.height;
return props.height || 600;
});
const currentView = computed(() => {
if (props.compact) return 'dayGridMonth';
// Mobile responsive view switching
if (process.client && window.innerWidth < 960) {
switch (mobileView.value) {
case 'week': return 'dayGridWeek';
case 'list': return 'listWeek';
case 'month':
default: return 'dayGridMonth';
}
}
return props.initialView;
});
const transformedEvents = computed((): FullCalendarEvent[] => {
console.log('[EventCalendar] Raw events received:', props.events.length);
console.log('[EventCalendar] Raw events array:', props.events);
props.events.forEach((event, index) => {
console.log(`[EventCalendar] Event ${index + 1}:`, {
id: event.id,
title: event.title,
start_datetime: event.start_datetime,
end_datetime: event.end_datetime,
event_type: event.event_type
});
});
const transformed = props.events.map((event: Event) => transformEventForCalendar(event));
console.log('[EventCalendar] Transformed events for FullCalendar:', transformed.length);
console.log('[EventCalendar] Transformed events array:', transformed);
transformed.forEach((event, index) => {
console.log(`[EventCalendar] Transformed Event ${index + 1}:`, {
id: event.id,
title: event.title,
start: event.start,
end: event.end,
backgroundColor: event.backgroundColor
});
});
return transformed;
});
// FullCalendar options
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, interactionPlugin, listPlugin],
initialView: currentView.value,
height: calendarHeight.value,
headerToolbar: props.compact ? false : {
left: 'prev,next today',
center: 'title',
right: process.client && window.innerWidth < 960 ?
'dayGridMonth,listWeek' :
'dayGridMonth,dayGridWeek,listWeek'
} as any,
events: transformedEvents.value,
eventClick: handleEventClick,
dateClick: handleDateClick,
datesSet: handleDatesSet,
eventDidMount: handleEventMount,
dayMaxEvents: props.compact ? 2 : 5,
eventDisplay: 'block',
displayEventTime: true,
eventTimeFormat: {
hour: '2-digit' as const,
minute: '2-digit' as const,
hour12: false
},
locale: 'en',
firstDay: 1, // Monday
weekends: true,
navLinks: true,
selectable: isBoard.value || isAdmin.value,
selectMirror: true,
select: handleDateSelect,
// Mobile optimizations
aspectRatio: process.client && window.innerWidth < 960 ? 1.0 : 1.35,
// Responsive behavior
windowResizeDelay: 100
}));
// Event handlers
function handleEventClick(clickInfo: any) {
emit('event-click', {
event: clickInfo.event,
eventData: clickInfo.event.extendedProps
});
}
function handleDateClick(dateInfo: any) {
if (isBoard.value || isAdmin.value) {
emit('date-click', {
date: dateInfo.dateStr,
allDay: dateInfo.allDay
});
}
}
function handleDateSelect(selectInfo: any) {
if (isBoard.value || isAdmin.value) {
emit('date-click', {
date: selectInfo.startStr,
endDate: selectInfo.endStr,
allDay: selectInfo.allDay
});
}
}
function handleDatesSet(dateInfo: any) {
emit('view-change', {
view: dateInfo.view.type,
start: dateInfo.start,
end: dateInfo.end
});
emit('date-range-change',
dateInfo.start.toISOString(),
dateInfo.end.toISOString()
);
}
function handleEventMount(mountInfo: any) {
// Add custom styling or tooltips
const event = mountInfo.event;
const el = mountInfo.el;
// Add tooltip with event details
el.setAttribute('title', `${event.title}\n${event.extendedProps.location || 'No location'}`);
// Add custom classes based on event properties
if (event.extendedProps.is_paid) {
el.classList.add('fc-paid-event');
}
if (event.extendedProps.user_rsvp?.rsvp_status === 'confirmed') {
el.classList.add('fc-user-rsvp');
}
}
// Transform event data for FullCalendar
function transformEventForCalendar(event: Event): FullCalendarEvent {
console.log('[EventCalendar] Transforming event:', {
id: event.id,
event_id: event.event_id,
title: event.title,
start_datetime: event.start_datetime,
end_datetime: event.end_datetime,
event_type: event.event_type
});
const eventTypeColors = {
'meeting': { bg: '#2196f3', border: '#1976d2' },
'social': { bg: '#4caf50', border: '#388e3c' },
'fundraiser': { bg: '#ff9800', border: '#f57c00' },
'workshop': { bg: '#9c27b0', border: '#7b1fa2' },
'board-only': { bg: '#a31515', border: '#8b1212' }
};
const colors = eventTypeColors[event.event_type] ||
{ bg: '#757575', border: '#424242' };
// Use event_id as the primary identifier for FullCalendar uniqueness
const calendarId = event.event_id || event.id || `temp_${(event as any).Id}_${Date.now()}`;
console.log('[EventCalendar] Using calendar ID:', calendarId, 'from event_id:', event.event_id, 'fallback id:', event.id);
// Ensure dates are properly formatted for FullCalendar
let startDate: string | Date;
let endDate: string | Date;
try {
// Convert to Date objects first to validate, then use ISO strings
const startDateObj = new Date(event.start_datetime);
const endDateObj = new Date(event.end_datetime);
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
console.error('[EventCalendar] Invalid date values for event:', calendarId, {
start: event.start_datetime,
end: event.end_datetime
});
// Use fallback dates
startDate = new Date().toISOString();
endDate = new Date(Date.now() + 3600000).toISOString(); // +1 hour
} else {
startDate = startDateObj.toISOString();
endDate = endDateObj.toISOString();
}
} catch (error) {
console.error('[EventCalendar] Date parsing error for event:', calendarId, error);
// Use fallback dates
startDate = new Date().toISOString();
endDate = new Date(Date.now() + 3600000).toISOString(); // +1 hour
}
const transformedEvent = {
id: calendarId, // ✅ Use event_id instead of event.id
title: event.title,
start: startDate,
end: endDate,
backgroundColor: colors.bg,
borderColor: colors.border,
textColor: '#ffffff',
extendedProps: {
originalEvent: event, // Store original event for debugging
description: event.description,
location: event.location,
event_type: event.event_type,
is_paid: event.is_paid === 'true',
cost_members: event.cost_members,
cost_non_members: event.cost_non_members,
max_attendees: event.max_attendees ? parseInt(event.max_attendees) : undefined,
current_attendees: typeof event.current_attendees === 'string' ? parseInt(event.current_attendees) : (event.current_attendees || 0),
user_rsvp: event.user_rsvp,
visibility: event.visibility,
creator: event.creator,
event_id: event.event_id, // Store for reference
database_id: event.id || (event as any).Id
}
};
console.log('[EventCalendar] Transformed event result:', {
id: transformedEvent.id,
title: transformedEvent.title,
start: transformedEvent.start,
end: transformedEvent.end,
backgroundColor: transformedEvent.backgroundColor
});
return transformedEvent;
}
// Public methods
function getCalendarApi() {
return fullCalendar.value?.getApi();
}
function refetchEvents() {
const api = getCalendarApi();
if (api) {
api.refetchEvents();
}
}
function changeView(viewType: string) {
const api = getCalendarApi();
if (api) {
api.changeView(viewType);
}
}
function gotoDate(date: string | Date) {
const api = getCalendarApi();
if (api) {
api.gotoDate(date);
}
}
// Watch for mobile view changes
watch(mobileView, (newView) => {
let viewType;
switch (newView) {
case 'week': viewType = 'dayGridWeek'; break;
case 'list': viewType = 'listWeek'; break;
case 'month':
default: viewType = 'dayGridMonth'; break;
}
changeView(viewType);
});
// Expose methods to parent components
defineExpose({
getCalendarApi,
refetchEvents,
changeView,
gotoDate
});
</script>
<style scoped>
.event-calendar :deep(.fc) {
font-family: 'Roboto', sans-serif;
}
.event-calendar :deep(.fc-theme-standard .fc-scrollgrid) {
border-color: rgba(0, 0, 0, 0.12);
}
.event-calendar :deep(.fc-theme-standard td),
.event-calendar :deep(.fc-theme-standard th) {
border-color: rgba(0, 0, 0, 0.12);
}
.event-calendar :deep(.fc-button-primary) {
background-color: #a31515;
border-color: #a31515;
font-weight: 500;
text-transform: none;
}
.event-calendar :deep(.fc-button-primary:hover) {
background-color: #8b1212;
border-color: #8b1212;
}
.event-calendar :deep(.fc-button-primary:disabled) {
background-color: rgba(163, 21, 21, 0.5);
border-color: rgba(163, 21, 21, 0.5);
}
.event-calendar :deep(.fc-today-button) {
font-weight: 500;
text-transform: none;
}
.event-calendar :deep(.fc-toolbar-title) {
font-size: 1.25rem;
font-weight: 600;
color: #a31515;
}
.event-calendar :deep(.fc-day-today) {
background-color: rgba(163, 21, 21, 0.05) !important;
}
.event-calendar :deep(.fc-event) {
border-radius: 4px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
}
.event-calendar :deep(.fc-event:hover) {
opacity: 0.85;
}
.event-calendar :deep(.fc-paid-event) {
border-left: 4px solid #ff9800 !important;
}
.event-calendar :deep(.fc-user-rsvp) {
box-shadow: 0 0 0 2px #4caf50;
}
.event-calendar :deep(.fc-list-event-title) {
font-weight: 500;
}
.event-calendar :deep(.fc-list-event-time) {
font-weight: 600;
color: #a31515;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.event-calendar :deep(.fc-toolbar) {
flex-direction: column;
gap: 8px;
}
.event-calendar :deep(.fc-toolbar-chunk) {
display: flex;
justify-content: center;
}
.event-calendar :deep(.fc-button-group) {
display: flex;
}
.event-calendar :deep(.fc-button) {
padding: 4px 8px;
font-size: 0.75rem;
}
.event-calendar :deep(.fc-toolbar-title) {
font-size: 1.1rem;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,809 @@
<template>
<v-dialog v-model="show" max-width="600" persistent>
<v-card v-if="event">
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2" :color="eventTypeColor">{{ eventTypeIcon }}</v-icon>
<span>{{ event?.title || 'Event Details' }}</span>
</div>
<v-btn
@click="close"
icon
variant="text"
size="small"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<!-- Event Type Badge -->
<v-chip
:color="eventTypeColor"
size="small"
variant="tonal"
class="mb-4"
>
<v-icon start>{{ eventTypeIcon }}</v-icon>
{{ eventTypeLabel }}
</v-chip>
<!-- Event Details -->
<v-row class="mb-4">
<!-- Date & Time -->
<v-col cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-calendar-clock</v-icon>
<div>
<div class="font-weight-medium">{{ formatEventDate }}</div>
<div class="text-body-2 text-medium-emphasis">{{ formatEventTime }}</div>
</div>
</div>
</v-col>
<!-- Location -->
<v-col v-if="event.location" cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-map-marker</v-icon>
<span>{{ event.location }}</span>
</div>
</v-col>
<!-- Description -->
<v-col v-if="event.description" cols="12">
<div class="d-flex align-start mb-2">
<v-icon class="me-2 mt-1">mdi-text</v-icon>
<div>
<div class="font-weight-medium mb-1">Description</div>
<!-- Display HTML content safely -->
<div
class="text-body-2 rich-text-content"
v-html="event.description"
/>
</div>
</div>
</v-col>
<!-- Capacity -->
<v-col v-if="event.max_attendees" cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-account-group</v-icon>
<div>
<span class="font-weight-medium">Capacity:</span>
<span class="ms-2">
{{ event.current_attendees || 0 }} / {{ event.max_attendees }}
</span>
<v-progress-linear
:model-value="capacityPercentage"
:color="capacityColor"
height="4"
class="mt-1"
rounded
/>
</div>
</div>
</v-col>
</v-row>
<!-- Payment Information -->
<v-alert
v-if="event.is_paid === 'true'"
type="info"
variant="tonal"
class="mb-4"
>
<v-alert-title>
<v-icon start>mdi-currency-eur</v-icon>
Payment Required
</v-alert-title>
<div class="mt-2">
<div v-if="memberPrice && nonMemberPrice">
<strong>Members:</strong> {{ memberPrice }}<br>
<strong>Non-Members:</strong> {{ nonMemberPrice }}
</div>
<div v-else-if="memberPrice">
<strong>Cost:</strong> {{ memberPrice }}
</div>
<div v-if="event.member_pricing_enabled === 'false'" class="text-caption mt-1">
<v-icon size="small">mdi-information</v-icon>
Member pricing is not available for this event
</div>
</div>
</v-alert>
<!-- RSVP Status -->
<v-card
v-if="hasRSVP"
variant="outlined"
class="mb-4"
:color="rsvpStatusColor"
>
<v-card-text class="py-3">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon :color="rsvpStatusColor" class="me-2">{{ rsvpStatusIcon }}</v-icon>
<div>
<div class="font-weight-medium">{{ rsvpStatusText }}</div>
<div v-if="userRSVP?.rsvp_notes" class="text-caption">{{ userRSVP.rsvp_notes }}</div>
</div>
</div>
<v-btn
@click="changeRSVP"
size="small"
variant="outlined"
:color="rsvpStatusColor"
>
Change
</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Payment Details (if RSVP'd to paid event) -->
<v-card
v-if="showPaymentDetails"
variant="outlined"
class="mb-4"
>
<v-card-title class="py-3">
<v-icon class="me-2">mdi-bank-transfer</v-icon>
Payment Details
</v-card-title>
<v-card-text class="pt-0">
<v-row dense>
<v-col cols="12">
<div class="text-body-2">
<strong>Amount:</strong> €{{ paymentAmount }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>IBAN:</strong> {{ paymentInfo.iban }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>Recipient:</strong> {{ paymentInfo.recipient }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>Reference:</strong> {{ userRSVP?.payment_reference }}
</div>
</v-col>
</v-row>
<v-btn
@click="copyPaymentDetails"
size="small"
variant="outlined"
class="mt-3"
prepend-icon="mdi-content-copy"
>
Copy Details
</v-btn>
</v-card-text>
</v-card>
<!-- RSVP Form -->
<v-card v-if="!hasRSVP && canRSVP" variant="outlined">
<v-card-title class="py-3">
<v-icon class="me-2">mdi-account-check</v-icon>
RSVP to this Event
</v-card-title>
<v-card-text class="pt-0">
<v-form v-model="rsvpValid">
<!-- Guest Selection (if event allows guests) -->
<div v-if="allowsGuests" class="mb-4">
<v-card variant="tonal" class="mb-3">
<v-card-text class="py-3">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-account-group</v-icon>
<span class="font-weight-medium">Bring Guests</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-3">
This event allows up to {{ maxGuestsAllowed }} additional guests per person.
</p>
<v-select
v-model="selectedGuests"
:items="guestOptions"
label="Number of Additional Guests"
variant="outlined"
density="compact"
/>
</v-card-text>
</v-card>
</div>
<v-textarea
v-model="rsvpNotes"
label="Notes (optional)"
rows="2"
variant="outlined"
class="mb-3"
/>
<v-btn
@click="submitRSVP('confirmed')"
color="primary"
:loading="rsvpLoading"
:disabled="isEventFull && !isWaitlistAvailable"
size="large"
block
class="mb-2"
>
<v-icon start>mdi-check</v-icon>
{{ isEventFull ? 'Join Waitlist' : 'RSVP' }}
</v-btn>
</v-form>
</v-card-text>
</v-card>
<!-- Event Full Message -->
<v-alert
v-if="isEventFull && !hasRSVP && !isWaitlistAvailable"
type="warning"
variant="tonal"
>
<v-alert-title>Event Full</v-alert-title>
This event has reached maximum capacity and waitlist is not available.
</v-alert>
<!-- Past Event Message -->
<v-alert
v-if="isPastEvent"
type="info"
variant="tonal"
>
<v-alert-title>Past Event</v-alert-title>
This event has already occurred.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<!-- Delete button for admin/board -->
<v-btn
v-if="canDeleteEvent"
@click="showDeleteConfirm = true"
color="error"
variant="outlined"
prepend-icon="mdi-delete"
:loading="deleteLoading"
>
Delete Event
</v-btn>
<v-spacer />
<v-btn
@click="close"
variant="outlined"
>
Close
</v-btn>
</v-card-actions>
</v-card>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteConfirm" max-width="500" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="me-2 text-error">mdi-alert</v-icon>
Delete Event
</v-card-title>
<v-card-text>
<v-alert type="warning" variant="tonal" class="mb-4">
<v-alert-title>Warning: This action cannot be undone</v-alert-title>
This will permanently delete the event and all associated RSVP data.
</v-alert>
<p class="text-body-1 mb-4">
Are you sure you want to delete "<strong>{{ event?.title }}</strong>"?
</p>
<div class="text-body-2 text-medium-emphasis">
<div v-if="event?.current_attendees && parseInt(event.current_attendees) > 0">
<v-icon size="small" class="me-1">mdi-information</v-icon>
This event has {{ event.current_attendees }} confirmed attendees. Their RSVPs will also be deleted.
</div>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="showDeleteConfirm = false"
variant="outlined"
:disabled="deleteLoading"
>
Cancel
</v-btn>
<v-btn
@click="handleDeleteEvent"
color="error"
:loading="deleteLoading"
>
Delete Event
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script setup lang="ts">
import type { Event, EventRSVP } from '~/utils/types';
import { useEvents } from '~/composables/useEvents';
import { useAuth } from '~/composables/useAuth';
// Helper function to replace date-fns format
const formatDate = (date: Date, formatStr: string): string => {
if (formatStr === 'EEEE, MMMM d, yyyy') {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
} else if (formatStr === 'MMM d') {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
} else if (formatStr === 'MMM d, yyyy') {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
} else if (formatStr === 'HH:mm') {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
return date.toLocaleDateString();
};
interface Props {
modelValue: boolean;
event: Event | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'rsvp-updated': [event: Event];
}>();
const { rsvpToEvent, deleteEvent } = useEvents();
const { isAdmin, isBoard } = useAuth();
// Reactive state
const rsvpValid = ref(false);
const rsvpLoading = ref(false);
const rsvpNotes = ref('');
const selectedGuests = ref(0);
const deleteLoading = ref(false);
const showDeleteConfirm = ref(false);
// Computed properties
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const userRSVP = computed((): EventRSVP | null => {
return props.event?.user_rsvp || null;
});
const hasRSVP = computed(() => !!userRSVP.value);
const canRSVP = computed(() => {
return props.event && !isPastEvent.value;
});
const isPastEvent = computed(() => {
if (!props.event) return false;
return new Date(props.event.start_datetime) < new Date();
});
const isEventFull = computed(() => {
if (!props.event?.max_attendees) return false;
const maxAttendees = parseInt(props.event.max_attendees);
const currentAttendees = typeof props.event.current_attendees === 'string'
? parseInt(props.event.current_attendees) || 0
: props.event.current_attendees || 0;
return currentAttendees >= maxAttendees;
});
const isWaitlistAvailable = computed(() => true); // Always allow waitlist for now
const eventTypeColor = computed(() => {
const colors = {
'meeting': 'blue',
'social': 'green',
'fundraiser': 'orange',
'workshop': 'purple',
'board-only': 'red'
};
return colors[props.event?.event_type as keyof typeof colors] || 'grey';
});
const eventTypeIcon = computed(() => {
const icons = {
'meeting': 'mdi-account-group',
'social': 'mdi-party-popper',
'fundraiser': 'mdi-heart',
'workshop': 'mdi-school',
'board-only': 'mdi-shield-account'
};
return icons[props.event?.event_type as keyof typeof icons] || 'mdi-calendar';
});
const eventTypeLabel = computed(() => {
const labels = {
'meeting': 'Meeting',
'social': 'Social Event',
'fundraiser': 'Fundraiser',
'workshop': 'Workshop',
'board-only': 'Board Only'
};
return labels[props.event?.event_type as keyof typeof labels] || 'Event';
});
const formatEventDate = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
if (startDate.toDateString() === endDate.toDateString()) {
return formatDate(startDate, 'EEEE, MMMM d, yyyy');
} else {
return `${formatDate(startDate, 'MMM d')} - ${formatDate(endDate, 'MMM d, yyyy')}`;
}
});
const formatEventTime = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
return `${formatDate(startDate, 'HH:mm')} - ${formatDate(endDate, 'HH:mm')}`;
});
const capacityPercentage = computed(() => {
if (!props.event?.max_attendees) return 0;
const max = parseInt(props.event.max_attendees);
const current = typeof props.event.current_attendees === 'string'
? parseInt(props.event.current_attendees) || 0
: props.event.current_attendees || 0;
return (current / max) * 100;
});
const capacityColor = computed(() => {
const percentage = capacityPercentage.value;
if (percentage >= 100) return 'error';
if (percentage >= 80) return 'warning';
return 'success';
});
const memberPrice = computed(() => props.event?.cost_members);
const nonMemberPrice = computed(() => props.event?.cost_non_members);
const rsvpStatusColor = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'success';
case 'waitlist': return 'warning';
case 'declined': return 'error';
default: return 'info';
}
});
const rsvpStatusIcon = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'mdi-check-circle';
case 'waitlist': return 'mdi-clock';
case 'declined': return 'mdi-close-circle';
default: return 'mdi-help-circle';
}
});
const rsvpStatusText = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'You are attending this event';
case 'waitlist': return 'You are on the waitlist';
case 'declined': return 'You declined this event';
default: return 'Status unknown';
}
});
const showPaymentDetails = computed(() => {
return props.event?.is_paid === 'true' &&
userRSVP.value?.rsvp_status === 'confirmed' &&
userRSVP.value?.payment_status === 'pending';
});
const paymentAmount = computed(() => {
if (!userRSVP.value || !props.event) return '0';
const isMemberPricing = userRSVP.value.is_member_pricing === 'true';
return isMemberPricing ? props.event.cost_members : props.event.cost_non_members;
});
const paymentInfo = computed(() => ({
iban: 'FR76 1234 5678 9012 3456 7890 123', // This should come from config
recipient: 'MonacoUSA Association' // This should come from config
}));
// Guest functionality
const allowsGuests = computed(() => {
return props.event?.guests_permitted === 'true';
});
const maxGuestsAllowed = computed(() => {
if (!allowsGuests.value) return 0;
return parseInt(props.event?.max_guests_permitted || '0');
});
const guestOptions = computed(() => {
const max = maxGuestsAllowed.value;
const options = [];
for (let i = 0; i <= max; i++) {
options.push({
title: i === 0 ? 'No additional guests' : `${i} guest${i > 1 ? 's' : ''}`,
value: i
});
}
return options;
});
// Admin/Board permissions
const canDeleteEvent = computed(() => {
console.log('[EventDetailsDialog] canDeleteEvent computed triggered');
console.log('[EventDetailsDialog] Auth composable values:', {
isAdmin: isAdmin.value,
isBoard: isBoard.value,
typeof_isAdmin: typeof isAdmin.value,
typeof_isBoard: typeof isBoard.value
});
const canDelete = isAdmin.value || isBoard.value;
console.log('[EventDetailsDialog] Final canDelete result:', canDelete);
return canDelete;
});
// Add watcher to see when dialog opens
watch(() => show.value, (newValue) => {
if (newValue) {
console.log('[EventDetailsDialog] Dialog opened');
console.log('[EventDetailsDialog] Event prop:', props.event);
console.log('[EventDetailsDialog] Auth status check on open:', {
isAdmin: isAdmin.value,
isBoard: isBoard.value,
canDelete: canDeleteEvent.value
});
}
});
// Methods
const close = () => {
show.value = false;
rsvpNotes.value = '';
};
const submitRSVP = async (status: 'confirmed' | 'declined') => {
console.log('[EventDetailsDialog] submitRSVP called with status:', status);
if (!props.event) {
console.error('[EventDetailsDialog] No event provided');
return;
}
rsvpLoading.value = true;
try {
// Use event_id field for consistent RSVP relationships
// This ensures RSVPs are linked properly to events using the business identifier
let eventId = props.event.event_id ||
(props.event as any).extendedProps?.event_id ||
(props.event as any).Id || // Fallback to database ID if event_id not available
props.event.id ||
(props.event as any).id; // Additional fallback
// Direct access to Id field as backup
if (!eventId && 'Id' in props.event) {
eventId = (props.event as any)['Id'];
console.log('[EventDetailsDialog] Found Id via direct property access:', eventId);
}
// Try to access the Id property using Object.keys approach
if (!eventId) {
const keys = Object.keys(props.event);
console.log('[EventDetailsDialog] Available keys:', keys);
if (keys.includes('Id')) {
eventId = props.event['Id' as keyof Event];
console.log('[EventDetailsDialog] Found Id via keys lookup:', eventId);
}
}
console.log('[EventDetailsDialog] Using event identifier for RSVP:', eventId);
console.log('[EventDetailsDialog] Event object keys:', Object.keys(props.event));
console.log('[EventDetailsDialog] Event event_id field:', props.event.event_id);
console.log('[EventDetailsDialog] Event database Id field:', (props.event as any).Id);
console.log('[EventDetailsDialog] Event id field:', props.event.id);
console.log('[EventDetailsDialog] Full event object:', JSON.stringify(props.event, null, 2));
if (!eventId) {
console.error('[EventDetailsDialog] Unable to determine event identifier');
throw new Error('Unable to determine event identifier');
}
console.log('[EventDetailsDialog] Calling rsvpToEvent with:', {
eventId,
status,
notes: rsvpNotes.value,
guests: selectedGuests.value
});
const result = await rsvpToEvent(eventId, {
member_id: '', // This will be filled by the composable
rsvp_status: status,
rsvp_notes: rsvpNotes.value,
extra_guests: selectedGuests.value.toString()
});
console.log('[EventDetailsDialog] RSVP submitted successfully:', result);
emit('rsvp-updated', props.event);
// TODO: Show success message
} catch (error) {
console.error('[EventDetailsDialog] Error submitting RSVP:', error);
// TODO: Show error message
} finally {
rsvpLoading.value = false;
}
};
const changeRSVP = () => {
// For now, just allow re-submitting RSVP
// In the future, this could open an edit dialog
if (userRSVP.value?.rsvp_status === 'confirmed') {
submitRSVP('declined');
} else if (userRSVP.value?.rsvp_status === 'declined') {
submitRSVP('confirmed');
}
};
const copyPaymentDetails = async () => {
const details = `
Event: ${props.event?.title}
Amount: €${paymentAmount.value}
IBAN: ${paymentInfo.value.iban}
Recipient: ${paymentInfo.value.recipient}
Reference: ${userRSVP.value?.payment_reference}
`.trim();
try {
await navigator.clipboard.writeText(details);
} catch (error) {
console.error('Error copying to clipboard:', error);
}
};
const handleDeleteEvent = async () => {
if (!props.event) return;
deleteLoading.value = true;
try {
// Use the correct event identifier for deletion
const eventId = (props.event as any).Id || props.event.id || props.event.event_id;
if (!eventId) {
throw new Error('Unable to determine event ID for deletion');
}
console.log('[EventDetailsDialog] Deleting event with ID:', eventId);
const result = await deleteEvent(eventId.toString());
console.log('[EventDetailsDialog] Event deleted successfully:', result);
// Close both dialogs
showDeleteConfirm.value = false;
show.value = false;
// Emit event for parent component to refresh
emit('rsvp-updated', props.event);
} catch (error) {
console.error('[EventDetailsDialog] Error deleting event:', error);
// TODO: Show error message to user
} finally {
deleteLoading.value = false;
}
};
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.text-medium-emphasis {
opacity: 0.7;
}
.v-progress-linear {
max-width: 200px;
}
/* Rich text content styling */
.rich-text-content {
word-wrap: break-word;
line-height: 1.5;
}
.rich-text-content :deep(h1),
.rich-text-content :deep(h2),
.rich-text-content :deep(h3) {
color: rgb(var(--v-theme-on-surface));
font-weight: 600;
margin: 16px 0 8px 0;
}
.rich-text-content :deep(h1) {
font-size: 1.5rem;
}
.rich-text-content :deep(h2) {
font-size: 1.25rem;
}
.rich-text-content :deep(h3) {
font-size: 1.125rem;
}
.rich-text-content :deep(p) {
margin: 8px 0;
}
.rich-text-content :deep(ul),
.rich-text-content :deep(ol) {
padding-left: 20px;
margin: 8px 0;
}
.rich-text-content :deep(li) {
margin: 4px 0;
}
.rich-text-content :deep(strong) {
font-weight: 600;
}
.rich-text-content :deep(em) {
font-style: italic;
}
.rich-text-content :deep(u) {
text-decoration: underline;
}
.rich-text-content :deep(a) {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.rich-text-content :deep(a:hover) {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<v-dialog v-model="show" max-width="400" persistent>
<v-card>
<v-card-title class="text-h5 text-center pa-6" style="color: #a31515;">
Reset Password
</v-card-title>
<v-card-text class="px-6">
<p class="text-body-2 mb-4 text-center text-medium-emphasis">
Enter your email address and we'll send you a link to reset your password.
</p>
<v-form @submit.prevent="handleSubmit" ref="resetForm">
<v-text-field
v-model="email"
label="Email Address"
type="email"
prepend-inner-icon="mdi-email"
variant="outlined"
:error-messages="errors.email"
:disabled="loading"
required
@input="clearErrors"
/>
<v-alert
v-if="message"
:type="messageType"
class="mb-4"
variant="tonal"
>
{{ message }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-6">
<v-btn
variant="text"
@click="close"
:disabled="loading"
>
Cancel
</v-btn>
<v-spacer />
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!email || !isValidEmail"
style="background-color: #a31515 !important;"
>
Send Reset Link
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'success', message: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Reactive data
const email = ref('');
const loading = ref(false);
const message = ref('');
const messageType = ref<'success' | 'error' | 'warning' | 'info'>('info');
const errors = ref({
email: ''
});
const resetForm = ref();
// Computed
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.value);
});
// Methods
const clearErrors = () => {
errors.value.email = '';
message.value = '';
};
const validateEmail = () => {
errors.value.email = '';
if (!email.value) {
errors.value.email = 'Email is required';
return false;
}
if (!isValidEmail.value) {
errors.value.email = 'Please enter a valid email address';
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validateEmail()) return;
loading.value = true;
message.value = '';
try {
const response = await $fetch<{
success: boolean;
message: string;
}>('/api/auth/forgot-password', {
method: 'POST',
body: {
email: email.value
}
});
if (response.success) {
message.value = response.message;
messageType.value = 'success';
// Emit success event
emit('success', response.message);
// Auto-close after 3 seconds
setTimeout(() => {
close();
}, 3000);
}
} catch (error: any) {
console.error('Password reset error:', error);
message.value = error.data?.message || 'Failed to send reset email. Please try again.';
messageType.value = 'error';
} finally {
loading.value = false;
}
};
const close = () => {
show.value = false;
// Reset form after dialog closes
setTimeout(() => {
email.value = '';
message.value = '';
errors.value.email = '';
loading.value = false;
}, 300);
};
// Auto-focus email field when dialog opens
watch(show, (newValue) => {
if (newValue) {
nextTick(() => {
const emailField = document.querySelector('input[type="email"]') as HTMLInputElement;
if (emailField) {
emailField.focus();
}
});
}
});
</script>
<style scoped>
.v-card {
border-radius: 16px !important;
}
.v-card-title {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.v-btn {
text-transform: none !important;
}
/* Form field focus styles */
.v-field--focused {
border-color: #a31515 !important;
}
.v-field--focused .v-field__outline {
border-color: #a31515 !important;
}
</style>

633
components/MemberCard.vue Normal file
View File

@@ -0,0 +1,633 @@
<template>
<v-card
class="member-card"
:class="{
'member-card--inactive': !isActive,
'member-card--overdue': isOverdue,
'member-card--due-soon': isDuesComingDue
}"
elevation="2"
@click="$emit('view', member)"
>
<!-- Status Stripe -->
<div
v-if="isOverdue || isDuesComingDue"
class="status-stripe"
:class="{
'status-stripe--overdue': isOverdue,
'status-stripe--due-soon': isDuesComingDue
}"
/>
<!-- Member Status Badge -->
<div class="member-status-badge">
<v-chip
:color="statusColor"
size="small"
variant="flat"
class="font-weight-bold"
>
<v-icon v-if="!isActive" start size="12">mdi-account-off</v-icon>
<v-icon v-else start size="12">mdi-account-check</v-icon>
{{ member.membership_status || 'Inactive' }}
</v-chip>
</div>
<!-- Action Buttons -->
<div v-if="canEdit || canDelete || (!member.keycloak_id && canCreatePortalAccount) || shouldShowEmailButton" class="member-action-buttons">
<!-- Email Button for Overdue/Due Soon Members -->
<v-btn
v-if="shouldShowEmailButton"
icon
size="small"
variant="text"
:color="isOverdue ? 'error' : 'warning'"
:loading="emailLoading"
@click.stop="sendDuesReminder"
:title="'Send Dues Reminder to ' + member.FullName"
>
<v-icon>mdi-email-alert</v-icon>
</v-btn>
<v-btn
v-if="canEdit"
icon
size="small"
variant="text"
@click.stop="$emit('edit', member)"
:title="'Edit ' + member.FullName"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
v-if="canDelete"
icon
size="small"
variant="text"
color="error"
@click.stop="$emit('delete', member)"
:title="'Delete ' + member.FullName"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<!-- Create Portal Account Button (Circular) -->
<v-btn
v-if="!member.keycloak_id && canCreatePortalAccount"
icon
size="small"
variant="text"
color="primary"
:loading="creatingPortalAccount"
@click.stop="$emit('create-portal-account', member)"
:title="'Create Portal Account for ' + member.FullName"
>
<v-icon>mdi-account-plus</v-icon>
</v-btn>
</div>
<!-- Card Content -->
<v-card-text class="pb-4 pt-3">
<div class="d-flex align-center mb-2">
<ProfileAvatar
:member-id="member.member_id"
:member-name="displayName"
:first-name="member.first_name"
:last-name="member.last_name"
size="medium"
class="mr-3"
/>
<div class="flex-grow-1">
<h3 class="text-subtitle-1 font-weight-bold mb-1">
{{ displayName }}
</h3>
<div class="nationality-display">
<template v-if="nationalitiesArray.length > 0">
<div class="d-flex align-center flex-wrap">
<!-- Display all flags together -->
<div class="flags-container d-flex align-center me-2">
<CountryFlag
v-for="nationality in nationalitiesArray"
:key="nationality"
:country-code="nationality"
:show-name="false"
size="small"
class="flag-item"
/>
</div>
<!-- Display country names -->
<div class="country-names">
<span class="text-caption text-medium-emphasis">
{{ nationalitiesArray.map(n => getCountryName(n)).join(', ') }}
</span>
</div>
</div>
</template>
<template v-else>
<span class="text-caption text-medium-emphasis">
Unknown
</span>
</template>
</div>
</div>
</div>
<!-- Member Info - More Compact -->
<div class="member-info mb-2">
<div class="info-row mb-1" v-if="member.email">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-caption">{{ member.email }}</span>
</div>
<div class="info-row mb-1" v-if="member.phone">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
<span class="text-caption">{{ member.FormattedPhone || member.phone }}</span>
</div>
<div class="info-row mb-1" v-if="member.member_since">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-calendar</v-icon>
<span class="text-caption">Since {{ formatDate(member.member_since) }}</span>
</div>
</div>
<!-- Status Section - Reorganized -->
<div class="status-section">
<!-- Primary Status (Dues) -->
<div class="d-flex align-center justify-space-between mb-2">
<v-chip
:color="duesColor"
:variant="duesVariant"
size="small"
class="mr-1"
>
<v-icon start size="12">{{ duesIcon }}</v-icon>
{{ duesText }}
</v-chip>
<!-- Portal Status - Compact -->
<v-tooltip
:text="member.keycloak_id ? 'Portal Account Active' : 'No Portal Account'"
location="top"
>
<template #activator="{ props }">
<v-chip
v-bind="props"
:color="member.keycloak_id ? 'success' : 'grey'"
variant="tonal"
size="x-small"
class="ml-1"
>
<v-icon size="12">{{ member.keycloak_id ? 'mdi-account-check' : 'mdi-account-off' }}</v-icon>
</v-chip>
</template>
</v-tooltip>
</div>
<!-- Secondary Status (Due Dates) - Only show if relevant -->
<div v-if="isDuesComingDue || (member.payment_due_date && !isDuesComingDue && isOverdue)" class="d-flex">
<v-chip
v-if="isDuesComingDue"
color="orange"
variant="flat"
size="x-small"
>
<v-icon start size="10">mdi-clock-alert</v-icon>
Due {{ formatDate(nextDuesDate) }}
</v-chip>
<v-chip
v-else-if="member.payment_due_date && !isDuesComingDue && isOverdue"
color="error"
variant="flat"
size="x-small"
>
<v-icon start size="10">mdi-calendar-alert</v-icon>
Overdue
</v-chip>
</div>
</div>
</v-card-text>
<!-- Click overlay for better UX -->
<div class="member-card-overlay" @click="$emit('view', member)"></div>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { getCountryName } from '~/utils/countries';
import {
isPaymentOverOneYear as checkPaymentOverOneYear,
isDuesActuallyCurrent as checkDuesActuallyCurrent,
calculateOverdueDays
} from '~/utils/dues-calculations';
interface Props {
member: Member;
canEdit?: boolean;
canDelete?: boolean;
canCreatePortalAccount?: boolean;
creatingPortalAccount?: boolean;
}
interface Emits {
(e: 'view', member: Member): void;
(e: 'edit', member: Member): void;
(e: 'delete', member: Member): void;
(e: 'create-portal-account', member: Member): void;
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canDelete: false,
canCreatePortalAccount: false,
creatingPortalAccount: false
});
defineEmits<Emits>();
// Computed properties
const memberInitials = computed(() => {
const firstName = props.member.first_name || '';
const lastName = props.member.last_name || '';
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
});
const displayName = computed(() => {
// Try FullName first, then build from first_name + last_name, then fallback
return props.member.FullName ||
`${props.member.first_name || ''} ${props.member.last_name || ''}`.trim() ||
'New Member';
});
const avatarColor = computed(() => {
// Generate consistent color based on member ID using high-contrast colors
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink', 'brown'];
const idNumber = parseInt(props.member.Id) || 0;
return colors[idNumber % colors.length];
});
const nationalitiesArray = computed(() => {
if (!props.member.nationality) return [];
// Handle multiple nationalities separated by comma, semicolon, or pipe
const nationalities = props.member.nationality
.split(/[,;|]/)
.map(n => n.trim().toUpperCase())
.filter(n => n.length > 0);
return nationalities;
});
const isActive = computed(() => {
return props.member.membership_status === 'Active';
});
const statusColor = computed(() => {
const status = props.member.membership_status;
switch (status) {
case 'Active': return 'success';
case 'Inactive': return 'grey';
case 'Pending': return 'warning';
case 'Expired': return 'error';
default: return 'grey';
}
});
/**
* Check if a member is in their grace period
* Uses the same logic as dues-status API
*/
const isInGracePeriod = computed(() => {
if (!props.member.payment_due_date) return false;
try {
const dueDate = new Date(props.member.payment_due_date);
const today = new Date();
return dueDate > today;
} catch {
return false;
}
});
/**
* Check if a member's last payment is over 1 year old
* Uses standardized dues calculation function
*/
const isPaymentOverOneYear = computed(() => {
return checkPaymentOverOneYear(props.member);
});
/**
* Check if dues are actually current
* Uses standardized dues calculation function
*/
const isDuesActuallyCurrent = computed(() => {
return checkDuesActuallyCurrent(props.member);
});
const duesColor = computed(() => {
if (isDuesActuallyCurrent.value) return 'success';
if (isInGracePeriod.value) return 'warning';
return 'error';
});
const duesVariant = computed(() => {
if (isDuesActuallyCurrent.value) return 'tonal';
if (isInGracePeriod.value) return 'tonal';
return 'flat';
});
const duesIcon = computed(() => {
if (isDuesActuallyCurrent.value) return 'mdi-check-circle';
if (isInGracePeriod.value) return 'mdi-clock-alert';
return 'mdi-alert-circle';
});
const duesText = computed(() => {
if (isDuesActuallyCurrent.value) return 'Dues Paid';
if (isInGracePeriod.value) return 'Grace Period';
return 'Dues Outstanding';
});
const isOverdue = computed(() => {
// If dues are current, not overdue
if (isDuesActuallyCurrent.value) return false;
// If in grace period, not yet overdue
if (isInGracePeriod.value) return false;
// Check if payment_due_date has passed
if (props.member.payment_due_date) {
const dueDate = new Date(props.member.payment_due_date);
const today = new Date();
return dueDate < today;
}
// If no due date but not paid and not in grace period, consider overdue
return props.member.current_year_dues_paid !== 'true';
});
// Calculate next dues date (1 year from when they last paid)
const nextDuesDate = computed(() => {
// If dues are paid, calculate 1 year from payment date
if (props.member.current_year_dues_paid === 'true' && props.member.membership_date_paid) {
const lastPaidDate = new Date(props.member.membership_date_paid);
const nextDue = new Date(lastPaidDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue.toISOString().split('T')[0]; // Return as date string
}
// If not paid but has a due date, use that
if (props.member.payment_due_date) {
return props.member.payment_due_date;
}
// Fallback: 1 year from member since date
if (props.member.member_since) {
const memberSince = new Date(props.member.member_since);
const nextDue = new Date(memberSince);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue.toISOString().split('T')[0];
}
return '';
});
// Check if dues are coming due within 2 months
const isDuesComingDue = computed(() => {
// Only show warning if dues are currently paid
if (props.member.current_year_dues_paid !== 'true') return false;
if (!nextDuesDate.value) return false;
const today = new Date();
const dueDate = new Date(nextDuesDate.value);
const twoMonthsFromNow = new Date();
twoMonthsFromNow.setMonth(twoMonthsFromNow.getMonth() + 2);
// Show warning if due date is within the next 2 months
return dueDate <= twoMonthsFromNow && dueDate > today;
});
// Email functionality
const emailLoading = ref(false);
const shouldShowEmailButton = computed(() => {
// Only show email button if member has email and is overdue or dues coming due
return !!(props.member.email && (isOverdue.value || isDuesComingDue.value));
});
// Methods
const formatDate = (dateString: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateString;
}
};
const sendDuesReminder = async () => {
if (!props.member.email || emailLoading.value) return;
emailLoading.value = true;
try {
// Determine the reminder type based on the member's status
const reminderType = isOverdue.value ? 'overdue' : 'due-soon';
const response = await $fetch<{
success: boolean;
message: string;
data: any;
}>(`/api/members/${props.member.Id}/send-dues-reminder`, {
method: 'post',
body: {
reminderType
}
});
if (response?.success) {
console.log(`Dues reminder sent successfully to ${props.member.email}`);
// You could show a success toast here if needed
}
} catch (error: any) {
console.error('Error sending dues reminder:', error);
// You could show an error toast here if needed
} finally {
emailLoading.value = false;
}
};
</script>
<style scoped>
.member-card {
cursor: pointer;
border-radius: 12px !important;
transition: all 0.3s ease;
position: relative;
height: 100%;
overflow: hidden;
}
.member-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15) !important;
}
.member-card--inactive {
opacity: 0.8;
}
.member-card--inactive .v-card-text {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.member-status-badge {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
}
.member-action-buttons {
position: absolute;
bottom: 12px;
right: 12px;
z-index: 3;
display: flex;
gap: 4px;
}
.member-action-buttons .v-btn {
pointer-events: all;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
}
.nationality-display {
min-height: 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flags-container {
display: flex;
align-items: center;
}
.flag-item {
margin-right: 4px;
}
.flag-item:last-child {
margin-right: 0;
}
.country-names {
flex: 1;
}
.member-info {
min-height: 60px;
}
.info-row {
display: flex;
align-items: center;
min-height: 24px;
}
.dues-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.member-card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
pointer-events: none;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.member-card {
margin-bottom: 16px;
}
.dues-status {
flex-direction: column;
align-items: flex-start;
}
.member-action-buttons {
bottom: 8px;
right: 8px;
}
}
/* Animation for status changes */
.v-chip {
transition: all 0.2s ease;
}
/* Custom scrollbar for long content */
.member-info::-webkit-scrollbar {
width: 4px;
}
.member-info::-webkit-scrollbar-track {
background: transparent;
}
.member-info::-webkit-scrollbar-thumb {
background-color: rgba(163, 21, 21, 0.3);
border-radius: 2px;
}
.text-error {
color: rgb(var(--v-theme-error)) !important;
}
/* Status Stripe Styles */
.status-stripe {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
z-index: 2;
border-radius: 12px 0 0 12px;
}
.status-stripe--overdue {
background: linear-gradient(180deg, #f44336 0%, #d32f2f 100%);
box-shadow: 2px 0 8px rgba(244, 67, 54, 0.3);
}
.status-stripe--due-soon {
background: linear-gradient(180deg, #ff9800 0%, #f57c00 100%);
box-shadow: 2px 0 8px rgba(255, 152, 0, 0.3);
}
.member-card--overdue {
border-left: 4px solid #f44336;
}
.member-card--due-soon {
border-left: 4px solid #ff9800;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="monaco-logo" :class="sizeClass">
<v-img
:src="logoSrc"
:width="logoWidth"
:height="logoHeight"
class="logo-img"
alt="MonacoUSA Logo"
:style="logoStyle"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'white' | 'dark'
clickable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
variant: 'default',
clickable: false
});
const emit = defineEmits<{
click: []
}>();
// Computed properties for responsive sizing
const sizeClass = computed(() => `monaco-logo--${props.size}`);
const logoSrc = computed(() => {
// Use the high-res Monaco flag image
return '/MONACOUSA-Flags_376x376.png';
});
const logoWidth = computed(() => {
switch (props.size) {
case 'small': return 32;
case 'medium': return 48;
case 'large': return 80;
default: return 48;
}
});
const logoHeight = computed(() => {
switch (props.size) {
case 'small': return 32;
case 'medium': return 48;
case 'large': return 80;
default: return 48;
}
});
const logoStyle = computed(() => {
const baseStyle: Record<string, string> = {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
};
if (props.clickable) {
baseStyle.cursor = 'pointer';
baseStyle.transition = 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out';
}
if (props.variant === 'white') {
baseStyle.backgroundColor = 'white';
baseStyle.padding = '4px';
} else if (props.variant === 'dark') {
baseStyle.backgroundColor = 'rgba(0, 0, 0, 0.1)';
baseStyle.padding = '4px';
}
return baseStyle;
});
// Handle click events
const handleClick = () => {
if (props.clickable) {
emit('click');
}
};
</script>
<style scoped>
.monaco-logo {
display: inline-flex;
align-items: center;
justify-content: center;
}
.monaco-logo--small {
min-width: 32px;
min-height: 32px;
}
.monaco-logo--medium {
min-width: 48px;
min-height: 48px;
}
.monaco-logo--large {
min-width: 80px;
min-height: 80px;
}
.logo-img {
object-fit: cover;
border-radius: inherit;
}
.monaco-logo:hover .logo-img {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(163, 21, 21, 0.2);
}
/* Ensure the logo maintains aspect ratio */
.v-img {
flex-shrink: 0;
}
/* Animation for clickable logos */
.monaco-logo[style*="cursor: pointer"]:hover {
transform: translateY(-2px);
}
.monaco-logo[style*="cursor: pointer"]:active {
transform: translateY(0);
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
.logo-img,
.monaco-logo {
transition: none !important;
transform: none !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.logo-img {
border: 2px solid currentColor;
}
}
/* Print styles */
@media print {
.monaco-logo {
box-shadow: none !important;
}
.logo-img {
transform: none !important;
}
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<span class="multiple-country-flags" :class="{ 'multiple-country-flags--small': size === 'small' }">
<ClientOnly>
<template v-if="countryCodes.length > 0">
<VueCountryFlag
v-for="(code, index) in countryCodes"
:key="`${code}-${index}`"
:country="code"
:size="flagSize"
:title="getCountryName(code)"
class="country-flag-item"
/>
</template>
<template v-else>
<span class="no-nationality">{{ fallbackText }}</span>
</template>
<template #fallback>
<span class="flag-placeholder" :style="placeholderStyle">🏳</span>
</template>
</ClientOnly>
<span v-if="showName && countryCodes.length > 0" class="country-names">
{{ countryNames }}
</span>
</span>
</template>
<script setup lang="ts">
import VueCountryFlag from 'vue-country-flag-next';
import { getCountryName, parseCountryInput } from '~/utils/countries';
interface Props {
nationality?: string; // Can be comma-separated like "FR,MC,US"
showName?: boolean;
size?: 'small' | 'medium' | 'large';
fallbackText?: string;
separator?: string; // For display names
}
const props = withDefaults(defineProps<Props>(), {
nationality: '',
showName: false,
size: 'medium',
fallbackText: 'Not specified',
separator: ', '
});
// Parse multiple nationalities
const countryCodes = computed(() => {
if (!props.nationality) return [];
// Split by comma and clean up
const codes = props.nationality
.split(',')
.map(code => code.trim())
.filter(code => code.length > 0)
.map(code => {
// If it's already a 2-letter code, use it
if (code.length === 2) {
return code.toUpperCase();
}
// Try to parse country name to get the code
return parseCountryInput(code) || '';
})
.filter(code => code.length === 2); // Only keep valid 2-letter codes
// Remove duplicates
return [...new Set(codes)];
});
const countryNames = computed(() => {
return countryCodes.value
.map(code => getCountryName(code))
.filter(name => name)
.join(props.separator);
});
const flagSize = computed(() => {
const sizeMap = {
small: 'sm',
medium: 'md',
large: 'lg'
};
return sizeMap[props.size];
});
const placeholderStyle = computed(() => {
const sizeMap = {
small: '1rem',
medium: '1.5rem',
large: '2rem'
};
return {
width: sizeMap[props.size],
height: `calc(${sizeMap[props.size]} * 0.75)`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
backgroundColor: '#f5f5f5',
fontSize: '0.75rem'
};
});
</script>
<style scoped>
.multiple-country-flags {
display: inline-flex;
align-items: center;
gap: 0.5rem;
vertical-align: middle;
}
.multiple-country-flags--small {
gap: 0.25rem;
}
.country-flag-item {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* Add slight overlap for multiple flags to save space */
.country-flag-item:not(:first-child) {
margin-left: -0.25rem;
}
.multiple-country-flags--small .country-flag-item:not(:first-child) {
margin-left: -0.125rem;
}
.country-names {
font-size: 0.875rem;
color: inherit;
white-space: nowrap;
margin-left: 0.25rem;
}
.multiple-country-flags--small .country-names {
font-size: 0.75rem;
}
.no-nationality {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
font-style: italic;
}
.multiple-country-flags--small .no-nationality {
font-size: 0.75rem;
}
.flag-placeholder {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* Ensure proper flag display */
:deep(.vue-country-flag) {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
position: relative;
z-index: 1;
}
/* Add hover effect to see all flags clearly */
.multiple-country-flags:hover .country-flag-item:not(:first-child) {
margin-left: 0.125rem;
transition: margin-left 0.2s ease;
}
</style>

View File

@@ -0,0 +1,742 @@
<template>
<div class="multiple-nationality-input">
<div class="nationality-list">
<div
v-for="(nationality, index) in nationalities"
:key="`nationality-${index}`"
class="nationality-item d-flex align-center gap-2 mb-2"
>
<!-- Mobile Safari optimized country selector -->
<v-text-field
v-if="useMobileInterface"
:model-value="getSelectedCountryName(nationalities[index])"
:label="index === 0 && label ? label : `Nationality ${index + 1}`"
variant="outlined"
density="comfortable"
readonly
:error="hasError && index === 0"
:error-messages="hasError && index === 0 ? errorMessage : undefined"
@click="openMobileSelector(index)"
append-inner-icon="mdi-chevron-down"
class="nationality-select mobile-optimized"
>
<template #prepend-inner v-if="nationalities[index]">
<CountryFlag
:country-code="nationalities[index]"
:show-name="false"
size="small"
class="flag-icon me-2"
/>
</template>
</v-text-field>
<!-- Traditional v-select for desktop -->
<v-select
v-else
v-model="nationalities[index]"
:items="countryOptions"
:label="index === 0 && label ? label : `Nationality ${index + 1}`"
variant="outlined"
density="comfortable"
:error="hasError && index === 0"
:error-messages="hasError && index === 0 ? errorMessage : undefined"
@update:model-value="updateNationalities"
class="nationality-select"
>
<template #selection="{ item }">
<div class="flag-selection d-flex align-center">
<CountryFlag
:country-code="item.value"
:show-name="false"
size="small"
class="flag-icon me-2"
/>
<span class="country-name">{{ item.title }}</span>
</div>
</template>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" class="flag-list-item">
<template #prepend>
<div class="flag-prepend">
<CountryFlag
:country-code="item.raw.code"
:show-name="false"
size="small"
class="flag-icon"
/>
</div>
</template>
<v-list-item-title class="country-name">{{ item.raw.name }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<v-btn
v-if="nationalities.length > 1"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeNationality(index)"
:title="`Remove ${getCountryName(nationality) || 'nationality'}`"
/>
</div>
</div>
<div class="nationality-actions mt-2">
<v-btn
variant="outlined"
color="primary"
size="small"
prepend-icon="mdi-plus"
@click="addNationality"
:disabled="disabled || nationalities.length >= maxNationalities"
>
Add Nationality
</v-btn>
<span v-if="nationalities.length >= maxNationalities" class="text-caption text-medium-emphasis ml-2">
Maximum {{ maxNationalities }} nationalities allowed
</span>
</div>
<!-- Preview of selected nationalities -->
<div v-if="nationalities.length > 0 && !hasEmptyNationality" class="nationality-preview mt-3">
<v-label class="text-caption mb-1">Selected Nationalities:</v-label>
<div class="d-flex flex-wrap gap-1">
<v-chip
v-for="nationality in validNationalities"
:key="nationality"
size="small"
variant="tonal"
color="primary"
>
<CountryFlag
:country-code="nationality"
:show-name="false"
size="small"
class="mr-1"
/>
{{ getCountryName(nationality) }}
</v-chip>
</div>
</div>
<!-- Mobile Safari Country Selection Dialog -->
<v-dialog
v-model="showMobileSelector"
:fullscreen="useMobileInterface"
:max-width="useMobileInterface ? undefined : '500px'"
:transition="useMobileInterface ? 'dialog-bottom-transition' : 'dialog-transition'"
class="mobile-country-dialog"
>
<v-card class="mobile-country-selector">
<v-card-title class="d-flex align-center justify-space-between pa-4">
<span class="text-h6">Select Country</span>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="showMobileSelector = false"
/>
</v-card-title>
<v-divider />
<v-card-text class="pa-0">
<!-- Search field -->
<div class="search-container pa-4 pb-2">
<v-text-field
v-model="searchQuery"
placeholder="Search countries..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
hide-details
clearable
class="country-search"
/>
</div>
<!-- Country list -->
<v-list class="country-list" density="comfortable">
<template v-for="country in filteredCountries" :key="country.code">
<v-list-item
@click="selectCountry(country.code)"
class="country-list-item"
:class="{ 'selected': nationalities[currentEditingIndex] === country.code }"
>
<template #prepend>
<div class="country-flag-container">
<CountryFlag
:country-code="country.code"
:show-name="false"
size="small"
class="country-flag"
/>
</div>
</template>
<v-list-item-title class="country-title">
{{ country.name }}
</v-list-item-title>
<template #append v-if="nationalities[currentEditingIndex] === country.code">
<v-icon color="primary" size="small">mdi-check</v-icon>
</template>
</v-list-item>
</template>
<v-list-item v-if="filteredCountries.length === 0" class="no-results">
<v-list-item-title class="text-center text-medium-emphasis">
No countries found
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
variant="text"
@click="showMobileSelector = false"
class="text-none"
>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { getAllCountries, searchCountries } from '~/utils/countries';
// Simple device detection utilities
const detectMobile = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent;
return /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
};
const detectMobileSafari = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent;
return /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
};
interface Props {
modelValue?: string; // Comma-separated string like "FR,MC,US"
label?: string;
error?: boolean;
errorMessage?: string;
disabled?: boolean;
maxNationalities?: number;
required?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: string): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
maxNationalities: 5,
error: false,
disabled: false,
required: false
});
const emit = defineEmits<Emits>();
// Device detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
const needsPerformanceMode = ref(false);
// Initialize device detection on mount
onMounted(() => {
if (process.client) {
isMobile.value = detectMobile();
isMobileSafari.value = detectMobileSafari();
needsPerformanceMode.value = isMobileSafari.value || isMobile.value;
}
});
// Parse initial nationalities from comma-separated string
const parseNationalities = (value: string): string[] => {
if (!value || value.trim() === '') return [''];
return value.split(',').map(n => n.trim()).filter(n => n.length > 0);
};
// Reactive nationalities array
const nationalities = ref<string[]>(parseNationalities(props.modelValue));
// Ensure there's always at least one empty nationality field
if (nationalities.value.length === 0) {
nationalities.value = [''];
}
// Mobile optimization flags
const useMobileInterface = computed(() => isMobileSafari.value || needsPerformanceMode.value);
// Mobile dialog state
const showMobileSelector = ref(false);
const currentEditingIndex = ref(-1);
const searchQuery = ref('');
// Filtered countries for mobile selector
const filteredCountries = computed(() => {
const countries = getAllCountries();
if (!searchQuery.value) return countries;
const query = searchQuery.value.toLowerCase();
return countries.filter(country =>
country.name.toLowerCase().includes(query) ||
country.code.toLowerCase().includes(query)
);
});
// Watch for external model changes
watch(() => props.modelValue, (newValue) => {
const newNationalities = parseNationalities(newValue || '');
if (newNationalities.length === 0) newNationalities.push('');
// Only update if different to prevent loops
const current = nationalities.value.filter(n => n).join(',');
const incoming = newNationalities.filter(n => n).join(',');
if (current !== incoming) {
nationalities.value = newNationalities;
}
});
// Country options for dropdowns
const countryOptions = computed(() => {
const countries = getAllCountries();
return countries.map(country => ({
title: country.name,
value: country.code,
code: country.code,
name: country.name
}));
});
// Computed properties
const validNationalities = computed(() => {
return nationalities.value.filter(n => n && n.trim().length > 0);
});
const hasEmptyNationality = computed(() => {
return nationalities.value.some(n => !n || n.trim() === '');
});
const hasError = computed(() => {
return props.error || !!props.errorMessage;
});
// Methods
const addNationality = () => {
if (nationalities.value.length < props.maxNationalities) {
nationalities.value.push('');
}
};
const removeNationality = (index: number) => {
if (nationalities.value.length > 1) {
nationalities.value.splice(index, 1);
updateNationalities();
}
};
const updateNationalities = () => {
// Remove duplicates and empty values for the model
const uniqueValid = [...new Set(validNationalities.value)];
const result = uniqueValid.join(',');
emit('update:modelValue', result);
};
// Helper methods
const getCountryName = (countryCode: string): string => {
if (!countryCode) return '';
const countries = getAllCountries();
const country = countries.find(c => c.code === countryCode);
return country?.name || '';
};
// Mobile Safari specific methods
const getSelectedCountryName = (countryCode: string): string => {
if (!countryCode) return '';
return getCountryName(countryCode) || '';
};
const openMobileSelector = (index: number) => {
currentEditingIndex.value = index;
showMobileSelector.value = true;
};
const selectCountry = (countryCode: string) => {
if (currentEditingIndex.value >= 0) {
nationalities.value[currentEditingIndex.value] = countryCode;
updateNationalities();
}
showMobileSelector.value = false;
currentEditingIndex.value = -1;
};
// Watch nationalities array for changes
watch(nationalities, () => {
updateNationalities();
}, { deep: true });
// Initialize the model value on mount if needed
onMounted(() => {
if (!props.modelValue && validNationalities.value.length > 0) {
updateNationalities();
}
});
</script>
<style scoped>
.multiple-nationality-input {
width: 100%;
}
.nationality-item {
position: relative;
}
.nationality-item .v-select {
flex: 1;
}
.nationality-actions {
display: flex;
align-items: center;
gap: 8px;
}
.nationality-preview {
padding: 12px;
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
border-left: 4px solid rgb(var(--v-theme-primary));
}
.nationality-preview .v-chip {
margin: 2px;
}
/* Animation for adding/removing items */
.nationality-item {
transition: all 0.3s ease;
}
.nationality-item.v-enter-active,
.nationality-item.v-leave-active {
transition: all 0.3s ease;
}
.nationality-item.v-enter-from,
.nationality-item.v-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* Error styling */
.error-message {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Focus and hover states */
.nationality-item .v-btn:hover {
background-color: rgba(var(--v-theme-error), 0.08);
}
/* Responsive adjustments */
@media (max-width: 600px) {
.nationality-item {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.nationality-item .v-btn {
align-self: flex-end;
width: fit-content;
}
}
/* Enhanced nationality select styling */
.nationality-select {
min-height: 56px;
}
/* Flag alignment fixes */
.flag-selection {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 24px;
padding: 2px 0;
}
.flag-prepend {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
margin-right: 12px;
flex-shrink: 0;
}
.flag-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
flex-shrink: 0;
width: 20px;
height: 15px;
}
.country-name {
line-height: 1.4;
font-size: 0.875rem;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.flag-list-item {
min-height: 48px;
padding: 8px 16px;
}
/* Vuetify overrides for better styling */
:deep(.nationality-select .v-field) {
min-height: 56px;
}
:deep(.nationality-select .v-field__input) {
align-items: center;
padding: 14px 16px;
min-height: 24px;
}
:deep(.nationality-select .v-field__field) {
align-items: center;
}
:deep(.nationality-select .v-field__overlay) {
border-radius: 8px;
}
:deep(.flag-list-item .v-list-item__prepend) {
align-self: center;
margin-inline-end: 12px;
}
:deep(.flag-selection) {
padding: 0;
margin: 0;
}
:deep(.v-select__selection) {
align-items: center;
}
/* Better dropdown menu styling */
:deep(.v-overlay__content .v-list) {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:deep(.v-list-item:hover) {
background-color: rgba(var(--v-theme-primary), 0.08);
}
:deep(.v-list-item--active) {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
/* Priority countries styling in dropdowns */
:deep(.v-list-item[data-country="MC"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
:deep(.v-list-item[data-country="FR"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
:deep(.v-list-item[data-country="US"]) {
background-color: rgba(var(--v-theme-primary), 0.02);
}
/* Mobile Safari Country Dialog Styles */
.mobile-country-dialog .v-dialog {
margin: 0;
}
.mobile-country-selector {
height: 100%;
display: flex;
flex-direction: column;
max-height: 100vh;
overflow: hidden;
}
.mobile-country-selector .v-card-text {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-container {
flex-shrink: 0;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.country-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
max-height: calc(100vh - 200px);
}
.country-list-item {
min-height: 56px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.country-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.country-list-item.selected {
background-color: rgba(var(--v-theme-primary), 0.12);
}
.country-flag-container {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 32px;
height: 24px;
margin-right: 16px;
flex-shrink: 0;
}
.country-flag {
width: 24px;
height: 18px;
}
.country-title {
font-size: 1rem;
line-height: 1.5;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.no-results {
padding: 32px 16px;
}
/* Mobile optimized text field */
.nationality-select.mobile-optimized {
cursor: pointer;
}
.nationality-select.mobile-optimized :deep(.v-field__input) {
cursor: pointer;
}
.nationality-select.mobile-optimized :deep(.v-field__field) {
cursor: pointer;
}
/* Mobile Safari specific fixes */
@media (max-width: 768px) {
.mobile-country-dialog :deep(.v-overlay__content) {
margin: 0 !important;
max-height: none !important;
height: 100% !important;
width: 100% !important;
}
.mobile-country-selector {
border-radius: 0 !important;
}
.country-list {
max-height: calc(100vh - 160px);
}
.country-list-item {
min-height: 60px; /* Larger touch targets */
padding: 16px;
}
.country-flag-container {
width: 36px;
min-width: 36px;
height: 27px;
}
.country-flag {
width: 28px;
height: 21px;
}
}
/* Performance optimizations for mobile Safari */
.is-mobile-safari .mobile-country-selector,
.performance-mode .mobile-country-selector {
-webkit-transform: translateZ(0); /* Force hardware acceleration */
transform: translateZ(0);
}
.is-mobile-safari .country-list,
.performance-mode .country-list {
will-change: scroll-position;
}
.is-mobile-safari .country-list-item,
.performance-mode .country-list-item {
transition: none; /* Disable transitions for better performance */
}
/* Smooth scrolling fix for mobile Safari */
.mobile-country-dialog :deep(.v-overlay__scrim) {
background: rgba(0, 0, 0, 0.5);
}
/* Fix dialog transition on mobile */
@media (max-width: 768px) {
.mobile-country-dialog :deep(.v-dialog-transition-enter-active),
.mobile-country-dialog :deep(.v-dialog-transition-leave-active) {
transition: transform 0.3s ease-out;
}
.mobile-country-dialog :deep(.v-dialog-transition-enter-from) {
transform: translateY(100%);
}
.mobile-country-dialog :deep(.v-dialog-transition-leave-to) {
transform: translateY(100%);
}
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="700"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<v-icon class="mr-3 text-white">mdi-database-cog</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
NocoDB Configuration
</h2>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<template #title>Admin Only Configuration</template>
Configure the NocoDB database connection for the Member Management system.
These settings will override environment variables when set.
</v-alert>
<v-form ref="formRef" v-model="formValid">
<v-row>
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Database Connection</h3>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.url"
label="NocoDB URL"
variant="outlined"
:rules="[rules.required, rules.url]"
required
placeholder="https://database.monacousa.org"
:error="hasFieldError('url')"
:error-messages="getFieldError('url')"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.apiKey"
label="API Token"
variant="outlined"
:rules="[rules.required]"
required
:type="showApiKey ? 'text' : 'password'"
:append-inner-icon="showApiKey ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showApiKey = !showApiKey"
placeholder="Enter your NocoDB API token"
:error="hasFieldError('apiKey')"
:error-messages="getFieldError('apiKey')"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.baseId"
label="Base ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="your-base-id"
:error="hasFieldError('baseId')"
:error-messages="getFieldError('baseId')"
/>
</v-col>
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Table Configuration</h3>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.tables.members"
label="Members Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="members-table-id"
:error="hasFieldError('tables.members')"
:error-messages="getFieldError('tables.members')"
/>
<div class="text-caption text-medium-emphasis mt-1">
Configure the table ID for the Members functionality
</div>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.tables.events"
label="Events Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="events-table-id"
:error="hasFieldError('tables.events')"
:error-messages="getFieldError('tables.events')"
/>
<div class="text-caption text-medium-emphasis mt-1">
Configure the table ID for the Events functionality
</div>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.tables.rsvps"
label="RSVPs Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="rsvps-table-id"
:error="hasFieldError('tables.rsvps')"
:error-messages="getFieldError('tables.rsvps')"
/>
<div class="text-caption text-medium-emphasis mt-1">
Configure the table ID for the Event RSVPs functionality
</div>
</v-col>
<v-col cols="12">
<v-divider class="my-2" />
</v-col>
<!-- Connection Status -->
<v-col cols="12" md="6">
<v-btn
@click="testConnection"
:loading="testLoading"
:disabled="!formValid || loading"
color="info"
variant="outlined"
block
>
<v-icon start>mdi-database-check</v-icon>
Test Connection
</v-btn>
</v-col>
<v-col cols="12" md="6">
<div class="d-flex align-center h-100">
<v-chip
v-if="connectionStatus"
:color="connectionStatus.success ? 'success' : 'error'"
variant="flat"
size="small"
>
<v-icon start size="14">
{{ connectionStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ connectionStatus.message }}
</v-chip>
<span v-else class="text-caption text-medium-emphasis">
Connection not tested
</span>
</div>
</v-col>
<!-- Display errors -->
<v-col cols="12" v-if="hasGeneralError">
<v-alert
type="error"
variant="tonal"
closable
@click:close="clearGeneralError"
>
{{ getGeneralError }}
</v-alert>
</v-col>
<!-- Display success -->
<v-col cols="12" v-if="showSuccessMessage">
<v-alert
type="success"
variant="tonal"
closable
@click:close="showSuccessMessage = false"
>
NocoDB configuration saved successfully!
</v-alert>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="saveSettings"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-content-save</v-icon>
Save Configuration
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { NocoDBSettings } from '~/utils/types';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'settings-saved'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
const testLoading = ref(false);
const showApiKey = ref(false);
const showSuccessMessage = ref(false);
// Form data
const form = ref<NocoDBSettings>({
url: 'https://database.monacousa.org',
apiKey: '',
baseId: '',
tables: {
members: '',
events: '',
rsvps: ''
}
});
// Error handling
const fieldErrors = ref<Record<string, string>>({});
const connectionStatus = ref<{ success: boolean; message: string } | null>(null);
// Validation rules
const rules = {
required: (value: string) => {
return !!value?.trim() || 'This field is required';
},
url: (value: string) => {
if (!value) return true; // Let required rule handle empty values
const pattern = /^https?:\/\/.+/;
return pattern.test(value) || 'Please enter a valid URL';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const hasGeneralError = computed(() => {
return !!fieldErrors.value.general;
});
const getGeneralError = computed(() => {
return fieldErrors.value.general || '';
});
const clearFieldErrors = () => {
fieldErrors.value = {};
};
const clearGeneralError = () => {
delete fieldErrors.value.general;
};
// Load current settings
const loadSettings = async () => {
try {
const response = await $fetch<{ success: boolean; data?: NocoDBSettings }>('/api/admin/nocodb-config');
if (response.success && response.data) {
form.value = { ...response.data };
// Ensure tables object exists with all required fields
if (!form.value.tables) {
form.value.tables = {
members: '',
events: '',
rsvps: ''
};
}
}
} catch (error: any) {
console.error('Failed to load NocoDB settings:', error);
// Use defaults if loading fails
}
};
// Test connection
const testConnection = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
testLoading.value = true;
connectionStatus.value = null;
try {
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/nocodb-test', {
method: 'POST',
body: form.value
});
connectionStatus.value = {
success: response.success,
message: response.message || (response.success ? 'Connection successful' : 'Connection failed')
};
} catch (error: any) {
connectionStatus.value = {
success: false,
message: error.message || 'Connection test failed'
};
} finally {
testLoading.value = false;
}
};
// Save settings
const saveSettings = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
const response = await $fetch<{ success: boolean; message?: string }>('/api/admin/nocodb-config', {
method: 'POST',
body: form.value
});
if (response.success) {
showSuccessMessage.value = true;
emit('settings-saved');
// Auto-close after a delay
setTimeout(() => {
closeDialog();
}, 2000);
} else {
throw new Error(response.message || 'Failed to save settings');
}
} catch (error: any) {
console.error('Error saving NocoDB settings:', error);
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
fieldErrors.value.general = error.message || 'Failed to save NocoDB configuration. Please try again.';
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
const resetForm = () => {
form.value = {
url: 'https://database.monacousa.org',
apiKey: '',
baseId: '',
tables: {
members: '',
events: '',
rsvps: ''
}
};
clearFieldErrors();
connectionStatus.value = null;
showSuccessMessage.value = false;
nextTick(() => {
formRef.value?.resetValidation();
});
};
// Watch for dialog open
watch(() => props.modelValue, async (newValue) => {
if (newValue) {
resetForm();
await loadSettings();
}
});
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section styling */
.v-card-text .v-row .v-col h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Connection status styling */
.h-100 {
height: 100%;
}
/* Password field styling */
.v-text-field :deep(.v-input__append-inner) {
cursor: pointer;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<v-card
v-if="showBanner"
class="pwa-install-banner"
elevation="8"
variant="elevated"
>
<v-card-text class="pa-4">
<v-row align="center" no-gutters>
<v-col cols="auto" class="mr-3">
<v-avatar size="48" color="white">
<v-img src="/icon-192x192.png" alt="MonacoUSA Portal" />
</v-avatar>
</v-col>
<v-col>
<div class="text-white">
<div class="text-subtitle-1 font-weight-bold mb-1">
Install MonacoUSA Portal
</div>
<div class="text-body-2 text-grey-lighten-2">
{{ installMessage }}
</div>
</div>
</v-col>
<v-col cols="auto">
<v-btn
v-if="canInstall"
@click="installPWA"
color="white"
variant="elevated"
size="small"
class="mr-2"
:loading="installing"
>
<v-icon start>mdi-download</v-icon>
Install
</v-btn>
<v-btn
@click="dismissBanner"
color="white"
variant="text"
size="small"
icon
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// Reactive state
const showBanner = ref(false);
const canInstall = ref(false);
const installing = ref(false);
const installMessage = ref('Add to your home screen for quick access');
let deferredPrompt: BeforeInstallPromptEvent | null = null;
// Device detection
const isIOS = computed(() => {
if (process.client) {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
return false;
});
const isAndroid = computed(() => {
if (process.client) {
return /Android/.test(navigator.userAgent);
}
return false;
});
const isStandalone = computed(() => {
if (process.client) {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
return false;
});
// Install messages based on platform
const getInstallMessage = () => {
if (isIOS.value) {
return 'Tap Share → Add to Home Screen to install';
} else if (isAndroid.value) {
return 'Add to your home screen for quick access';
} else {
return 'Install this app for a better experience';
}
};
// PWA installation logic
const installPWA = async () => {
if (!deferredPrompt) return;
installing.value = true;
try {
// Show the install prompt
await deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await deferredPrompt.userChoice;
console.log(`PWA install prompt outcome: ${outcome}`);
if (outcome === 'accepted') {
console.log('✅ PWA installation accepted');
showBanner.value = false;
localStorage.setItem('pwa-install-dismissed', 'true');
}
// Clear the deferredPrompt
deferredPrompt = null;
canInstall.value = false;
} catch (error) {
console.error('❌ PWA installation error:', error);
} finally {
installing.value = false;
}
};
const dismissBanner = () => {
showBanner.value = false;
localStorage.setItem('pwa-install-dismissed', 'true');
localStorage.setItem('pwa-install-dismissed-date', new Date().toISOString());
};
const shouldShowBanner = () => {
// Don't show if already dismissed recently (within 7 days)
const dismissedDate = localStorage.getItem('pwa-install-dismissed-date');
if (dismissedDate) {
const daysSinceDismissed = (Date.now() - new Date(dismissedDate).getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceDismissed < 7) {
return false;
}
}
// Don't show if permanently dismissed
if (localStorage.getItem('pwa-install-dismissed') === 'true' && !dismissedDate) {
return false;
}
// Don't show if already installed
if (isStandalone.value) {
return false;
}
return true;
};
// Setup event listeners
onMounted(() => {
if (!process.client) return;
installMessage.value = getInstallMessage();
// Listen for the beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e: Event) => {
console.log('🔔 PWA install prompt available');
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Save the event so it can be triggered later
deferredPrompt = e as BeforeInstallPromptEvent;
canInstall.value = true;
// Show banner if conditions are met
if (shouldShowBanner()) {
showBanner.value = true;
}
});
// Listen for successful installation
window.addEventListener('appinstalled', () => {
console.log('✅ PWA was installed successfully');
showBanner.value = false;
deferredPrompt = null;
canInstall.value = false;
});
// For iOS devices, show banner if not installed and not dismissed
if (isIOS.value && shouldShowBanner()) {
showBanner.value = true;
}
});
</script>
<style scoped>
.pwa-install-banner {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 1000;
max-width: 400px;
margin: 0 auto;
border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
background: #a31515 !important; /* Solid MonacoUSA red */
background-image: none !important; /* Remove any gradients */
}
@media (max-width: 600px) {
.pwa-install-banner {
left: 16px;
right: 16px;
bottom: 16px;
}
}
/* Animation */
.pwa-install-banner {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,626 @@
<template>
<div class="phone-input-wrapper" :class="{ 'phone-input-wrapper--mobile': mobileDetection.isMobile }">
<v-text-field
v-model="localNumber"
:label="label"
:placeholder="placeholder"
:error="error"
:error-messages="errorMessage"
:hint="helpText"
:persistent-hint="!!helpText"
:required="required"
:disabled="disabled"
variant="outlined"
:density="mobileDetection.isMobile ? 'default' : 'comfortable'"
class="phone-text-field"
@input="handleInput"
@blur="handleBlur"
>
<template #prepend-inner>
<!-- Country Selector -->
<v-menu
v-model="dropdownOpen"
:close-on-content-click="false"
location="bottom start"
:offset="4"
:min-width="mobileDetection.isMobile ? '90vw' : '280'"
:transition="mobileDetection.isMobile ? 'none' : 'fade-transition'"
:no-click-animation="true"
:persistent="mobileDetection.isMobile"
:attach="false"
>
<template #activator="{ props: menuProps }">
<div
v-bind="menuProps"
class="country-selector"
:class="{
'country-selector--open': dropdownOpen,
'country-selector--mobile': mobileDetection.isMobile
}"
>
<img
:src="flagUrl"
:alt="`${selectedCountry.name} flag`"
class="country-flag"
@error="handleFlagError"
/>
<span class="country-code">{{ selectedCountry.dialCode }}</span>
<v-icon
:size="mobileDetection.isMobile ? 18 : 16"
class="dropdown-icon"
:class="{ 'dropdown-icon--rotated': dropdownOpen }"
>
mdi-chevron-down
</v-icon>
</div>
</template>
<!-- Dropdown Content -->
<v-card
class="country-dropdown"
:class="{ 'country-dropdown--mobile': mobileDetection.isMobile }"
:elevation="mobileDetection.isMobile ? 24 : 8"
>
<!-- Mobile Header -->
<div v-if="mobileDetection.isMobile" class="mobile-header">
<h3 class="mobile-title">Select Country</h3>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="closeDropdown"
class="close-btn"
/>
</div>
<!-- Search Bar -->
<div class="search-container">
<v-text-field
v-model="searchQuery"
placeholder="Search countries..."
variant="outlined"
:density="mobileDetection.isMobile ? 'default' : 'compact'"
prepend-inner-icon="mdi-magnify"
hide-details
class="search-input"
:autofocus="!mobileDetection.isMobile"
clearable
/>
</div>
<!-- Country List -->
<v-list
class="country-list"
:class="{ 'country-list--mobile': mobileDetection.isMobile }"
:density="mobileDetection.isMobile ? 'default' : 'compact'"
>
<v-list-item
v-for="country in filteredCountries"
:key="country.iso2"
:class="{
'country-item': true,
'country-item--selected': country.iso2 === selectedCountry.iso2,
'country-item--preferred': isPreferredCountry(country.iso2),
'country-item--mobile': mobileDetection.isMobile
}"
@click="selectCountry(country)"
:ripple="mobileDetection.isMobile"
>
<template #prepend>
<img
:src="getCountryFlagUrl(country.iso2)"
:alt="`${country.name} flag`"
class="list-flag"
:class="{ 'list-flag--mobile': mobileDetection.isMobile }"
@error="handleFlagError"
/>
</template>
<v-list-item-title
class="country-name"
:class="{ 'country-name--mobile': mobileDetection.isMobile }"
>
{{ country.name }}
</v-list-item-title>
<template #append>
<span
class="dial-code"
:class="{ 'dial-code--mobile': mobileDetection.isMobile }"
>
{{ country.dialCode }}
</span>
</template>
</v-list-item>
</v-list>
<!-- Mobile Footer -->
<div v-if="mobileDetection.isMobile" class="mobile-footer">
<v-btn
block
variant="text"
@click="closeDropdown"
class="cancel-btn"
>
Cancel
</v-btn>
</div>
</v-card>
</v-menu>
</template>
</v-text-field>
</div>
</template>
<script setup lang="ts">
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
import { getPhoneCountriesWithPreferred, searchPhoneCountries, getPhoneCountryByCode, type PhoneCountry } from '~/utils/phone-countries';
interface Props {
modelValue?: string;
label?: string;
placeholder?: string;
error?: boolean;
errorMessage?: string;
helpText?: string;
required?: boolean;
disabled?: boolean;
defaultCountry?: string;
preferredCountries?: string[];
}
interface Emits {
(e: 'update:modelValue', value: string): void;
(e: 'country-changed', country: PhoneCountry): void;
(e: 'phone-data', data: { number: string; isValid: boolean; country: PhoneCountry }): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: 'Phone number',
error: false,
required: false,
disabled: false,
defaultCountry: 'MC',
preferredCountries: () => ['MC', 'FR', 'US', 'IT', 'CH']
});
const emit = defineEmits<Emits>();
// Simple mobile detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
// Initialize mobile detection
onMounted(() => {
if (process.client) {
const userAgent = navigator.userAgent;
isMobile.value = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
isMobileSafari.value = /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
}
});
// Create computed-like object for template compatibility
const mobileDetection = computed(() => ({
isMobile: isMobile.value,
isMobileSafari: isMobileSafari.value
}));
// Get comprehensive countries list
const countries = getPhoneCountriesWithPreferred(props.preferredCountries);
// Reactive state
const dropdownOpen = ref(false);
const searchQuery = ref('');
const localNumber = ref('');
const selectedCountry = ref<PhoneCountry>(
getPhoneCountryByCode(props.defaultCountry) || countries[0]
);
// Computed
const flagUrl = computed(() => getCountryFlagUrl(selectedCountry.value.iso2));
const filteredCountries = computed(() => {
return searchPhoneCountries(searchQuery.value, props.preferredCountries);
});
// Methods
const getCountryFlagUrl = (iso2: string) => {
return `https://flagcdn.com/24x18/${iso2.toLowerCase()}.png`;
};
const isPreferredCountry = (iso2: string) => {
return props.preferredCountries.includes(iso2);
};
const selectCountry = (country: PhoneCountry) => {
selectedCountry.value = country;
dropdownOpen.value = false;
searchQuery.value = ''; // Clear search on selection
emit('country-changed', country);
// Reformat existing number with new country
if (localNumber.value) {
handleInput();
}
};
const handleInput = () => {
const rawInput = localNumber.value;
// Create full international number
const fullNumber = selectedCountry.value.dialCode + rawInput.replace(/\D/g, '');
try {
// Parse and validate
const phoneNumber = parsePhoneNumber(fullNumber);
const isValid = phoneNumber?.isValid() || false;
// Format for display (national format)
if (phoneNumber && isValid) {
const formatter = new AsYouType(selectedCountry.value.iso2 as any);
const formatted = formatter.input(rawInput);
localNumber.value = formatted;
}
// Emit data
emit('update:modelValue', fullNumber);
emit('phone-data', {
number: fullNumber,
isValid,
country: selectedCountry.value
});
} catch (error) {
// Handle invalid numbers gracefully
emit('update:modelValue', fullNumber);
emit('phone-data', {
number: fullNumber,
isValid: false,
country: selectedCountry.value
});
}
};
const handleBlur = () => {
// Additional formatting on blur if needed
};
const handleFlagError = (event: Event) => {
// Fallback to a default flag or hide image
const img = event.target as HTMLImageElement;
img.style.display = 'none';
};
// Mobile-specific handlers
const closeDropdown = () => {
dropdownOpen.value = false;
searchQuery.value = '';
};
// Initialize from modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue && newValue !== selectedCountry.value.dialCode + localNumber.value.replace(/\D/g, '')) {
try {
const phoneNumber = parsePhoneNumber(newValue);
if (phoneNumber) {
// Find matching country
const matchingCountry = countries.find(c =>
c.dialCode === '+' + phoneNumber.countryCallingCode
);
if (matchingCountry) {
selectedCountry.value = matchingCountry;
}
// Set local number (national format)
localNumber.value = phoneNumber.formatNational().replace(phoneNumber.countryCallingCode, '').trim();
}
} catch (error) {
// Handle invalid initial value
localNumber.value = newValue;
}
}
}, { immediate: true });
// Clean up search query when dropdown closes
watch(dropdownOpen, (isOpen) => {
if (!isOpen) {
// Clear search after a small delay to allow selection to complete
setTimeout(() => {
searchQuery.value = '';
}, 100);
}
});
// Component initialization
onMounted(() => {
console.log('[PhoneInputWrapper] Initialized with device info:', {
isMobile: isMobile.value,
isMobileSafari: isMobileSafari.value
});
});
</script>
<style scoped>
.phone-input-wrapper {
width: 100%;
}
.country-selector {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(var(--v-theme-surface), 1);
border: 1px solid transparent;
margin-right: 8px;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.country-selector:hover {
background: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.24);
}
.country-selector--open {
background: rgba(var(--v-theme-primary), 0.12);
border-color: rgba(var(--v-theme-primary), 0.48);
}
.country-flag {
width: 24px;
height: 18px;
border-radius: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
object-fit: cover;
}
.country-code {
font-size: 0.875rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
min-width: 32px;
}
.dropdown-icon {
transition: transform 0.2s ease;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
.dropdown-icon--rotated {
transform: rotate(180deg);
}
/* Dropdown Styling */
.country-dropdown {
min-width: 280px;
max-width: 320px;
max-height: 400px;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-container {
padding: 12px;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-surface), 1);
}
.search-input :deep(.v-field) {
background: rgba(var(--v-theme-surface), 1);
}
/* Country List */
.country-list {
flex: 1;
max-height: 300px;
overflow-y: auto;
background: rgba(var(--v-theme-surface), 1);
-webkit-overflow-scrolling: touch;
}
.country-list::-webkit-scrollbar {
width: 6px;
}
.country-list::-webkit-scrollbar-track {
background: transparent;
}
.country-list::-webkit-scrollbar-thumb {
background: rgba(var(--v-theme-primary), 0.3);
border-radius: 3px;
}
.country-item {
cursor: pointer;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.country-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
}
.country-item--selected {
background: rgba(var(--v-theme-primary), 0.12) !important;
border-left-color: rgb(var(--v-theme-primary));
font-weight: 600;
}
.country-item--preferred {
background: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
.list-flag {
width: 20px;
height: 15px;
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
object-fit: cover;
}
.country-name {
font-size: 0.875rem;
font-weight: 500;
}
.dial-code {
font-size: 0.8125rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-family: 'Roboto Mono', monospace;
}
/* Mobile Header */
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-primary), 0.04);
}
.mobile-title {
font-size: 1.125rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
margin: 0;
}
.close-btn {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
}
/* Mobile Footer */
.mobile-footer {
padding: 16px 20px;
border-top: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-surface), 1);
}
.cancel-btn {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
}
/* Mobile-specific styling */
.phone-input-wrapper--mobile {
position: relative;
}
.country-selector--mobile {
padding: 6px 10px;
margin-right: 6px;
border-radius: 8px;
min-height: 44px; /* Touch-friendly size */
align-items: center;
-webkit-tap-highlight-color: transparent;
}
.country-selector--mobile:active {
background: rgba(var(--v-theme-primary), 0.16);
}
.country-dropdown--mobile {
width: 90vw !important;
max-width: 400px !important;
max-height: 70vh !important;
}
.country-list--mobile {
max-height: calc(50vh - 120px) !important;
-webkit-overflow-scrolling: touch;
}
.country-item--mobile {
min-height: 56px !important;
padding: 12px 20px !important;
border-left-width: 4px !important;
-webkit-tap-highlight-color: transparent;
}
.country-item--mobile:active {
background: rgba(var(--v-theme-primary), 0.16) !important;
}
.list-flag--mobile {
width: 24px !important;
height: 18px !important;
}
.country-name--mobile {
font-size: 1rem !important;
font-weight: 500 !important;
}
.dial-code--mobile {
font-size: 0.9375rem !important;
font-weight: 600 !important;
}
/* Touch-friendly input field */
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field) {
min-height: 56px !important;
}
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on iOS */
padding: 16px !important;
}
/* Responsive Breakpoints */
@media (max-width: 768px) {
.country-dropdown {
min-width: 260px;
max-width: 300px;
}
.country-list {
max-height: 250px;
}
.country-selector {
min-height: 48px;
padding: 6px 10px;
}
.search-input :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom */
}
}
/* iOS specific fixes */
@supports (-webkit-touch-callout: none) {
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on focus */
-webkit-appearance: none;
}
.search-input :deep(.v-field__input) {
font-size: 16px !important;
-webkit-appearance: none;
}
.country-list {
-webkit-overflow-scrolling: touch;
}
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
.country-item,
.country-selector,
.dropdown-icon {
transition: none !important;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<v-avatar
:size="avatarSize"
:color="showInitials ? backgroundColor : 'grey-lighten-2'"
:class="avatarClass"
>
<!-- Loading state -->
<v-progress-circular
v-if="loading"
:size="iconSize"
indeterminate
color="white"
/>
<!-- Profile image -->
<v-img
v-else-if="imageUrl && !imageError && !loading"
:src="imageUrl"
:alt="altText"
cover
@error="handleImageError"
@load="handleImageLoad"
:class="imageClass"
/>
<!-- Initials fallback -->
<span
v-else-if="initials && !loading"
:class="['text-white font-weight-bold', initialsClass]"
:style="{ fontSize: initialsSize }"
>
{{ initials }}
</span>
<!-- Icon fallback -->
<v-icon
v-else
:size="iconSize"
color="grey-darken-2"
>
mdi-account
</v-icon>
</v-avatar>
</template>
<script setup lang="ts">
import { generateInitials, generateAvatarColor } from '~/utils/client-utils';
interface Props {
memberId?: string;
memberName?: string;
firstName?: string;
lastName?: string;
size?: 'small' | 'medium' | 'large';
lazy?: boolean;
clickable?: boolean;
showBorder?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
lazy: true,
clickable: false,
showBorder: false
});
const emit = defineEmits<{
click: [];
imageLoaded: [];
imageError: [error: string];
}>();
// Reactive state
const loading = ref(false);
const imageError = ref(false);
const imageUrl = ref<string | null>(null);
const isVisible = ref(false);
// Computed properties
const avatarSize = computed(() => {
switch (props.size) {
case 'small': return 36;
case 'medium': return 80;
case 'large': return 200;
default: return 80;
}
});
const iconSize = computed(() => {
switch (props.size) {
case 'small': return 20;
case 'medium': return 40;
case 'large': return 100;
default: return 40;
}
});
const initialsSize = computed(() => {
switch (props.size) {
case 'small': return '14px';
case 'medium': return '28px';
case 'large': return '72px';
default: return '28px';
}
});
const initials = computed(() => {
if (props.firstName && props.lastName) {
return generateInitials(props.firstName, props.lastName);
}
if (props.memberName) {
return generateInitials(undefined, undefined, props.memberName);
}
return '?';
});
const backgroundColor = computed(() => {
const name = props.memberName || `${props.firstName} ${props.lastName}`.trim();
return name ? generateAvatarColor(name) : '#9e9e9e';
});
const showInitials = computed(() => {
return !loading.value && !imageUrl.value && initials.value !== '?';
});
const altText = computed(() => {
return props.memberName || `${props.firstName} ${props.lastName}`.trim() || 'Profile';
});
const avatarClass = computed(() => [
{
'cursor-pointer': props.clickable,
'elevation-2': props.showBorder,
'profile-avatar--border': props.showBorder
}
]);
const imageClass = computed(() => [
'profile-avatar__image',
{
'profile-avatar__image--loaded': !loading.value
}
]);
const initialsClass = computed(() => [
'profile-avatar__initials',
{
'text-h6': props.size === 'small',
'text-h4': props.size === 'medium',
'text-h1': props.size === 'large'
}
]);
// Methods
const loadProfileImage = async () => {
if (!props.memberId || loading.value) {
return;
}
try {
loading.value = true;
imageError.value = false;
const sizeParam = props.size === 'small' ? 'small' :
props.size === 'large' ? 'medium' : 'medium'; // Use medium for both medium and large
const response = await $fetch(`/api/profile/image/${props.memberId}/${sizeParam}`);
if (response.success && response.imageUrl) {
// Pre-load the image to ensure it's valid
const img = new Image();
img.onload = () => {
imageUrl.value = response.imageUrl;
loading.value = false;
emit('imageLoaded');
};
img.onerror = () => {
handleImageError();
};
img.src = response.imageUrl;
} else {
loading.value = false;
}
} catch (error: any) {
console.warn(`Profile image not found for member ${props.memberId}:`, error.message);
loading.value = false;
imageError.value = true;
}
};
const handleImageError = () => {
loading.value = false;
imageError.value = true;
imageUrl.value = null;
emit('imageError', 'Failed to load profile image');
};
const handleImageLoad = () => {
loading.value = false;
emit('imageLoaded');
};
const handleClick = () => {
if (props.clickable) {
emit('click');
}
};
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = null;
const avatarRef = ref<HTMLElement>();
const initIntersectionObserver = () => {
if (!props.lazy || !avatarRef.value || typeof IntersectionObserver === 'undefined') {
// Load immediately if not lazy or no intersection observer support
isVisible.value = true;
loadProfileImage();
return;
}
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !isVisible.value) {
isVisible.value = true;
loadProfileImage();
// Stop observing once visible
if (observer && avatarRef.value) {
observer.unobserve(avatarRef.value);
}
}
},
{
rootMargin: '50px',
threshold: 0.1
}
);
observer.observe(avatarRef.value);
};
// Watch for prop changes
watch(
() => props.memberId,
(newMemberId) => {
if (newMemberId) {
imageUrl.value = null;
imageError.value = false;
if (isVisible.value || !props.lazy) {
loadProfileImage();
}
} else {
imageUrl.value = null;
imageError.value = false;
loading.value = false;
}
}
);
// Lifecycle
onMounted(() => {
if (props.memberId) {
if (props.lazy) {
nextTick(() => {
initIntersectionObserver();
});
} else {
loadProfileImage();
}
}
});
onUnmounted(() => {
if (observer && avatarRef.value) {
observer.unobserve(avatarRef.value);
observer = null;
}
});
</script>
<style scoped>
.profile-avatar--border {
border: 2px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.profile-avatar__image {
transition: opacity 0.3s ease-in-out;
opacity: 0;
}
.profile-avatar__image--loaded {
opacity: 1;
}
.profile-avatar__initials {
user-select: none;
letter-spacing: -0.5px;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-pointer:hover {
transform: scale(1.05);
transition: transform 0.2s ease-in-out;
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="600"
persistent
scrollable
>
<v-card class="registration-success-card">
<v-card-title class="d-flex align-center pa-6 bg-success">
<v-icon class="mr-3 text-white" size="32">mdi-check-circle</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
Registration Successful!
</h2>
</v-card-title>
<v-card-text class="pa-6">
<!-- Success Message -->
<div class="text-center mb-6">
<v-avatar size="80" class="mb-4" color="success">
<v-icon size="48" color="white">mdi-account-check</v-icon>
</v-avatar>
<h3 class="text-h6 mb-3">
Welcome to MonacoUSA Association!
</h3>
<p class="text-body-1 mb-2">
Your membership application has been submitted successfully.
</p>
<v-chip
v-if="memberData?.memberId"
color="success"
variant="outlined"
size="small"
class="ma-1"
>
<v-icon start size="14">mdi-identifier</v-icon>
Member ID: {{ memberData.memberId }}
</v-chip>
</div>
<v-divider class="mb-6" />
<!-- Next Steps -->
<div class="mb-6">
<h4 class="text-h6 mb-3 d-flex align-center">
<v-icon class="mr-2" color="primary">mdi-format-list-checks</v-icon>
Next Steps
</h4>
<v-timeline density="compact" side="end">
<v-timeline-item
dot-color="success"
size="small"
icon="mdi-check"
>
<template #opposite>
<strong class="text-body-2">Step 1</strong>
</template>
<div class="mb-2">
<strong class="text-body-2">Registration Complete</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
Your account has been created in our system.
</p>
</div>
</v-timeline-item>
<v-timeline-item
dot-color="warning"
size="small"
icon="mdi-email"
>
<template #opposite>
<strong class="text-body-2">Step 2</strong>
</template>
<div class="mb-2">
<strong class="text-body-2">Check Your Email</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
We've sent a verification email to <strong>{{ memberData?.email }}</strong>.
Click the link in the email to verify your account and set your password.
</p>
</div>
</v-timeline-item>
<v-timeline-item
dot-color="info"
size="small"
icon="mdi-bank"
>
<template #opposite>
<strong class="text-body-2">Step 3</strong>
</template>
<div class="mb-2">
<strong class="text-body-2">Pay Membership Dues</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
Transfer your annual membership dues using the banking details below.
</p>
</div>
</v-timeline-item>
<v-timeline-item
dot-color="success"
size="small"
icon="mdi-account-check"
>
<template #opposite>
<strong class="text-body-2">Step 4</strong>
</template>
<div>
<strong class="text-body-2">Account Activation</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
Once payment is verified, your account will be activated and you can access the member portal.
</p>
</div>
</v-timeline-item>
</v-timeline>
</div>
<v-divider class="mb-6" />
<!-- Payment Information -->
<div class="payment-info mb-6">
<h4 class="text-h6 mb-3 d-flex align-center">
<v-icon class="mr-2" color="primary">mdi-bank</v-icon>
Payment Instructions
</h4>
<v-card variant="outlined" class="pa-4" color="primary-lighten-5">
<v-row dense>
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">Amount:</span>
</v-col>
<v-col cols="12" sm="8">
<span class="text-body-1 font-weight-bold">€{{ paymentInfo?.membershipFee || '50' }}/year</span>
</v-col>
</v-row>
<v-row dense v-if="paymentInfo?.iban" class="mb-2">
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">IBAN:</span>
</v-col>
<v-col cols="12" sm="8">
<div class="d-flex align-center">
<span class="text-body-2 font-family-monospace mr-2">{{ paymentInfo.iban }}</span>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
@click="copyToClipboard(paymentInfo.iban)"
:title="'Copy IBAN'"
/>
</div>
</v-col>
</v-row>
<v-row dense v-if="paymentInfo?.accountHolder">
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">Account:</span>
</v-col>
<v-col cols="12" sm="8">
<span class="text-body-2">{{ paymentInfo.accountHolder }}</span>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">Reference:</span>
</v-col>
<v-col cols="12" sm="8">
<span class="text-body-2">Member {{ memberData?.memberId || 'Registration' }}</span>
</v-col>
</v-row>
</v-card>
</div>
<!-- Important Notes -->
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<template #title>Important Notes</template>
<ul class="text-body-2 ml-4">
<li>Check your spam folder if you don't receive the verification email within 10 minutes</li>
<li>Your membership will be activated within 2-3 business days after payment verification</li>
<li>Contact our administrators if you need assistance with the verification process</li>
</ul>
</v-alert>
<!-- Copy Notification -->
<v-snackbar
v-model="showCopyNotification"
timeout="2000"
color="success"
location="bottom"
>
IBAN copied to clipboard!
</v-snackbar>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="outlined"
@click="closeDialog"
class="mr-3"
>
Close
</v-btn>
<v-btn
color="primary"
@click="goToLogin"
>
<v-icon start>mdi-login</v-icon>
Go to Login
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
memberData?: {
memberId: string;
email: string;
};
paymentInfo?: {
membershipFee: number;
iban: string;
accountHolder: string;
};
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'go-to-login'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const showCopyNotification = ref(false);
// Methods
const closeDialog = () => {
emit('update:model-value', false);
};
const goToLogin = () => {
emit('go-to-login');
closeDialog();
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showCopyNotification.value = true;
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showCopyNotification.value = true;
}
};
</script>
<style scoped>
.registration-success-card {
border-radius: 16px !important;
}
.bg-success {
background: linear-gradient(135deg, #4caf50 0%, #66bb6a 100%) !important;
}
.payment-info .v-card {
border-radius: 12px !important;
}
/* Timeline styling */
.v-timeline :deep(.v-timeline-item__body) {
padding-bottom: 16px;
}
.v-timeline :deep(.v-timeline-item__opposite) {
padding-inline-end: 16px;
}
/* Copy button styling */
.v-btn--size-x-small {
min-width: 24px !important;
width: 24px;
height: 24px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
.v-timeline :deep(.v-timeline-item__opposite) {
display: none;
}
}
/* Print styles (if user wants to print) */
@media print {
.v-card-actions {
display: none;
}
.payment-info .v-card {
border: 2px solid #ddd !important;
}
}
</style>

View File

@@ -0,0 +1,424 @@
<template>
<v-card
v-if="event"
elevation="3"
class="upcoming-event-banner ma-2"
:color="eventTypeColor"
theme="dark"
rounded="xl"
>
<v-card-text class="pa-4">
<!-- Mobile Layout -->
<div v-if="$vuetify.display.mobile" class="mobile-banner-layout">
<!-- Header -->
<div class="d-flex align-center mb-3">
<v-avatar :color="eventTypeColor" class="me-3" size="40">
<v-icon :icon="eventTypeIcon" size="20"></v-icon>
</v-avatar>
<div class="flex-grow-1">
<h3 class="text-h6 font-weight-bold text-truncate">{{ event.title }}</h3>
<div class="text-caption opacity-90">{{ eventTypeLabel }}</div>
</div>
</div>
<!-- Event Details -->
<div class="mb-3">
<div class="d-flex align-center mb-1">
<v-icon size="16" class="me-2">mdi-calendar-clock</v-icon>
<span class="text-body-2">{{ formatEventDate }}</span>
</div>
<div v-if="event.location" class="d-flex align-center mb-1">
<v-icon size="16" class="me-2">mdi-map-marker</v-icon>
<span class="text-body-2 text-truncate">{{ event.location }}</span>
</div>
<div class="d-flex align-center justify-space-between">
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
<v-icon size="16" class="me-2">mdi-currency-eur</v-icon>
<span class="text-body-2">{{ priceDisplay }}</span>
</div>
<div v-if="event.max_attendees" class="d-flex align-center">
<v-icon size="16" class="me-2">mdi-account-group</v-icon>
<span class="text-body-2">{{ event.current_attendees || 0 }}/{{ event.max_attendees }} attending</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex ga-2">
<v-btn
@click="handleQuickRSVP"
:color="userRSVP ? 'success' : 'white'"
:variant="userRSVP ? 'elevated' : 'outlined'"
size="small"
class="text-none flex-grow-1"
rounded="lg"
>
<v-icon start size="18">
{{ userRSVP ? 'mdi-check' : 'mdi-plus' }}
</v-icon>
{{ userRSVP ? 'Attending' : 'Quick RSVP' }}
</v-btn>
<v-btn
@click="handleViewDetails"
color="white"
variant="outlined"
size="small"
class="text-none"
rounded="lg"
icon
>
<v-icon size="18">mdi-eye</v-icon>
</v-btn>
</div>
</div>
<!-- Desktop Layout -->
<v-row v-else align="center" no-gutters>
<v-col cols="12" md="8">
<div class="d-flex align-center mb-2">
<v-avatar :color="eventTypeColor" class="me-3" size="32">
<v-icon :icon="eventTypeIcon" size="16"></v-icon>
</v-avatar>
<div>
<h3 class="text-h6 font-weight-bold">{{ event.title }}</h3>
<div class="text-caption opacity-90">{{ eventTypeLabel }}</div>
</div>
</div>
<div class="d-flex align-center flex-wrap ga-4">
<div class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-calendar-clock</v-icon>
<span class="text-body-2">{{ formatEventDate }}</span>
</div>
<div v-if="event.location" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-map-marker</v-icon>
<span class="text-body-2">{{ event.location }}</span>
</div>
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-currency-eur</v-icon>
<span class="text-body-2">{{ priceDisplay }}</span>
</div>
<div v-if="event.max_attendees" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-account-group</v-icon>
<span class="text-body-2">{{ event.current_attendees || 0 }}/{{ event.max_attendees }} attending</span>
</div>
</div>
</v-col>
<v-col cols="12" md="4" class="text-end">
<div class="d-flex ga-2 justify-end">
<v-btn
@click="handleQuickRSVP"
:color="userRSVP ? 'success' : 'white'"
:variant="userRSVP ? 'elevated' : 'outlined'"
size="small"
class="text-none"
rounded="lg"
>
<v-icon start size="small">
{{ userRSVP ? 'mdi-check' : 'mdi-plus' }}
</v-icon>
{{ userRSVP ? 'Attending' : 'Quick RSVP' }}
</v-btn>
<v-btn
@click="handleViewDetails"
color="white"
variant="outlined"
size="small"
class="text-none"
rounded="lg"
>
<v-icon start size="small">mdi-eye</v-icon>
View Details
</v-btn>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { Event, EventRSVP } from '~/utils/types';
// Helper functions to replace date-fns
const formatDate = (date: Date, formatStr: string): string => {
const options: Intl.DateTimeFormatOptions = {};
if (formatStr === 'HH:mm') {
options.hour = '2-digit';
options.minute = '2-digit';
options.hour12 = false;
} else if (formatStr === 'EEE, MMM d • HH:mm') {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
}) + ' • ' + date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} else if (formatStr === 'MMM d') {
options.month = 'short';
options.day = 'numeric';
}
return date.toLocaleDateString('en-US', options);
};
const addDays = (date: Date, days: number): Date => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
const isWithinInterval = (date: Date, interval: { start: Date; end: Date }): boolean => {
return date >= interval.start && date <= interval.end;
};
interface Props {
event: Event | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'event-click': [event: Event];
'quick-rsvp': [event: Event];
}>();
// Computed properties
const userRSVP = computed((): EventRSVP | null => {
return props.event?.user_rsvp || null;
});
const canRSVP = computed(() => {
if (!props.event) return false;
const eventDate = new Date(props.event.start_datetime);
const now = new Date();
return eventDate > now; // Can RSVP to future events
});
const eventTypeIcon = computed(() => {
if (!props.event) return 'mdi-calendar';
const icons = {
'meeting': 'mdi-account-group',
'social': 'mdi-party-popper',
'fundraiser': 'mdi-heart',
'workshop': 'mdi-school',
'board-only': 'mdi-shield-account'
};
return icons[props.event.event_type as keyof typeof icons] || 'mdi-calendar';
});
const eventTypeColor = computed(() => {
if (!props.event) return 'primary';
// Check if event is soon (within 24 hours)
const eventDate = new Date(props.event.start_datetime);
const now = new Date();
const isSoon = isWithinInterval(eventDate, {
start: now,
end: addDays(now, 1)
});
if (isSoon) return 'warning';
const colors = {
'meeting': 'blue',
'social': 'green',
'fundraiser': 'orange',
'workshop': 'purple',
'board-only': 'red'
};
return colors[props.event.event_type as keyof typeof colors] || 'primary';
});
const eventTypeLabel = computed(() => {
if (!props.event) return '';
const labels = {
'meeting': 'Meeting',
'social': 'Social Event',
'fundraiser': 'Fundraiser',
'workshop': 'Workshop',
'board-only': 'Board Only'
};
return labels[props.event.event_type as keyof typeof labels] || 'Event';
});
const iconColor = computed(() => {
// Use white for better contrast on colored backgrounds
return 'white';
});
const memberPrice = computed(() => props.event?.cost_members || '');
const nonMemberPrice = computed(() => props.event?.cost_non_members || '');
const priceDisplay = computed(() => {
if (!props.event || props.event.is_paid !== 'true') return '';
const memberCost = props.event.cost_members;
const nonMemberCost = props.event.cost_non_members;
if (memberCost && nonMemberCost) {
// Show both prices
return `${memberCost} (Members) | €${nonMemberCost} (Non-Members)`;
} else if (memberCost) {
// Only member price
return `${memberCost} (Members)`;
} else if (nonMemberCost) {
// Only non-member price
return `${nonMemberCost}`;
}
return '';
});
const formatEventDate = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
const now = new Date();
// Different formats based on timing
if (startDate.toDateString() === now.toDateString()) {
return `Today at ${formatDate(startDate, 'HH:mm')}`;
}
if (startDate.toDateString() === addDays(now, 1).toDateString()) {
return `Tomorrow at ${formatDate(startDate, 'HH:mm')}`;
}
if (startDate.toDateString() === endDate.toDateString()) {
return formatDate(startDate, 'EEE, MMM d • HH:mm');
}
return `${formatDate(startDate, 'MMM d')} - ${formatDate(endDate, 'MMM d')}`;
});
const capacityInfo = computed(() => {
if (!props.event?.max_attendees) return '';
const current = props.event.current_attendees || 0;
const max = parseInt(props.event.max_attendees);
return `${current}/${max} attending`;
});
const rsvpStatusColor = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'success';
case 'waitlist': return 'warning';
case 'declined': return 'error';
default: return 'info';
}
});
const rsvpStatusIcon = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'mdi-check';
case 'waitlist': return 'mdi-clock';
case 'declined': return 'mdi-close';
default: return 'mdi-help';
}
});
const rsvpStatusText = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'Attending';
case 'waitlist': return 'Waitlisted';
case 'declined': return 'Declined';
default: return 'Unknown';
}
});
const quickRSVPColor = computed(() => {
return eventTypeColor.value === 'warning' ? 'success' : 'white';
});
// Methods
const handleViewEvent = () => {
if (props.event) {
emit('event-click', props.event);
}
};
const handleViewDetails = () => {
if (props.event) {
emit('event-click', props.event);
}
};
const handleQuickRSVP = () => {
if (props.event) {
emit('quick-rsvp', props.event);
}
};
</script>
<style scoped>
.v-banner :deep(.v-banner__wrapper) {
padding: 16px 24px;
}
.v-banner :deep(.v-banner__prepend) {
margin-inline-end: 16px;
}
.v-banner :deep(.v-banner__actions) {
margin-inline-start: 16px;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.v-banner :deep(.v-banner__wrapper) {
padding: 12px 16px;
}
.v-banner :deep(.v-banner__prepend) {
margin-inline-end: 12px;
}
.v-banner :deep(.v-banner__actions) {
margin-inline-start: 0;
margin-top: 8px;
}
.text-h6 {
font-size: 1.1rem !important;
}
}
/* Ensure proper spacing on different screen sizes */
.ga-4 {
gap: 16px;
}
.ga-2 {
gap: 8px;
}
@media (max-width: 600px) {
.ga-4 {
gap: 8px;
}
}
</style>

View File

@@ -0,0 +1,725 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card v-if="member" class="member-modal">
<!-- Hero Header with Profile -->
<div class="member-hero-header">
<v-btn
icon
variant="text"
color="white"
class="close-btn"
@click="$emit('update:model-value', false)"
>
<v-icon>mdi-close</v-icon>
</v-btn>
<div class="hero-content">
<ProfileAvatar
:member-id="member.member_id"
:member-name="member.FullName || `${member.first_name} ${member.last_name}`"
:first-name="member.first_name"
:last-name="member.last_name"
size="120"
class="mb-4 elevation-4"
clickable
show-border
@click="openImageLightbox"
/>
<h1 class="text-h4 font-weight-bold text-white mb-2">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h1>
<div class="d-flex align-center justify-center gap-3 mb-3">
<div class="d-flex align-center">
<CountryFlag
v-if="member.nationality"
:country-code="member.nationality"
:show-name="false"
size="medium"
class="mr-2"
/>
<span class="text-white">
{{ getCountryName(member.nationality) || 'No nationality' }}
</span>
</div>
<v-divider vertical color="white" opacity="0.5" class="mx-2" />
<span class="text-white">
Member since {{ formatDate(member.member_since) || 'Unknown' }}
</span>
</div>
<!-- Status Badges -->
<div class="d-flex justify-center gap-2">
<v-chip
:color="statusColor"
variant="flat"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">{{ statusIcon }}</v-icon>
{{ member.membership_status }}
</v-chip>
<v-chip
:color="duesColor"
:variant="duesVariant"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">{{ duesIcon }}</v-icon>
{{ duesText }}
</v-chip>
<v-chip
v-if="member.membership_type"
color="purple"
variant="tonal"
size="small"
class="font-weight-bold"
>
{{ member.membership_type }}
</v-chip>
</div>
</div>
</div>
<!-- Quick Actions Bar -->
<div class="quick-actions-bar">
<v-btn
v-if="!member.dues_paid_this_year"
color="success"
variant="flat"
prepend-icon="mdi-cash-check"
@click="markDuesPaid"
>
Mark Dues Paid
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-pencil"
@click="$emit('edit', member)"
>
Edit Profile
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-email"
@click="sendEmail"
>
Send Email
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-phone"
:disabled="!member.phone"
@click="callPhone"
>
Call
</v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
color="primary"
variant="tonal"
icon="mdi-dots-vertical"
v-bind="props"
/>
</template>
<v-list>
<v-list-item @click="viewPaymentHistory">
<v-list-item-title>
<v-icon start>mdi-history</v-icon>
Payment History
</v-list-item-title>
</v-list-item>
<v-list-item @click="generateInvoice">
<v-list-item-title>
<v-icon start>mdi-file-document</v-icon>
Generate Invoice
</v-list-item-title>
</v-list-item>
<v-list-item @click="exportMemberData">
<v-list-item-title>
<v-icon start>mdi-download</v-icon>
Export Data
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<!-- Content Tabs -->
<v-card-text class="pa-0">
<v-tabs
v-model="activeTab"
bg-color="grey-lighten-4"
slider-color="primary"
>
<v-tab value="overview">
<v-icon start>mdi-account-details</v-icon>
Overview
</v-tab>
<v-tab value="payments">
<v-icon start>mdi-cash-multiple</v-icon>
Payments
</v-tab>
<v-tab value="activity">
<v-icon start>mdi-history</v-icon>
Activity
</v-tab>
<v-tab value="notes">
<v-icon start>mdi-note-text</v-icon>
Notes
</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab">
<!-- Overview Tab -->
<v-tabs-window-item value="overview">
<v-container>
<v-row>
<!-- Personal Information -->
<v-col cols="12" md="6">
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="primary">mdi-account</v-icon>
Personal Information
</v-card-title>
<v-card-text>
<div class="info-grid">
<div class="info-item">
<label>Full Name</label>
<p>{{ member.first_name }} {{ member.last_name }}</p>
</div>
<div class="info-item">
<label>Email</label>
<p>
<a :href="`mailto:${member.email}`" class="text-primary">
{{ member.email }}
</a>
</p>
</div>
<div class="info-item" v-if="member.phone">
<label>Phone</label>
<p>
<a :href="`tel:${member.phone}`" class="text-primary">
{{ member.FormattedPhone || member.phone }}
</a>
</p>
</div>
<div class="info-item" v-if="member.date_of_birth">
<label>Date of Birth</label>
<p>{{ formatDate(member.date_of_birth) }}</p>
</div>
<div class="info-item" v-if="member.address">
<label>Address</label>
<p>{{ member.address }}</p>
</div>
<div class="info-item">
<label>Nationality</label>
<div class="d-flex align-center">
<CountryFlag
v-if="member.nationality"
:country-code="member.nationality"
:show-name="false"
size="small"
class="mr-2"
/>
<span>{{ getCountryName(member.nationality) || 'Not specified' }}</span>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Membership Information -->
<v-col cols="12" md="6">
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="primary">mdi-card-account-details</v-icon>
Membership Details
</v-card-title>
<v-card-text>
<div class="info-grid">
<div class="info-item">
<label>Member ID</label>
<p>{{ member.member_id }}</p>
</div>
<div class="info-item">
<label>Membership Type</label>
<v-chip :color="getMembershipColor(member.membership_type)" size="small" variant="tonal">
{{ member.membership_type }}
</v-chip>
</div>
<div class="info-item">
<label>Status</label>
<v-chip :color="statusColor" size="small" variant="flat">
{{ member.membership_status }}
</v-chip>
</div>
<div class="info-item">
<label>Member Since</label>
<p>{{ formatDate(member.member_since) || 'Not specified' }}</p>
</div>
<div class="info-item">
<label>Last Renewal</label>
<p>{{ member.last_renewal ? formatDate(member.last_renewal) : 'Never' }}</p>
</div>
<div class="info-item">
<label>Dues Status</label>
<v-chip :color="duesColor" size="small" :variant="duesVariant">
{{ duesText }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Emergency Contact -->
<v-col cols="12" v-if="member.emergency_contact">
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="error">mdi-phone-alert</v-icon>
Emergency Contact
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<div class="info-item">
<label>Name</label>
<p>{{ member.emergency_contact.name || 'Not provided' }}</p>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="info-item">
<label>Relationship</label>
<p>{{ member.emergency_contact.relationship || 'Not provided' }}</p>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="info-item">
<label>Phone</label>
<p>{{ member.emergency_contact.phone || 'Not provided' }}</p>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-tabs-window-item>
<!-- Payments Tab -->
<v-tabs-window-item value="payments">
<v-container>
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon start color="primary">mdi-cash-multiple</v-icon>
Payment History
</div>
<v-btn
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-plus"
@click="recordPayment"
>
Record Payment
</v-btn>
</v-card-title>
<v-card-text>
<v-list lines="two" class="pa-0">
<v-list-item
v-for="payment in recentPayments"
:key="payment.id"
class="px-0"
>
<template v-slot:prepend>
<v-icon :color="payment.status === 'Completed' ? 'success' : 'warning'">
{{ payment.status === 'Completed' ? 'mdi-check-circle' : 'mdi-clock-outline' }}
</v-icon>
</template>
<v-list-item-title>
${{ payment.amount }} - {{ payment.type }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(payment.date) }} {{ payment.method }}
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
:color="payment.status === 'Completed' ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ payment.status }}
</v-chip>
</template>
</v-list-item>
</v-list>
<div v-if="!recentPayments || recentPayments.length === 0" class="text-center py-8 text-medium-emphasis">
No payment history available
</div>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
<!-- Activity Tab -->
<v-tabs-window-item value="activity">
<v-container>
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="primary">mdi-history</v-icon>
Recent Activity
</v-card-title>
<v-card-text>
<v-timeline side="end" density="compact">
<v-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:dot-color="activity.color"
size="small"
>
<template v-slot:opposite>
<div class="text-caption">
{{ formatRelativeTime(activity.date) }}
</div>
</template>
<div>
<div class="font-weight-medium">{{ activity.title }}</div>
<div class="text-caption text-medium-emphasis">{{ activity.description }}</div>
</div>
</v-timeline-item>
</v-timeline>
<div v-if="!recentActivities || recentActivities.length === 0" class="text-center py-8 text-medium-emphasis">
No recent activity
</div>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
<!-- Notes Tab -->
<v-tabs-window-item value="notes">
<v-container>
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon start color="primary">mdi-note-text</v-icon>
Member Notes
</div>
<v-btn
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-plus"
@click="addNote"
>
Add Note
</v-btn>
</v-card-title>
<v-card-text>
<v-textarea
v-model="memberNotes"
label="Notes about this member"
rows="6"
variant="outlined"
placeholder="Add notes about this member..."
/>
<v-btn
color="primary"
variant="flat"
@click="saveNotes"
:disabled="!memberNotes"
>
Save Notes
</v-btn>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
<!-- Footer Actions -->
<v-card-actions class="pa-4 bg-grey-lighten-5">
<v-spacer />
<v-btn
variant="text"
@click="$emit('update:model-value', false)"
>
Close
</v-btn>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-pencil"
@click="$emit('edit', member)"
>
Edit Member
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { countries } from '~/utils/countries';
interface Props {
modelValue: boolean;
member: Member | null;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'edit', member: Member): void;
(e: 'mark-dues-paid', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// State
const activeTab = ref('overview');
const memberNotes = ref('');
const recentPayments = ref([]);
const recentActivities = ref([]);
// Computed properties
const statusColor = computed(() => {
if (!props.member) return 'default';
return props.member.membership_status === 'Active' ? 'success' : 'error';
});
const statusIcon = computed(() => {
if (!props.member) return 'mdi-account';
return props.member.membership_status === 'Active' ? 'mdi-check-circle' : 'mdi-close-circle';
});
const duesColor = computed(() => {
if (!props.member) return 'default';
if (props.member.dues_paid_this_year) return 'success';
if (props.member.dues_status === 'Overdue') return 'error';
return 'warning';
});
const duesVariant = computed(() => {
if (!props.member) return 'tonal';
return props.member.dues_paid_this_year ? 'flat' : 'tonal';
});
const duesIcon = computed(() => {
if (!props.member) return 'mdi-cash';
if (props.member.dues_paid_this_year) return 'mdi-check-circle';
if (props.member.dues_status === 'Overdue') return 'mdi-alert-circle';
return 'mdi-clock-outline';
});
const duesText = computed(() => {
if (!props.member) return 'Unknown';
if (props.member.dues_paid_this_year) return 'Dues Paid';
if (props.member.dues_status === 'Overdue') return 'Dues Overdue';
return 'Dues Due';
});
const isOverdue = computed(() => {
if (!props.member || !props.member.payment_due_date) return false;
return new Date(props.member.payment_due_date) < new Date();
});
// Methods
const getCountryName = (code: string) => {
if (!code) return null;
const country = countries.find(c => c.code === code);
return country ? country.name : code;
};
const getMembershipColor = (type: string) => {
switch (type) {
case 'VIP': return 'error';
case 'Premium': return 'warning';
case 'Lifetime': return 'purple';
default: return 'info';
}
};
const formatDate = (date: string) => {
if (!date) return 'N/A';
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) return 'N/A';
return parsedDate.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
};
const formatRelativeTime = (date: string) => {
const now = new Date();
const then = new Date(date);
const diff = now.getTime() - then.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
};
const openImageLightbox = () => {
// TODO: Implement image lightbox
};
const markDuesPaid = () => {
if (props.member) {
emit('mark-dues-paid', props.member);
}
};
const sendEmail = () => {
if (props.member) {
window.location.href = `mailto:${props.member.email}`;
}
};
const callPhone = () => {
if (props.member && props.member.phone) {
window.location.href = `tel:${props.member.phone}`;
}
};
const viewPaymentHistory = () => {
activeTab.value = 'payments';
};
const generateInvoice = () => {
// TODO: Generate invoice for member
};
const exportMemberData = () => {
// TODO: Export member data
};
const recordPayment = () => {
// TODO: Record payment for member
};
const addNote = () => {
// Focus on notes textarea
activeTab.value = 'notes';
};
const saveNotes = () => {
// TODO: Save notes to database
};
// Load member-specific data when dialog opens
watch(() => props.modelValue, (newVal) => {
if (newVal && props.member) {
// Reset to overview tab
activeTab.value = 'overview';
// Load member notes
memberNotes.value = props.member.notes || '';
// TODO: Load payment history and activities
}
});
</script>
<style scoped>
.member-modal {
overflow: hidden;
}
.member-hero-header {
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 3rem 2rem;
text-align: center;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1;
}
.hero-content {
position: relative;
z-index: 0;
}
.quick-actions-bar {
display: flex;
gap: 0.5rem;
padding: 1rem;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
overflow-x: auto;
}
.info-card {
border: 1px solid #e0e0e0;
}
.info-grid {
display: grid;
gap: 1.5rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-item label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #666;
letter-spacing: 0.5px;
}
.info-item p {
margin: 0;
font-size: 1rem;
color: #333;
}
.info-item a {
text-decoration: none;
}
.info-item a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,285 @@
<template>
<div class="activity-timeline">
<div
v-for="(item, index) in activities"
:key="item.id"
v-motion
:initial="{ opacity: 0, x: -20 }"
:visibleOnce="{
opacity: 1,
x: 0,
transition: {
delay: index * 100,
duration: 500,
type: 'spring',
stiffness: 200
}
}"
class="timeline-item"
:class="{ 'timeline-item--last': index === activities.length - 1 }"
>
<!-- Timeline Marker -->
<div
class="timeline-marker"
:class="[
`timeline-marker--${item.type}`,
{ 'timeline-marker--pulse': item.isNew }
]"
>
<v-icon
:color="getIconColor(item.type)"
size="16"
>
{{ item.icon }}
</v-icon>
</div>
<!-- Timeline Content -->
<div class="timeline-content">
<div class="timeline-header">
<h4 class="timeline-title">{{ item.title }}</h4>
<span class="timeline-time">{{ formatTime(item.timestamp) }}</span>
</div>
<p class="timeline-description">{{ item.description }}</p>
<!-- Optional metadata -->
<div v-if="item.metadata" class="timeline-metadata">
<v-chip
v-for="(meta, key) in item.metadata"
:key="key"
size="x-small"
variant="tonal"
:color="getMetaColor(key)"
class="mr-1"
>
{{ meta }}
</v-chip>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface TimelineActivity {
id: string | number;
type: 'event' | 'profile';
title: string;
description: string;
timestamp: string | Date;
icon: string;
isNew?: boolean;
metadata?: Record<string, any>;
}
interface Props {
activities: TimelineActivity[];
maxItems?: number;
}
const props = withDefaults(defineProps<Props>(), {
maxItems: 10
});
// Compute visible activities
const visibleActivities = computed(() => {
return props.activities.slice(0, props.maxItems);
});
// Get icon color based on activity type
const getIconColor = (type: string) => {
const colors: Record<string, string> = {
event: 'error',
profile: 'info'
};
return colors[type] || 'grey';
};
// Get metadata chip color
const getMetaColor = (key: string) => {
const colors: Record<string, string> = {
status: 'success',
category: 'primary',
amount: 'warning',
level: 'info'
};
return colors[key] || 'grey';
};
// Format timestamp
const formatTime = (timestamp: string | Date) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
};
</script>
<style scoped lang="scss">
.activity-timeline {
position: relative;
padding-left: 2rem;
// Vertical line
&::before {
content: '';
position: absolute;
left: 0.75rem;
top: 0.5rem;
bottom: 1rem;
width: 2px;
background: linear-gradient(
to bottom,
rgba(220, 38, 38, 0.3),
rgba(220, 38, 38, 0.1),
transparent
);
}
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
&--last {
padding-bottom: 0;
}
}
.timeline-marker {
position: absolute;
left: -1.25rem;
top: 0.125rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 2px solid;
z-index: 1;
transition: all 0.3s ease;
&--event {
border-color: rgb(220, 38, 38);
background: linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05));
}
&--profile {
border-color: rgb(59, 130, 246);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05));
}
&--pulse {
&::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid currentColor;
opacity: 0;
animation: pulse-ring 2s infinite;
}
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.3;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.timeline-content {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.6)
);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:hover {
transform: translateX(4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.timeline-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.timeline-time {
font-size: 0.75rem;
color: rgb(156, 163, 175);
white-space: nowrap;
}
.timeline-description {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
margin: 0 0 0.5rem 0;
line-height: 1.5;
}
.timeline-metadata {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
@media (max-width: 640px) {
.activity-timeline {
padding-left: 1.5rem;
}
.timeline-marker {
left: -1rem;
width: 1.25rem;
height: 1.25rem;
}
.timeline-content {
padding: 0.75rem;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="bento-grid" :class="gridClass">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
columns?: number;
gap?: 'sm' | 'md' | 'lg' | 'xl';
responsive?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
columns: 12,
gap: 'md',
responsive: true
});
const gridClass = computed(() => {
return {
[`bento-grid--cols-${props.columns}`]: true,
[`bento-grid--gap-${props.gap}`]: true,
'bento-grid--responsive': props.responsive
};
});
</script>
<style scoped lang="scss">
.bento-grid {
display: grid;
width: 100%;
// Column configurations
&--cols-12 {
grid-template-columns: repeat(12, 1fr);
}
&--cols-6 {
grid-template-columns: repeat(6, 1fr);
}
&--cols-4 {
grid-template-columns: repeat(4, 1fr);
}
&--cols-3 {
grid-template-columns: repeat(3, 1fr);
}
// Gap sizes
&--gap-sm {
gap: 0.75rem;
}
&--gap-md {
gap: 1.25rem;
}
&--gap-lg {
gap: 1.75rem;
}
&--gap-xl {
gap: 2.25rem;
}
// Responsive behavior
&--responsive {
@media (max-width: 640px) {
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 641px) and (max-width: 768px) {
grid-template-columns: repeat(6, 1fr);
}
@media (min-width: 769px) and (max-width: 1024px) {
grid-template-columns: repeat(8, 1fr);
}
}
}
// Global Bento Item Classes
:deep(.bento-item) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// Size variants
:deep(.bento-item--small) {
grid-column: span 3;
}
:deep(.bento-item--medium) {
grid-column: span 4;
}
:deep(.bento-item--large) {
grid-column: span 6;
}
:deep(.bento-item--xlarge) {
grid-column: span 8;
}
:deep(.bento-item--full) {
grid-column: span 12;
}
// Height variants
:deep(.bento-item--tall) {
grid-row: span 2;
}
:deep(.bento-item--xtall) {
grid-row: span 3;
}
// Responsive overrides
@media (max-width: 640px) {
:deep(.bento-item--small),
:deep(.bento-item--medium),
:deep(.bento-item--large),
:deep(.bento-item--xlarge) {
grid-column: span 12;
}
}
@media (min-width: 641px) and (max-width: 768px) {
:deep(.bento-item--small) {
grid-column: span 3;
}
:deep(.bento-item--medium),
:deep(.bento-item--large) {
grid-column: span 6;
}
:deep(.bento-item--xlarge) {
grid-column: span 6;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
:deep(.bento-item--small) {
grid-column: span 3;
}
:deep(.bento-item--medium) {
grid-column: span 4;
}
:deep(.bento-item--large) {
grid-column: span 6;
}
:deep(.bento-item--xlarge) {
grid-column: span 8;
}
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 400,
duration: 600,
type: 'spring',
stiffness: 200
}
}"
class="events-card"
>
<div class="events-header">
<div class="header-left">
<v-icon color="error" size="20">mdi-calendar</v-icon>
<h3 class="events-title">Upcoming Events</h3>
</div>
<v-btn
variant="text"
color="error"
size="small"
@click="$emit('view-all')"
>
View All
<v-icon end size="16">mdi-arrow-right</v-icon>
</v-btn>
</div>
<div class="events-list">
<div
v-for="(event, index) in events"
:key="event.id"
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{
opacity: 1,
x: 0,
transition: {
delay: 500 + (index * 100),
duration: 500,
type: 'spring'
}
}"
class="event-item"
:class="{ 'event-item--pending': event.status === 'pending' }"
>
<div class="event-date">
<div class="date-month">{{ formatMonth(event.date) }}</div>
<div class="date-day">{{ formatDay(event.date) }}</div>
</div>
<div class="event-details">
<h4 class="event-name">{{ event.title }}</h4>
<div class="event-meta">
<span class="event-time">
<v-icon size="14" color="grey">mdi-clock-outline</v-icon>
{{ event.time }}
</span>
<span class="event-location">
<v-icon size="14" color="grey">mdi-map-marker</v-icon>
{{ event.location }}
</span>
</div>
</div>
<div class="event-status">
<v-chip
:color="event.status === 'confirmed' ? 'success' : 'warning'"
size="x-small"
variant="tonal"
>
{{ event.status }}
</v-chip>
</div>
</div>
</div>
<div class="events-footer">
<div class="footer-message">
<v-icon size="16" color="grey">mdi-information</v-icon>
<span>{{ events.length }} upcoming event{{ events.length !== 1 ? 's' : '' }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Event {
id: string;
title: string;
date: string;
time: string;
location: string;
status: 'confirmed' | 'pending';
}
interface Props {
events: Event[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
'view-all': [];
}>();
// Computed stats
const confirmedCount = computed(() =>
props.events.filter(e => e.status === 'confirmed').length
);
const pendingCount = computed(() =>
props.events.filter(e => e.status === 'pending').length
);
// Date formatting
const formatMonth = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
};
const formatDay = (dateString: string) => {
return new Date(dateString).getDate();
};
</script>
<style scoped lang="scss">
.events-card {
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.events-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.events-title {
font-size: 1.125rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.events-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-right: 0.5rem;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: rgba(220, 38, 38, 0.05);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba(220, 38, 38, 0.2);
border-radius: 2px;
&:hover {
background: rgba(220, 38, 38, 0.3);
}
}
}
.event-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.6)
);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.5);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
transform: translateX(4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
border-color: rgba(220, 38, 38, 0.2);
}
&--pending {
opacity: 0.8;
border-style: dashed;
}
}
.event-date {
flex-shrink: 0;
width: 48px;
height: 48px;
background: linear-gradient(135deg, #dc2626, #b91c1c);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.date-month {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.date-day {
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
}
.event-details {
flex: 1;
min-width: 0;
}
.event-name {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0 0 0.25rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: rgb(107, 114, 128);
span {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.event-status {
flex-shrink: 0;
align-self: center;
}
.events-footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(220, 38, 38, 0.1);
}
.footer-message {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: rgb(107, 114, 128);
}
@media (max-width: 640px) {
.event-meta {
flex-direction: column;
gap: 0.25rem;
}
}
</style>

View File

@@ -0,0 +1,348 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.95 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
delay: 600,
duration: 600,
type: 'spring',
stiffness: 200
}
}"
class="payment-card"
>
<!-- Card Header -->
<div class="payment-header">
<div class="header-left">
<v-icon color="success" size="20">mdi-credit-card</v-icon>
<h3 class="payment-title">Payment Status</h3>
</div>
<v-chip
color="success"
variant="tonal"
size="small"
>
<v-icon start size="14">mdi-check-circle</v-icon>
Active
</v-chip>
</div>
<!-- Membership Info -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{
opacity: 1,
transition: {
delay: 700,
duration: 500
}
}"
class="membership-info"
>
<div class="info-row">
<span class="info-label">Membership Type</span>
<span class="info-value">{{ membershipType }}</span>
</div>
<div class="info-row">
<span class="info-label">Next Payment</span>
<span class="info-value">{{ nextPaymentDate }}</span>
</div>
<div class="info-row">
<span class="info-label">Amount</span>
<span class="info-value amount">${{ membershipAmount }}</span>
</div>
</div>
<!-- Payment Method -->
<div
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 800,
duration: 500
}
}"
class="payment-method"
>
<div class="method-header">
<span class="method-label">Payment Method</span>
<v-btn
variant="text"
color="error"
size="x-small"
@click="$emit('update-payment')"
>
Update
</v-btn>
</div>
<div class="method-card">
<v-icon color="primary" size="20">mdi-credit-card</v-icon>
<span class="card-number"> 4242</span>
<span class="card-exp">12/25</span>
</div>
</div>
<!-- Recent Payments -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{
opacity: 1,
transition: {
delay: 900,
duration: 500
}
}"
class="recent-payments"
>
<h4 class="payments-title">Recent Payments</h4>
<div class="payments-list">
<div
v-for="(payment, index) in paymentHistory"
:key="payment.id"
v-motion
:initial="{ opacity: 0, x: -10 }"
:visibleOnce="{
opacity: 1,
x: 0,
transition: {
delay: 1000 + (index * 50),
duration: 400
}
}"
class="payment-item"
>
<v-icon
size="16"
:color="index === 0 ? 'success' : 'grey'"
>
mdi-check-circle
</v-icon>
<span class="payment-date">{{ payment.date }}</span>
<span class="payment-amount">${{ payment.amount }}</span>
</div>
</div>
</div>
<!-- Action Button -->
<v-btn
color="error"
variant="outlined"
block
class="mt-4"
prepend-icon="mdi-history"
@click="$emit('update-payment')"
>
View Payment History
</v-btn>
</div>
</template>
<script setup lang="ts">
interface Payment {
id: number;
date: string;
amount: string;
}
interface Props {
membershipType: string;
nextPaymentDate: string;
membershipAmount: string;
paymentHistory: Payment[];
}
defineProps<Props>();
defineEmits<{
'update-payment': [];
}>();
</script>
<style scoped lang="scss">
.payment-card {
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.payment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.payment-title {
font-size: 1.125rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.membership-info {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.05),
rgba(34, 197, 94, 0.02)
);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1.25rem;
border: 1px solid rgba(34, 197, 94, 0.1);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
&:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
}
.info-label {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
font-weight: 500;
}
.info-value {
font-size: 0.875rem;
color: rgb(31, 41, 55);
font-weight: 600;
&.amount {
font-size: 1.125rem;
background: linear-gradient(135deg, #22c55e, #16a34a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.payment-method {
background: rgba(255, 255, 255, 0.5);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1.25rem;
}
.method-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.method-label {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.method-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.card-number {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: rgb(31, 41, 55);
letter-spacing: 0.05em;
}
.card-exp {
font-size: 0.75rem;
color: rgb(107, 114, 128);
}
.recent-payments {
flex: 1;
margin-bottom: 1rem;
}
.payments-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0 0 0.75rem 0;
}
.payments-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.payment-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.3);
border-radius: 0.5rem;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.5);
transform: translateX(2px);
}
}
.payment-date {
flex: 1;
font-size: 0.8125rem;
color: rgb(107, 114, 128);
}
.payment-amount {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
}
@media (max-width: 640px) {
.payment-card {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.95 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
duration: 600,
type: 'spring',
stiffness: 200
}
}"
class="profile-card"
>
<!-- Background Gradient -->
<div class="profile-background">
<div class="profile-gradient"></div>
<div class="profile-pattern"></div>
</div>
<!-- Content -->
<div class="profile-content">
<!-- Header Section -->
<div class="profile-header">
<div class="profile-avatar-wrapper">
<div
v-motion
:initial="{ scale: 0 }"
:enter="{
scale: 1,
transition: {
delay: 200,
type: 'spring',
stiffness: 200
}
}"
class="profile-avatar"
>
<ProfileAvatar
v-if="member"
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="x-large"
:show-badge="false"
/>
</div>
<div class="profile-level-badge">
<v-icon size="16" color="white">mdi-star</v-icon>
<span>{{ memberLevel }}</span>
</div>
</div>
<div class="profile-info">
<h2
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 300,
duration: 500
}
}"
class="profile-name"
>
{{ fullName }}
</h2>
<p class="profile-email">{{ email }}</p>
<div class="profile-badges">
<v-chip
color="error"
variant="tonal"
size="small"
class="profile-badge"
>
<v-icon start size="14">mdi-crown</v-icon>
{{ membershipType }}
</v-chip>
<v-chip
variant="outlined"
color="error"
size="small"
class="profile-badge"
>
<v-icon start size="14">mdi-calendar</v-icon>
Since {{ memberSince }}
</v-chip>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="profile-stats">
<div
v-for="(stat, index) in stats"
:key="stat.label"
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 400 + (index * 100),
duration: 500
}
}"
class="stat-item"
>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
<!-- Progress Section -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{
opacity: 1,
transition: {
delay: 700,
duration: 500
}
}"
class="profile-progress"
>
<div class="progress-header">
<span class="progress-title">Level Progress</span>
<span class="progress-percentage">{{ levelProgress }}%</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${levelProgress}%` }"
></div>
</div>
<p class="progress-subtitle">
{{ pointsToNext }} points to {{ nextLevel }}
</p>
</div>
<!-- Action Button -->
<v-btn
color="error"
variant="flat"
block
class="profile-action mt-4"
prepend-icon="mdi-account-edit"
@click="$emit('edit-profile')"
>
Edit Profile
</v-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Member } from '~/utils/types';
interface Props {
member: Member | null;
memberPoints?: number;
eventsAttended?: number;
connections?: number;
}
const props = withDefaults(defineProps<Props>(), {
memberPoints: 2450,
eventsAttended: 12,
connections: 48
});
const emit = defineEmits<{
'edit-profile': [];
}>();
// Computed properties
const fullName = computed(() => {
if (props.member) {
return `${props.member.first_name} ${props.member.last_name}`;
}
return 'Member';
});
const email = computed(() => props.member?.email || '');
const membershipType = computed(() => 'Premium');
const memberLevel = computed(() => 'Gold');
const memberSince = computed(() => {
if (props.member?.join_date) {
return new Date(props.member.join_date).getFullYear();
}
return new Date().getFullYear();
});
// Stats data
const stats = computed(() => [
{ label: 'Points', value: props.memberPoints.toLocaleString() },
{ label: 'Events', value: props.eventsAttended },
{ label: 'Connections', value: props.connections }
]);
// Level progress calculation
const levelProgress = computed(() => {
// Calculate progress to next level (mock calculation)
const currentLevelMin = 2000;
const nextLevelMin = 3000;
const progress = ((props.memberPoints - currentLevelMin) / (nextLevelMin - currentLevelMin)) * 100;
return Math.min(Math.max(progress, 0), 100).toFixed(0);
});
const pointsToNext = computed(() => {
const nextLevelMin = 3000;
return nextLevelMin - props.memberPoints;
});
const nextLevel = computed(() => 'Platinum');
</script>
<style scoped lang="scss">
.profile-card {
position: relative;
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.profile-background {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
overflow: hidden;
}
.profile-gradient {
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.9),
rgba(185, 28, 28, 0.9)
);
}
.profile-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image:
repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,.1) 35px, rgba(255,255,255,.1) 70px);
}
.profile-content {
position: relative;
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
}
.profile-header {
display: flex;
align-items: flex-start;
gap: 1.25rem;
margin-bottom: 1.5rem;
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 1rem;
overflow: hidden;
border: 4px solid white;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.profile-level-badge {
position: absolute;
bottom: -4px;
right: -4px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.5rem;
font-size: 0.625rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.125rem;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
}
.profile-info {
flex: 1;
padding-top: 0.5rem;
}
.profile-name {
font-size: 1.25rem;
font-weight: 700;
color: rgb(31, 41, 55);
margin: 0 0 0.25rem 0;
}
.profile-email {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0 0 0.75rem 0;
}
.profile-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.profile-badge {
font-weight: 500;
}
.profile-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
padding: 1.25rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.03),
rgba(220, 38, 38, 0.01)
);
border-radius: 0.75rem;
margin-bottom: 1.25rem;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, #dc2626, #b91c1c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
color: rgb(156, 163, 175);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-progress {
padding: 1rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.75rem;
margin-bottom: 1rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.progress-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
}
.progress-percentage {
font-size: 0.875rem;
font-weight: 700;
color: rgb(220, 38, 38);
}
.progress-bar {
height: 8px;
background: rgba(220, 38, 38, 0.1);
border-radius: 9999px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #dc2626, #ef4444);
border-radius: 9999px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
}
.progress-subtitle {
font-size: 0.75rem;
color: rgb(156, 163, 175);
margin: 0;
}
.profile-action {
margin-top: auto;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-badges {
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20, scale: 0.9 }"
:enter="{
opacity: 1,
y: 0,
scale: 1,
transition: {
delay: delay,
duration: 500,
type: 'spring',
stiffness: 200
}
}"
:hovered="{
scale: 1.05,
y: -5,
transition: {
duration: 200
}
}"
class="quick-action-card"
@click="$emit('click')"
>
<div class="action-icon" :style="{ background: iconBackground }">
<v-icon :color="color" size="28">{{ icon }}</v-icon>
</div>
<h4 class="action-title">{{ title }}</h4>
<v-icon class="action-arrow" color="grey" size="16">mdi-arrow-right</v-icon>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
icon: string;
title: string;
color?: string;
delay?: number;
}
const props = withDefaults(defineProps<Props>(), {
color: 'error',
delay: 0
});
defineEmits<{
click: [];
}>();
// Compute icon background based on color
const iconBackground = computed(() => {
const colors: Record<string, string> = {
error: 'linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05))',
primary: 'linear-gradient(135deg, rgba(33, 150, 243, 0.1), rgba(33, 150, 243, 0.05))',
success: 'linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05))',
warning: 'linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(245, 158, 11, 0.05))',
info: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05))'
};
return colors[props.color] || colors.error;
});
</script>
<style scoped lang="scss">
.quick-action-card {
position: relative;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg,
rgba(220, 38, 38, 0.3),
rgba(220, 38, 38, 0.1),
transparent
);
transform: translateX(-100%);
transition: transform 0.3s ease;
}
&:hover {
border-color: rgba(220, 38, 38, 0.2);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
&::before {
transform: translateX(0);
}
.action-arrow {
transform: translateX(4px);
color: rgb(220, 38, 38) !important;
}
.action-icon {
transform: rotate(-5deg) scale(1.1);
}
}
}
.action-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.action-title {
font-size: 0.9375rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
line-height: 1.4;
}
.action-arrow {
position: absolute;
top: 1.5rem;
right: 1.5rem;
transition: all 0.3s ease;
}
@media (max-width: 640px) {
.quick-action-card {
padding: 1rem;
}
.action-icon {
width: 48px;
height: 48px;
}
.action-title {
font-size: 0.875rem;
}
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.98 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
duration: 500,
type: 'spring',
stiffness: 200
}
}"
class="simple-profile-card"
>
<!-- Header with Avatar -->
<div class="profile-header">
<div class="profile-avatar-wrapper">
<ProfileAvatar
v-if="member"
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="x-large"
:show-badge="false"
/>
</div>
<div class="profile-title">
<h2 class="profile-name">{{ fullName }}</h2>
<p class="profile-member-id">{{ member?.member_id || 'MUSA-0000' }}</p>
</div>
</div>
<!-- Profile Information -->
<div class="profile-info-section">
<h3 class="section-title">Contact Information</h3>
<div class="info-grid">
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-email</v-icon>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">{{ member?.email || 'Not provided' }}</span>
<v-chip
v-if="emailVerified"
size="x-small"
color="success"
variant="tonal"
class="ml-2"
>
Verified
</v-chip>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-phone</v-icon>
<div class="info-content">
<span class="info-label">Phone</span>
<span class="info-value">{{ member?.phone || 'Not provided' }}</span>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-map-marker</v-icon>
<div class="info-content">
<span class="info-label">Address</span>
<span class="info-value">{{ member?.address || 'Not provided' }}</span>
</div>
</div>
</div>
</div>
<!-- Personal Information -->
<div class="profile-info-section">
<h3 class="section-title">Personal Information</h3>
<div class="info-grid">
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-flag</v-icon>
<div class="info-content">
<span class="info-label">Nationality</span>
<span class="info-value">{{ formatNationality(member?.nationality) }}</span>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-cake</v-icon>
<div class="info-content">
<span class="info-label">Date of Birth</span>
<span class="info-value">{{ formatDate(member?.date_of_birth) }}</span>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-calendar-account</v-icon>
<div class="info-content">
<span class="info-label">Member Since</span>
<span class="info-value">{{ formatDate(member?.member_since) }}</span>
</div>
</div>
</div>
</div>
<!-- Bio Section (if available) -->
<div v-if="member?.bio" class="profile-info-section">
<h3 class="section-title">About Me</h3>
<p class="bio-text">{{ member.bio }}</p>
</div>
<!-- Action Button -->
<v-btn
color="error"
variant="flat"
block
class="profile-action"
prepend-icon="mdi-account-edit"
@click="$emit('edit-profile')"
>
Edit Profile
</v-btn>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Member } from '~/utils/types';
interface Props {
member: Member | null;
emailVerified?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
emailVerified: false
});
const emit = defineEmits<{
'edit-profile': [];
}>();
// Computed properties
const fullName = computed(() => {
if (props.member) {
return `${props.member.first_name} ${props.member.last_name}`;
}
return 'Member';
});
// Format nationality (handles multiple nationalities)
const formatNationality = (nationality?: string) => {
if (!nationality) return 'Not provided';
// Split by comma if multiple nationalities
const nationalities = nationality.split(',').map(n => n.trim());
// Map country codes to full names if needed
const countryMap: Record<string, string> = {
'US': 'United States',
'FR': 'France',
'MC': 'Monaco',
'IT': 'Italy',
'UK': 'United Kingdom',
'DE': 'Germany',
'ES': 'Spain'
};
return nationalities.map(n => countryMap[n] || n).join(', ');
};
// Format date
const formatDate = (dateString?: string) => {
if (!dateString) return 'Not provided';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateString;
}
};
</script>
<style scoped lang="scss">
.simple-profile-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
height: 100%;
display: flex;
flex-direction: column;
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
margin-bottom: 1.5rem;
}
.profile-avatar-wrapper {
flex-shrink: 0;
}
.profile-title {
flex: 1;
}
.profile-name {
font-size: 1.5rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0 0 0.25rem 0;
}
.profile-member-id {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0;
font-family: 'Courier New', monospace;
}
.profile-info-section {
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(107, 114, 128);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 1rem 0;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.info-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.info-label {
font-size: 0.75rem;
color: rgb(156, 163, 175);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.info-value {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.4;
}
.bio-text {
font-size: 0.875rem;
color: rgb(75, 85, 99);
line-height: 1.6;
margin: 0;
}
.profile-action {
margin-top: auto;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,332 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20, scale: 0.95 }"
:enter="{
opacity: 1,
y: 0,
scale: 1,
transition: {
delay: delay,
duration: 600,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
:hovered="{
scale: 1.02,
y: -2,
transition: {
duration: 200
}
}"
class="stats-card"
>
<div class="stats-card-inner">
<!-- Icon Section -->
<div class="stats-icon" :style="{ background: iconBackground }">
<v-icon :color="iconColor" size="24">{{ icon }}</v-icon>
</div>
<!-- Content Section -->
<div class="stats-content">
<p class="stats-label">{{ label }}</p>
<div class="stats-value-wrapper">
<h3
class="stats-value"
v-motion
:initial="{ opacity: 0 }"
:visible="{
opacity: 1,
transition: {
delay: delay + 200,
duration: 800
}
}"
>
<span v-if="prefix">{{ prefix }}</span>
<AnimatedNumber :value="value" :duration="1500" :format="formatNumber" />
<span v-if="suffix">{{ suffix }}</span>
</h3>
<div
v-if="change !== undefined"
class="stats-change"
:class="changeClass"
v-motion
:initial="{ opacity: 0, scale: 0.8 }"
:visible="{
opacity: 1,
scale: 1,
transition: {
delay: delay + 400,
duration: 500,
type: 'spring'
}
}"
>
<v-icon size="16">
{{ change >= 0 ? 'mdi-trending-up' : 'mdi-trending-down' }}
</v-icon>
<span>{{ Math.abs(change) }}%</span>
</div>
</div>
<p v-if="subtitle" class="stats-subtitle">{{ subtitle }}</p>
</div>
<!-- Background Decoration -->
<div class="stats-decoration">
<svg viewBox="0 0 200 100" class="stats-chart">
<path
:d="sparklinePath"
fill="none"
:stroke="decorationColor"
stroke-width="2"
stroke-linecap="round"
opacity="0.2"
/>
<path
:d="sparklinePath"
fill="url(#gradient)"
opacity="0.1"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="decorationColor" stop-opacity="0.3" />
<stop offset="100%" :stop-color="decorationColor" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
interface Props {
label: string;
value: number;
icon: string;
iconColor?: string;
iconBackground?: string;
change?: number;
prefix?: string;
suffix?: string;
subtitle?: string;
delay?: number;
decorationColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
iconColor: 'error',
iconBackground: 'linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05))',
delay: 0,
decorationColor: '#dc2626'
});
// Animated number component
const AnimatedNumber = {
props: {
value: Number,
duration: { type: Number, default: 1000 },
format: Function
},
setup(props: any) {
const displayValue = ref(0);
onMounted(() => {
const startTime = Date.now();
const startValue = 0;
const endValue = props.value;
const updateValue = () => {
const now = Date.now();
const progress = Math.min((now - startTime) / props.duration, 1);
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
displayValue.value = startValue + (endValue - startValue) * easeOutQuart;
if (progress < 1) {
requestAnimationFrame(updateValue);
} else {
displayValue.value = endValue;
}
};
updateValue();
});
const formattedValue = computed(() => {
if (props.format) {
return props.format(displayValue.value);
}
return Math.round(displayValue.value).toLocaleString();
});
return () => formattedValue.value;
}
};
// Compute change indicator class
const changeClass = computed(() => {
if (props.change === undefined) return '';
return props.change >= 0 ? 'stats-change--positive' : 'stats-change--negative';
});
// Format number function
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return Math.round(num).toLocaleString();
};
// Generate random sparkline path
const sparklinePath = computed(() => {
const points = 10;
const width = 200;
const height = 100;
const values = Array.from({ length: points }, () => Math.random() * 0.6 + 0.2);
const path = values.map((value, index) => {
const x = (index / (points - 1)) * width;
const y = height - (value * height);
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ');
return `${path} L ${width} ${height} L 0 ${height} Z`;
});
</script>
<style scoped lang="scss">
.stats-card {
position: relative;
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
&:hover {
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
}
.stats-card-inner {
position: relative;
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
z-index: 1;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.stats-content {
flex: 1;
display: flex;
flex-direction: column;
}
.stats-label {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.stats-value-wrapper {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.stats-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #dc2626, #b91c1c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.stats-change {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
&--positive {
background: rgba(34, 197, 94, 0.1);
color: rgb(34, 197, 94);
}
&--negative {
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
}
}
.stats-subtitle {
font-size: 0.75rem;
color: rgb(156, 163, 175);
margin: 0.25rem 0 0 0;
}
.stats-decoration {
position: absolute;
bottom: 0;
right: 0;
width: 60%;
height: 50%;
pointer-events: none;
}
.stats-chart {
width: 100%;
height: 100%;
}
@media (max-width: 640px) {
.stats-value {
font-size: 1.5rem;
}
.stats-card-inner {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<span>{{ displayValue }}</span>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
interface Props {
value: number
duration?: number
format?: (value: number) => string
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
duration: 1000,
format: (value: number) => value.toLocaleString(),
delay: 0
})
const displayValue = ref(props.format(0))
const startTimestamp = ref<number | null>(null)
const startValue = ref(0)
const animate = (timestamp: number) => {
if (!startTimestamp.value) {
startTimestamp.value = timestamp
}
const progress = Math.min((timestamp - startTimestamp.value) / props.duration, 1)
// Easing function for smooth animation
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4)
const easedProgress = easeOutQuart(progress)
const currentValue = startValue.value + (props.value - startValue.value) * easedProgress
displayValue.value = props.format(currentValue)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
const startAnimation = () => {
startTimestamp.value = null
if (props.delay > 0) {
setTimeout(() => {
requestAnimationFrame(animate)
}, props.delay)
} else {
requestAnimationFrame(animate)
}
}
watch(() => props.value, (newValue, oldValue) => {
startValue.value = oldValue || 0
startAnimation()
})
onMounted(() => {
startAnimation()
})
</script>

View File

@@ -0,0 +1,417 @@
<template>
<div
class="floating-input"
:class="[
`floating-input--${variant}`,
{
'floating-input--focused': isFocused || modelValue,
'floating-input--error': error,
'floating-input--disabled': disabled
}
]"
>
<div class="floating-input__wrapper">
<Icon
v-if="leftIcon"
:name="leftIcon"
class="floating-input__icon floating-input__icon--left"
/>
<input
:id="inputId"
v-model="modelValue"
:type="type"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
class="floating-input__field"
:class="{
'floating-input__field--with-left-icon': leftIcon,
'floating-input__field--with-right-icon': rightIcon || clearable
}"
@focus="handleFocus"
@blur="handleBlur"
@input="$emit('update:modelValue', $event.target.value)"
/>
<label
:for="inputId"
class="floating-input__label"
:class="{
'floating-input__label--floating': isFocused || modelValue,
'floating-input__label--with-icon': leftIcon
}"
>
{{ label }}
<span v-if="required" class="floating-input__required">*</span>
</label>
<button
v-if="clearable && modelValue"
type="button"
class="floating-input__clear"
@click="clearInput"
>
<Icon name="x" />
</button>
<Icon
v-if="rightIcon && !clearable"
:name="rightIcon"
class="floating-input__icon floating-input__icon--right"
/>
</div>
<Transition name="message">
<div v-if="error || helperText" class="floating-input__message">
<Icon
v-if="error"
name="alert-circle"
class="floating-input__message-icon"
/>
<span>{{ error || helperText }}</span>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Icon from '~/components/ui/Icon.vue'
interface Props {
modelValue?: string
label: string
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'number'
variant?: 'glass' | 'solid' | 'outline'
leftIcon?: string
rightIcon?: string
error?: string
helperText?: string
required?: boolean
disabled?: boolean
readonly?: boolean
clearable?: boolean
autocomplete?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
variant: 'glass',
required: false,
disabled: false,
readonly: false,
clearable: false,
autocomplete: 'off'
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'focus': []
'blur': []
'clear': []
}>()
const isFocused = ref(false)
const inputId = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
const handleFocus = () => {
isFocused.value = true
emit('focus')
}
const handleBlur = () => {
isFocused.value = false
emit('blur')
}
const clearInput = () => {
emit('update:modelValue', '')
emit('clear')
}
</script>
<style scoped lang="scss">
.floating-input {
position: relative;
width: 100%;
&__wrapper {
position: relative;
display: flex;
align-items: center;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Base styles
.floating-input--glass & {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
&:hover:not(.floating-input--disabled &) {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(220, 38, 38, 0.2);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
}
.floating-input--solid & {
background: white;
border: 2px solid #e5e5e5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&:hover:not(.floating-input--disabled &) {
border-color: #d4d4d4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.floating-input--outline & {
background: transparent;
border: 2px solid #d4d4d4;
&:hover:not(.floating-input--disabled &) {
border-color: #a3a3a3;
}
}
// Focus state
.floating-input--focused & {
border-color: #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
transform: translateY(-1px);
}
.floating-input--focused.floating-input--glass & {
background: rgba(255, 255, 255, 0.9);
}
// Error state
.floating-input--error & {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
// Disabled state
.floating-input--disabled & {
opacity: 0.5;
cursor: not-allowed;
}
}
&__field {
flex: 1;
padding: 1.25rem 1rem 0.5rem;
background: transparent;
border: none;
outline: none;
font-size: 1rem;
color: #27272a;
transition: padding 0.2s ease;
&--with-left-icon {
padding-left: 3rem;
}
&--with-right-icon {
padding-right: 3rem;
}
&:disabled {
cursor: not-allowed;
}
// Remove autofill background
&:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px transparent inset;
-webkit-text-fill-color: #27272a;
transition: background-color 5000s ease-in-out 0s;
}
}
&__label {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1rem;
color: #71717a;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
padding: 0 0.25rem;
&--with-icon {
left: 3rem;
}
&--floating {
top: 0.75rem;
transform: translateY(0);
font-size: 0.75rem;
color: #dc2626;
font-weight: 500;
.floating-input--glass & {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.9) 50%,
transparent 50%,
transparent 100%
);
}
.floating-input--solid & {
background: linear-gradient(
to bottom,
white 0%,
white 50%,
transparent 50%,
transparent 100%
);
}
}
.floating-input--error &--floating {
color: #ef4444;
}
}
&__required {
color: #ef4444;
margin-left: 0.125rem;
}
&__icon {
position: absolute;
width: 1.25rem;
height: 1.25rem;
color: #dc2626;
&--left {
left: 1rem;
}
&--right {
right: 1rem;
}
}
&__clear {
position: absolute;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: rgba(220, 38, 38, 0.1);
border: none;
border-radius: 50%;
color: #dc2626;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(220, 38, 38, 0.2);
transform: scale(1.1);
}
svg {
width: 0.875rem;
height: 0.875rem;
}
}
&__message {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #71717a;
.floating-input--error & {
color: #ef4444;
}
}
&__message-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
}
// Animations
.message-enter-active,
.message-leave-active {
transition: all 0.2s ease;
}
.message-enter-from {
opacity: 0;
transform: translateY(-4px);
}
.message-leave-to {
opacity: 0;
transform: translateY(-4px);
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.floating-input {
&__field {
color: white;
&:-webkit-autofill {
-webkit-text-fill-color: white;
}
}
&__wrapper {
.floating-input--glass & {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
.floating-input--solid & {
background: #27272a;
border-color: #3f3f46;
}
}
&__label {
color: #a3a3a3;
&--floating {
.floating-input--glass & {
background: linear-gradient(
to bottom,
rgba(30, 30, 30, 0.9) 0%,
rgba(30, 30, 30, 0.9) 50%,
transparent 50%,
transparent 100%
);
}
.floating-input--solid & {
background: linear-gradient(
to bottom,
#27272a 0%,
#27272a 50%,
transparent 50%,
transparent 100%
);
}
}
}
}
}
</style>

276
components/ui/GlassCard.vue Normal file
View File

@@ -0,0 +1,276 @@
<template>
<div
v-motion
:initial="animated ? animationConfig.initial : {}"
:enter="animated ? animationConfig.enter : {}"
:hovered="hoverable ? { scale: 1.02 } : {}"
:delay="delay"
class="glass-card"
:class="[
`glass-card--${variant}`,
`glass-card--${size}`,
{
'glass-card--clickable': clickable,
'glass-card--elevated': elevated
}
]"
>
<div v-if="hasHeader" class="glass-card__header">
<slot name="header">
<h3 v-if="title" class="glass-card__title">{{ title }}</h3>
<p v-if="subtitle" class="glass-card__subtitle">{{ subtitle }}</p>
</slot>
</div>
<div class="glass-card__body">
<slot />
</div>
<div v-if="hasFooter" class="glass-card__footer">
<slot name="footer" />
</div>
<div v-if="gradient" class="glass-card__gradient"></div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
interface Props {
title?: string
subtitle?: string
variant?: 'light' | 'dark' | 'colored' | 'gradient'
size?: 'sm' | 'md' | 'lg' | 'xl'
clickable?: boolean
hoverable?: boolean
elevated?: boolean
gradient?: boolean
animated?: boolean
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'light',
size: 'md',
clickable: false,
hoverable: true,
elevated: true,
gradient: false,
animated: true,
delay: 0
})
const slots = useSlots()
const hasHeader = computed(() => !!slots.header || props.title || props.subtitle)
const hasFooter = computed(() => !!slots.footer)
const animationConfig = {
initial: {
opacity: 0,
y: 20,
scale: 0.95
},
enter: {
opacity: 1,
y: 0,
scale: 1,
transition: {
type: 'spring',
stiffness: 200,
damping: 20
}
}
}
</script>
<style scoped lang="scss">
.glass-card {
position: relative;
border-radius: 20px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Glass effect base
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.1) 100%);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
// Variants
&--light {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #27272a;
}
&--dark {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
}
&--colored {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(185, 28, 28, 0.05) 100%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(220, 38, 38, 0.2);
color: #27272a;
}
&--gradient {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.4) 100%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.4);
color: #27272a;
}
// Sizes
&--sm {
.glass-card__body {
padding: 1rem;
}
.glass-card__header {
padding: 1rem 1rem 0.5rem;
}
.glass-card__footer {
padding: 0.5rem 1rem 1rem;
}
}
&--md {
.glass-card__body {
padding: 1.5rem;
}
.glass-card__header {
padding: 1.5rem 1.5rem 0.75rem;
}
.glass-card__footer {
padding: 0.75rem 1.5rem 1.5rem;
}
}
&--lg {
.glass-card__body {
padding: 2rem;
}
.glass-card__header {
padding: 2rem 2rem 1rem;
}
.glass-card__footer {
padding: 1rem 2rem 2rem;
}
}
&--xl {
.glass-card__body {
padding: 2.5rem;
}
.glass-card__header {
padding: 2.5rem 2.5rem 1.25rem;
}
.glass-card__footer {
padding: 1.25rem 2.5rem 2.5rem;
}
}
// States
&--clickable {
cursor: pointer;
&:active {
transform: scale(0.98);
}
}
&--elevated {
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.1),
0 2px 10px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover {
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.15),
0 4px 15px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
}
&:hover {
transform: translateY(-2px);
}
// Header
&__header {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
&__title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #dc2626;
}
&__subtitle {
font-size: 0.875rem;
margin: 0.25rem 0 0;
opacity: 0.8;
}
// Body
&__body {
position: relative;
z-index: 1;
}
// Footer
&__footer {
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
// Gradient overlay
&__gradient {
position: absolute;
top: 0;
right: 0;
width: 50%;
height: 100%;
background: linear-gradient(90deg,
transparent 0%,
rgba(220, 38, 38, 0.05) 100%);
pointer-events: none;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.glass-card--light {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
}
</style>

197
components/ui/Icon.vue Normal file
View File

@@ -0,0 +1,197 @@
<template>
<component
:is="iconComponent"
v-if="iconComponent"
:size="size"
:stroke-width="strokeWidth"
:color="color"
class="lucide-icon"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import * as icons from 'lucide-vue-next'
interface Props {
name: string
size?: number | string
strokeWidth?: number
color?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
strokeWidth: 2,
color: 'currentColor'
})
// Convert kebab-case to PascalCase for icon component names
const toPascalCase = (str: string) => {
return str
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
}
const iconComponent = computed(() => {
// Handle special cases and common mappings
const iconMap: Record<string, string> = {
'alert-circle': 'AlertCircle',
'chevron-down': 'ChevronDown',
'chevron-up': 'ChevronUp',
'x': 'X',
'check': 'Check',
'trending-up': 'TrendingUp',
'trending-down': 'TrendingDown',
'minus': 'Minus',
'search': 'Search',
'filter': 'Filter',
'calendar': 'Calendar',
'map-pin': 'MapPin',
'users': 'Users',
'clock': 'Clock',
'star': 'Star',
'grid': 'Grid',
'list': 'List',
'plus': 'Plus',
'user': 'User',
'mail': 'Mail',
'phone': 'Phone',
'globe': 'Globe',
'briefcase': 'Briefcase',
'building': 'Building',
'award': 'Award',
'shield': 'Shield',
'heart': 'Heart',
'edit': 'Edit',
'settings': 'Settings',
'log-out': 'LogOut',
'bell': 'Bell',
'home': 'Home',
'activity': 'Activity',
'message-square': 'MessageSquare',
'arrow-right': 'ArrowRight',
'external-link': 'ExternalLink',
'download': 'Download',
'upload': 'Upload',
'share': 'Share',
'copy': 'Copy',
'trash': 'Trash',
'eye': 'Eye',
'eye-off': 'EyeOff',
'lock': 'Lock',
'unlock': 'Unlock',
'camera': 'Camera',
'image': 'Image',
'video': 'Video',
'file-text': 'FileText',
'bar-chart': 'BarChart',
'pie-chart': 'PieChart',
'dollar-sign': 'DollarSign',
'credit-card': 'CreditCard',
'gift': 'Gift',
'bookmark': 'Bookmark',
'tag': 'Tag',
'folder': 'Folder',
'layers': 'Layers',
'zap': 'Zap',
'sun': 'Sun',
'moon': 'Moon',
'more-horizontal': 'MoreHorizontal',
'more-vertical': 'MoreVertical',
'menu': 'Menu',
'arrow-left': 'ArrowLeft',
'arrow-up': 'ArrowUp',
'arrow-down': 'ArrowDown',
'chevron-left': 'ChevronLeft',
'chevron-right': 'ChevronRight',
'check-circle': 'CheckCircle',
'x-circle': 'XCircle',
'alert-triangle': 'AlertTriangle',
'info': 'Info',
'help-circle': 'HelpCircle',
'loader': 'Loader',
'refresh-cw': 'RefreshCw',
'link': 'Link',
'paperclip': 'Paperclip',
'send': 'Send',
'inbox': 'Inbox',
'archive': 'Archive',
'flag': 'Flag',
'save': 'Save',
'wifi': 'Wifi',
'wifi-off': 'WifiOff',
'mic': 'Mic',
'mic-off': 'MicOff',
'volume': 'Volume',
'volume-x': 'VolumeX',
'play': 'Play',
'pause': 'Pause',
'skip-forward': 'SkipForward',
'skip-back': 'SkipBack',
'maximize': 'Maximize',
'minimize': 'Minimize',
'expand': 'Expand',
'compass': 'Compass',
'map': 'Map',
'navigation': 'Navigation',
'target': 'Target',
'crown': 'Crown',
'key': 'Key',
'code': 'Code',
'terminal': 'Terminal',
'database': 'Database',
'server': 'Server',
'cpu': 'Cpu',
'hard-drive': 'HardDrive',
'monitor': 'Monitor',
'smartphone': 'Smartphone',
'tablet': 'Tablet',
'watch': 'Watch',
'printer': 'Printer',
'headphones': 'Headphones',
'bluetooth': 'Bluetooth',
'battery': 'Battery',
'battery-charging': 'BatteryCharging',
'clipboard': 'Clipboard',
'hash': 'Hash',
'at-sign': 'AtSign',
'percent': 'Percent',
'thumbs-up': 'ThumbsUp',
'thumbs-down': 'ThumbsDown',
'smile': 'Smile',
'frown': 'Frown',
'coffee': 'Coffee',
'shopping-cart': 'ShoppingCart',
'shopping-bag': 'ShoppingBag',
'package': 'Package',
'truck': 'Truck',
'book': 'Book',
'book-open': 'BookOpen',
'feather': 'Feather',
'sliders': 'Sliders',
'toggle-left': 'ToggleLeft',
'toggle-right': 'ToggleRight',
'power': 'Power',
'log-in': 'LogIn',
'circle': 'Circle',
'square': 'Square',
'triangle': 'Triangle'
}
// Get the icon name from the map or convert from kebab-case
const iconName = iconMap[props.name] || toPascalCase(props.name)
// Return the icon component from lucide-vue-next
return (icons as any)[iconName] || (icons as any)[iconName + 'Icon'] || null
})
</script>
<style scoped>
.lucide-icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,408 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.95 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
delay: delay * 50,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
:hovered="{ scale: 1.02 }"
class="member-card"
:class="[
`member-card--${variant}`,
{ 'member-card--featured': featured }
]"
@click="$emit('click', member)"
>
<div class="member-card__header">
<div class="member-card__avatar">
<img
v-if="member.avatar"
:src="member.avatar"
:alt="member.name"
@error="handleImageError"
/>
<div v-else class="member-card__avatar-placeholder">
{{ initials }}
</div>
<div
v-if="member.status === 'online'"
class="member-card__status-indicator"
/>
</div>
<div v-if="member.role" class="member-card__role">
{{ member.role }}
</div>
</div>
<div class="member-card__body">
<h3 class="member-card__name">{{ member.name }}</h3>
<p v-if="member.title" class="member-card__title">{{ member.title }}</p>
<p v-if="member.company" class="member-card__company">{{ member.company }}</p>
<div v-if="member.tags && member.tags.length" class="member-card__tags">
<span
v-for="tag in member.tags.slice(0, 3)"
:key="tag"
class="member-card__tag"
>
{{ tag }}
</span>
<span
v-if="member.tags.length > 3"
class="member-card__tag member-card__tag--more"
>
+{{ member.tags.length - 3 }}
</span>
</div>
</div>
<div class="member-card__footer">
<div class="member-card__stats">
<div v-if="member.joinDate" class="member-card__stat">
<span class="member-card__stat-label">Member Since</span>
<span class="member-card__stat-value">{{ member.joinDate }}</span>
</div>
<div v-if="member.connections !== undefined" class="member-card__stat">
<span class="member-card__stat-label">Connections</span>
<span class="member-card__stat-value">{{ member.connections }}</span>
</div>
</div>
<div class="member-card__actions">
<button
class="member-card__action"
@click.stop="$emit('connect', member)"
>
<span>{{ member.connected ? '✓' : '+' }}</span>
{{ member.connected ? 'Connected' : 'Connect' }}
</button>
<button
class="member-card__action member-card__action--secondary"
@click.stop="$emit('message', member)"
>
<span></span>
Message
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Member {
id: string | number
name: string
avatar?: string
title?: string
company?: string
role?: string
status?: 'online' | 'offline' | 'away'
tags?: string[]
joinDate?: string
connections?: number
connected?: boolean
}
interface Props {
member: Member
variant?: 'glass' | 'solid' | 'outline'
featured?: boolean
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
featured: false,
delay: 0
})
defineEmits<{
click: [member: Member]
connect: [member: Member]
message: [member: Member]
}>()
const initials = computed(() => {
const names = props.member.name.split(' ')
return names.map(n => n[0]).join('').toUpperCase().slice(0, 2)
})
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}
</script>
<style scoped lang="scss">
.member-card {
position: relative;
display: flex;
flex-direction: column;
padding: 1.5rem;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Glass variant
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
&:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
}
// Solid variant
&--solid {
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
}
// Outline variant
&--outline {
background: transparent;
border: 2px solid rgba(220, 38, 38, 0.2);
&:hover {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.3);
transform: translateY(-4px);
}
}
// Featured state
&--featured {
border: 2px solid #dc2626;
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.15);
&::before {
content: '⭐';
position: absolute;
top: -0.5rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: #dc2626;
border-radius: 50%;
font-size: 1rem;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
&__avatar {
position: relative;
width: 4rem;
height: 4rem;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 1.5rem;
font-weight: 600;
color: #dc2626;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%);
}
&__status-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 1rem;
height: 1rem;
background: #10b981;
border: 2px solid white;
border-radius: 50%;
}
&__role {
padding: 0.25rem 0.75rem;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
&__body {
flex: 1;
margin-bottom: 1rem;
}
&__name {
margin: 0 0 0.25rem;
font-size: 1.125rem;
font-weight: 600;
color: #27272a;
}
&__title {
margin: 0 0 0.125rem;
font-size: 0.875rem;
font-weight: 500;
color: #dc2626;
}
&__company {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
&__tag {
padding: 0.25rem 0.5rem;
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
&--more {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
}
&__footer {
padding-top: 1rem;
border-top: 1px solid rgba(220, 38, 38, 0.1);
}
&__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
&__stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__stat-label {
font-size: 0.75rem;
color: #6b7280;
}
&__stat-value {
font-size: 0.875rem;
font-weight: 600;
color: #27272a;
}
&__actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
&__action {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
background: #dc2626;
color: white;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
span {
font-size: 0.875rem;
}
&:hover {
background: #b91c1c;
transform: translateY(-1px);
}
&--secondary {
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
&:hover {
background: rgba(220, 38, 38, 0.2);
}
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.member-card {
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
&--solid {
background: #27272a;
}
&__name {
color: white;
}
&__stat-value {
color: #e5e5e5;
}
}
}
</style>

View File

@@ -0,0 +1,329 @@
<template>
<button
v-motion
:initial="animated ? { scale: 0.95, opacity: 0 } : {}"
:enter="animated ? { scale: 1, opacity: 1 } : {}"
:hovered="hoverable ? { scale: 1.05 } : {}"
:tapped="{ scale: 0.95 }"
:delay="delay"
class="monaco-button"
:class="[
`monaco-button--${variant}`,
`monaco-button--${size}`,
{
'monaco-button--block': block,
'monaco-button--loading': loading,
'monaco-button--icon-only': !$slots.default && icon
}
]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="monaco-button__spinner">
<svg class="monaco-button__spinner-svg" viewBox="0 0 24 24">
<circle
class="monaco-button__spinner-circle"
cx="12"
cy="12"
r="10"
stroke-width="3"
fill="none"
/>
</svg>
</span>
<Icon
v-if="icon && !loading"
:name="icon"
class="monaco-button__icon"
:class="{ 'monaco-button__icon--left': $slots.default }"
/>
<span v-if="$slots.default" class="monaco-button__content">
<slot />
</span>
<Icon
v-if="rightIcon && !loading"
:name="rightIcon"
class="monaco-button__icon monaco-button__icon--right"
/>
</button>
</template>
<script setup lang="ts">
import Icon from '~/components/ui/Icon.vue'
interface Props {
variant?: 'primary' | 'secondary' | 'glass' | 'gradient' | 'outline' | 'ghost'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
icon?: string
rightIcon?: string
block?: boolean
disabled?: boolean
loading?: boolean
hoverable?: boolean
animated?: boolean
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
block: false,
disabled: false,
loading: false,
hoverable: true,
animated: true,
delay: 0
})
defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<style scoped lang="scss">
.monaco-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 600;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
border: none;
overflow: hidden;
// Create shimmer effect for gradient variant
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
&:hover::before {
left: 100%;
}
// Variants
&--primary {
background: #dc2626;
color: white;
box-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
&:hover:not(:disabled) {
background: #b91c1c;
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.35);
transform: translateY(-2px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
&--secondary {
background: white;
color: #dc2626;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
&:hover:not(:disabled) {
background: #fef2f2;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
}
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #dc2626;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(220, 38, 38, 0.2);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transform: translateY(-2px);
}
}
&--gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
box-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
&:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.35);
transform: translateY(-2px);
}
}
&--outline {
background: transparent;
color: #dc2626;
border: 2px solid #dc2626;
&:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.1);
border-color: #b91c1c;
transform: translateY(-2px);
}
}
&--ghost {
background: transparent;
color: #dc2626;
&:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.1);
transform: translateY(-2px);
}
}
// Sizes
&--xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 8px;
}
&--sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: 10px;
}
&--md {
padding: 0.5rem 1rem;
font-size: 1rem;
}
&--lg {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
border-radius: 14px;
}
&--xl {
padding: 1rem 2rem;
font-size: 1.25rem;
border-radius: 16px;
}
// States
&--block {
width: 100%;
}
&--icon-only {
aspect-ratio: 1;
padding: 0;
&.monaco-button--xs { width: 1.75rem; }
&.monaco-button--sm { width: 2rem; }
&.monaco-button--md { width: 2.5rem; }
&.monaco-button--lg { width: 3rem; }
&.monaco-button--xl { width: 3.5rem; }
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--loading {
color: transparent;
pointer-events: none;
}
// Icons
&__icon {
width: 1.25em;
height: 1.25em;
flex-shrink: 0;
&--left {
margin-right: 0.25rem;
}
&--right {
margin-left: 0.25rem;
}
}
// Spinner
&__spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&__spinner-svg {
width: 1.5em;
height: 1.5em;
animation: spin 1s linear infinite;
}
&__spinner-circle {
stroke: currentColor;
stroke-linecap: round;
stroke-dasharray: 64;
stroke-dashoffset: 64;
animation: dash 1.5s ease-in-out infinite;
}
}
// Animations
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes dash {
0% { stroke-dashoffset: 64; }
50% { stroke-dashoffset: 16; }
100% { stroke-dashoffset: 64; }
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.monaco-button {
&--secondary {
background: #27272a;
color: #dc2626;
&:hover:not(:disabled) {
background: #3f3f46;
}
}
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
}
}
</style>

369
components/ui/StatsCard.vue Normal file
View File

@@ -0,0 +1,369 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: delay * 100,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
class="stats-card"
:class="[
`stats-card--${variant}`,
{ 'stats-card--clickable': clickable }
]"
@click="$emit('click')"
>
<div class="stats-card__header">
<div class="stats-card__icon-wrapper">
<Icon
:name="icon"
class="stats-card__icon"
/>
</div>
<div v-if="trend" class="stats-card__trend" :class="`stats-card__trend--${trend.type}`">
<Icon
:name="trend.type === 'up' ? 'trending-up' : trend.type === 'down' ? 'trending-down' : 'minus'"
class="stats-card__trend-icon"
/>
<span>{{ trend.value }}%</span>
</div>
</div>
<div class="stats-card__content">
<h3 class="stats-card__label">{{ label }}</h3>
<div class="stats-card__value-wrapper">
<span v-if="prefix" class="stats-card__prefix">{{ prefix }}</span>
<AnimatedNumber
:value="value"
:duration="1500"
:format="format"
class="stats-card__value"
/>
<span v-if="suffix" class="stats-card__suffix">{{ suffix }}</span>
</div>
<p v-if="description" class="stats-card__description">{{ description }}</p>
</div>
<div v-if="progress !== undefined" class="stats-card__progress">
<div class="stats-card__progress-bar">
<div
class="stats-card__progress-fill"
:style="{ width: `${Math.min(100, Math.max(0, progress))}%` }"
/>
</div>
<span class="stats-card__progress-label">{{ progress }}% Complete</span>
</div>
<div v-if="sparkline" class="stats-card__sparkline">
<svg
viewBox="0 0 100 40"
preserveAspectRatio="none"
class="stats-card__sparkline-svg"
>
<polyline
:points="sparklinePoints"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
:points="`${sparklinePoints} 100,40 0,40`"
fill="currentColor"
fill-opacity="0.1"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '~/components/ui/Icon.vue'
import AnimatedNumber from '~/components/ui/AnimatedNumber.vue'
interface Trend {
type: 'up' | 'down' | 'neutral'
value: number
}
interface Props {
label: string
value: number
icon: string
variant?: 'glass' | 'solid' | 'gradient' | 'outline'
prefix?: string
suffix?: string
description?: string
trend?: Trend
progress?: number
sparkline?: number[]
clickable?: boolean
format?: (value: number) => string
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
clickable: false,
delay: 0,
format: (value: number) => value.toLocaleString()
})
defineEmits<{
click: []
}>()
const sparklinePoints = computed(() => {
if (!props.sparkline || props.sparkline.length === 0) return ''
const data = props.sparkline
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
return data
.map((value, index) => {
const x = (index / (data.length - 1)) * 100
const y = 40 - ((value - min) / range) * 35
return `${x},${y}`
})
.join(' ')
})
</script>
<style scoped lang="scss">
.stats-card {
position: relative;
padding: 1.5rem;
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
// Glass variant
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transform: translateY(-2px);
}
}
// Solid variant
&--solid {
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}
// Gradient variant
&--gradient {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(220, 38, 38, 0.02) 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(220, 38, 38, 0.1);
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.08);
&:hover {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.08) 0%,
rgba(220, 38, 38, 0.03) 100%);
transform: translateY(-2px);
}
}
// Outline variant
&--outline {
background: transparent;
border: 2px solid rgba(220, 38, 38, 0.2);
&:hover {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.3);
transform: translateY(-2px);
}
}
&--clickable {
cursor: pointer;
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
&__icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%);
border-radius: 12px;
}
&__icon {
width: 1.5rem;
height: 1.5rem;
color: #dc2626;
}
&__trend {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
&--up {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
&--down {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
&--neutral {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
}
&__trend-icon {
width: 1rem;
height: 1rem;
}
&__content {
margin-bottom: 1rem;
}
&__label {
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
margin: 0 0 0.5rem;
}
&__value-wrapper {
display: flex;
align-items: baseline;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__value {
font-size: 2rem;
font-weight: 700;
color: #27272a;
line-height: 1;
}
&__prefix,
&__suffix {
font-size: 1.25rem;
font-weight: 500;
color: #6b7280;
}
&__description {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
&__progress {
margin-top: 1rem;
}
&__progress-bar {
height: 6px;
background: rgba(220, 38, 38, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
&__progress-fill {
height: 100%;
background: linear-gradient(90deg, #dc2626 0%, #b91c1c 100%);
border-radius: 3px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
&__progress-label {
font-size: 0.75rem;
color: #6b7280;
}
&__sparkline {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
opacity: 0.5;
}
&__sparkline-svg {
width: 100%;
height: 100%;
color: #dc2626;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.stats-card {
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
&--solid {
background: #27272a;
}
&__value {
color: white;
}
&__label,
&__description,
&__progress-label {
color: #a3a3a3;
}
}
}
</style>

View File

@@ -1,64 +1,326 @@
import type { AuthState } from '~/utils/types';
import type { User } from '~/utils/types';
export const useAuth = () => {
const authState = useState<AuthState>('auth.state', () => ({
authenticated: false,
user: null,
groups: [],
}));
// Use useState for SSR compatibility - prevents hydration mismatches
const user = useState<User | null>('auth.user', () => null);
const isAuthenticated = computed(() => !!user.value);
const loading = ref(false);
const error = ref<string | null>(null);
const login = () => {
return navigateTo('/api/auth/login');
};
// Enhanced role checking method - supports both realm roles and legacy groups
const hasRole = (roleName: string): boolean => {
if (!user.value) return false;
const logout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' });
authState.value = {
authenticated: false,
user: null,
groups: [],
};
await navigateTo('/login');
} catch (error) {
console.error('Logout error:', error);
await navigateTo('/login');
// Get roles from user token (Keycloak format)
const userToken = user.value as any; // Cast for accessing token properties
// Check realm roles first (new system)
const realmRoles = userToken.realm_access?.roles || [];
if (realmRoles.includes(roleName)) {
return true;
}
// Check client roles (new system)
const clientRoles = userToken.resource_access || {};
for (const clientId in clientRoles) {
const roles = clientRoles[clientId]?.roles || [];
if (roles.includes(roleName)) {
return true;
}
}
// Fallback to legacy group system
const groups = user.value.groups || [];
return groups.includes(roleName) || groups.includes(`/${roleName}`);
};
const checkAuth = async () => {
try {
const response = await $fetch<AuthState>('/api/auth/session');
authState.value = response;
return response.authenticated;
} catch (error) {
console.error('Auth check error:', error);
authState.value = {
authenticated: false,
user: null,
groups: [],
};
// Enhanced tier-based computed properties with role support
const isUser = computed(() => {
// Check new realm roles first
if (hasRole('monaco-user')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'user';
});
// Alias for consistency with new naming convention
const isMember = isUser;
const isBoard = computed(() => {
// Check new realm roles first
if (hasRole('monaco-board')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'board';
});
const isAdmin = computed(() => {
// Check new realm roles first
if (hasRole('monaco-admin')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'admin';
});
// Enhanced tier computation with role priority
const userTier = computed(() => {
if (hasRole('monaco-admin')) return 'admin';
if (hasRole('monaco-board')) return 'board';
if (hasRole('monaco-user')) return 'user';
// Fallback to legacy tier system
return user.value?.tier || 'user';
});
const firstName = computed(() => {
if (user.value?.firstName) return user.value.firstName;
if (user.value?.name) return user.value.name.split(' ')[0];
return 'User';
});
// Enhanced helper methods
const hasTier = (requiredTier: 'user' | 'board' | 'admin') => {
// Use computed userTier which handles both new and legacy systems
return userTier.value === requiredTier;
};
const hasGroup = (groupName: string) => {
return user.value?.groups?.includes(groupName) || false;
};
// New helper methods for realm roles
const hasRealmRole = (roleName: string): boolean => {
if (!user.value) return false;
const userToken = user.value as any;
const realmRoles = userToken.realm_access?.roles || [];
return realmRoles.includes(roleName);
};
const hasClientRole = (roleName: string, clientId?: string): boolean => {
if (!user.value) return false;
const userToken = user.value as any;
const clientRoles = userToken.resource_access || {};
if (clientId) {
// Check specific client
const roles = clientRoles[clientId]?.roles || [];
return roles.includes(roleName);
} else {
// Check all clients
for (const cId in clientRoles) {
const roles = clientRoles[cId]?.roles || [];
if (roles.includes(roleName)) {
return true;
}
}
return false;
}
};
const isAdmin = computed(() => {
return authState.value.groups?.includes('admin') || false;
});
// Get all user roles (combines realm and client roles)
const getAllRoles = (): string[] => {
if (!user.value) return [];
const userToken = user.value as any;
const roles: string[] = [];
const hasRole = (role: string) => {
return authState.value.groups?.includes(role) || false;
// Add realm roles
const realmRoles = userToken.realm_access?.roles || [];
roles.push(...realmRoles);
// Add client roles
const clientRoles = userToken.resource_access || {};
for (const clientId in clientRoles) {
const clientRolesList = clientRoles[clientId]?.roles || [];
roles.push(...clientRolesList);
}
// Add legacy groups for compatibility
const groups = user.value.groups || [];
roles.push(...groups);
return [...new Set(roles)]; // Remove duplicates
};
// Direct login method
const login = async (credentials: { username: string; password: string; rememberMe?: boolean }) => {
loading.value = true;
error.value = null;
try {
console.log('🔄 Starting login request...');
const response = await $fetch<{
success: boolean;
redirectTo?: string;
user?: User;
}>('/api/auth/direct-login', {
method: 'POST',
body: credentials,
timeout: 30000 // 30 second timeout
});
console.log('✅ Login response received:', response);
if (response.success) {
// Add a small delay to ensure cookie is set before checking session
console.log('⏳ Waiting for cookie to be set...');
await new Promise(resolve => setTimeout(resolve, 200));
// After successful login, get the user data from the session
console.log('🔄 Getting user data from session...');
// Try multiple times in case of timing issues
let sessionSuccess = false;
let attempts = 0;
const maxAttempts = 3;
while (!sessionSuccess && attempts < maxAttempts) {
attempts++;
console.log(`🔄 Session check attempt ${attempts}/${maxAttempts}`);
sessionSuccess = await checkAuth();
if (!sessionSuccess && attempts < maxAttempts) {
console.log('⏳ Session not ready, waiting 500ms...');
await new Promise(resolve => setTimeout(resolve, 500));
}
}
if (sessionSuccess) {
console.log('👤 User data retrieved from session:', user.value);
// Return redirect URL for the component to handle
console.log('✅ Login successful, returning redirect URL:', response.redirectTo || '/dashboard');
return {
success: true,
redirectTo: response.redirectTo || '/dashboard'
};
} else {
console.warn('❌ Failed to get user data from session after login');
// Still return success with redirect since login was successful on server
return {
success: true,
redirectTo: '/dashboard'
};
}
}
console.warn('❌ Login response indicates failure:', response);
return { success: false, error: 'Login failed' };
} catch (err: any) {
console.error('❌ Login error caught:', err);
// Handle different types of errors
let errorMessage = 'Login failed';
if (err.status === 502) {
errorMessage = 'Server temporarily unavailable. Please try again.';
} else if (err.status === 401) {
errorMessage = 'Invalid username or password';
} else if (err.status === 429) {
errorMessage = 'Too many login attempts. Please try again later.';
} else if (err.data?.message) {
errorMessage = err.data.message;
} else if (err.message) {
errorMessage = err.message;
}
error.value = errorMessage;
return { success: false, error: errorMessage };
} finally {
loading.value = false;
}
};
// OAuth login method (fallback)
const loginOAuth = () => {
return navigateTo('/api/auth/login');
};
// Password reset method
const requestPasswordReset = async (email: string) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{
success: boolean;
message: string;
}>('/api/auth/forgot-password', {
method: 'POST',
body: { email }
});
return { success: true, message: response.message };
} catch (err: any) {
error.value = err.data?.message || 'Password reset failed';
return { success: false, error: error.value };
} finally {
loading.value = false;
}
};
// Check authentication status - simple and reliable
const checkAuth = async () => {
try {
console.log('🔄 Performing session check...');
const response = await $fetch<{
authenticated: boolean;
user: User | null;
}>('/api/auth/session');
if (response.authenticated && response.user) {
user.value = response.user;
return true;
} else {
user.value = null;
return false;
}
} catch (err) {
console.error('Auth check error:', err);
user.value = null;
return false;
}
};
// Logout method
const logout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' });
user.value = null;
await navigateTo('/login');
} catch (err) {
console.error('Logout error:', err);
user.value = null;
await navigateTo('/login');
}
};
return {
authState: readonly(authState),
user: computed(() => authState.value.user),
authenticated: computed(() => authState.value.authenticated),
groups: computed(() => authState.value.groups),
// State
user: readonly(user),
isAuthenticated,
loading: readonly(loading),
error: readonly(error),
// Tier-based properties
userTier,
isUser,
isMember, // Alias for isUser, better naming convention
isBoard,
isAdmin,
hasRole,
firstName,
// Helper methods
hasTier,
hasGroup,
hasRole, // Enhanced with realm role support
hasRealmRole,
hasClientRole,
getAllRoles,
// Actions
login,
loginOAuth,
logout,
requestPasswordReset,
checkAuth,
};
};

441
composables/useEvents.ts Normal file
View File

@@ -0,0 +1,441 @@
// composables/useEvents.ts
import type { Event, EventsResponse, EventFilters, EventCreateRequest, EventRSVPRequest } from '~/utils/types';
export const useEvents = () => {
const events = ref<Event[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const upcomingEvent = ref<Event | null>(null);
const cache = reactive<Map<string, { data: Event[]; timestamp: number }>>(new Map());
const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// Get authenticated user info
const { user, userTier } = useAuth();
/**
* Fetch events with optional filtering and caching
*/
const fetchEvents = async (filters?: EventFilters & { force?: boolean }) => {
loading.value = true;
error.value = null;
try {
// Create cache key
const cacheKey = JSON.stringify(filters || {});
const cached = cache.get(cacheKey);
// Check cache if not forcing refresh
if (!filters?.force && cached) {
const now = Date.now();
if (now - cached.timestamp < CACHE_TIMEOUT) {
events.value = cached.data;
loading.value = false;
return cached.data;
}
}
// Default date range (current month + 2 months ahead)
const defaultFilters: EventFilters = {
start_date: startOfMonth(new Date()).toISOString(),
end_date: endOfMonth(addMonths(new Date(), 2)).toISOString(),
user_role: userTier.value,
...filters
};
const response = await $fetch<EventsResponse>('/api/events', {
query: {
...defaultFilters,
calendar_format: 'false'
}
});
if (response.success) {
events.value = response.data;
// Cache the results
cache.set(cacheKey, {
data: response.data,
timestamp: Date.now()
});
// Update upcoming event
updateUpcomingEvent(response.data);
return response.data;
} else {
throw new Error(response.message || 'Failed to fetch events');
}
} catch (err: any) {
error.value = err.message || 'Failed to load events';
console.error('Error fetching events:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Create a new event (board/admin only)
*/
const createEvent = async (eventData: EventCreateRequest) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; data: Event; message: string }>('/api/events', {
method: 'POST',
body: eventData
});
if (response.success) {
// Clear cache and refresh events
cache.clear();
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to create event');
}
} catch (err: any) {
error.value = err.message || 'Failed to create event';
console.error('Error creating event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* RSVP to an event with support for guests and real-time updates
*/
const rsvpToEvent = async (eventId: string, rsvpData: Omit<EventRSVPRequest, 'event_id'> & { extra_guests?: string }) => {
loading.value = true;
error.value = null;
try {
console.log('[useEvents] RSVP to event:', eventId, 'with data:', rsvpData);
const response = await $fetch<{ success: boolean; data: any; message: string }>(`/api/events/${eventId}/rsvp`, {
method: 'POST',
body: {
...rsvpData,
event_id: eventId,
member_id: user.value?.id || ''
}
});
if (response.success) {
// Find event by event_id first, then fallback to database ID
let eventIndex = events.value.findIndex(e => e.event_id === eventId);
if (eventIndex === -1) {
eventIndex = events.value.findIndex(e => (e as any).Id === eventId || e.id === eventId);
}
console.log('[useEvents] Event found at index:', eventIndex, 'using event_id:', eventId);
if (eventIndex !== -1) {
const event = events.value[eventIndex];
// Update RSVP status
event.user_rsvp = response.data;
// Calculate attendee count including guests
if (rsvpData.rsvp_status === 'confirmed') {
const currentCount = parseInt(event.current_attendees || '0');
const guestCount = parseInt(rsvpData.extra_guests || '0');
const totalAdded = 1 + guestCount; // Member + guests
event.current_attendees = (currentCount + totalAdded).toString();
console.log('[useEvents] Updated attendee count:', {
previous: currentCount,
added: totalAdded,
new: event.current_attendees,
guests: guestCount
});
}
// Trigger reactivity
events.value[eventIndex] = { ...event };
}
// Clear cache for fresh data on next load
cache.clear();
// Force refresh events data to ensure accuracy
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to RSVP');
}
} catch (err: any) {
error.value = err.message || 'Failed to RSVP to event';
console.error('Error RSVPing to event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Cancel RSVP to an event
*/
const cancelRSVP = async (eventId: string) => {
loading.value = true;
error.value = null;
try {
// Find the event to get current RSVP info
let event = events.value.find(e => e.event_id === eventId);
if (!event) {
event = events.value.find(e => (e as any).Id === eventId || e.id === eventId);
}
if (!event?.user_rsvp) {
throw new Error('No RSVP found to cancel');
}
const response = await $fetch<{ success: boolean; data: any; message: string }>(`/api/events/${eventId}/rsvp`, {
method: 'DELETE'
});
if (response.success) {
const eventIndex = events.value.findIndex(e => e === event);
if (eventIndex !== -1) {
const currentCount = parseInt(events.value[eventIndex].current_attendees || '0');
const guestCount = parseInt(events.value[eventIndex].user_rsvp?.extra_guests || '0');
const totalRemoved = 1 + guestCount; // Member + guests
// Update attendee count and remove RSVP
events.value[eventIndex].current_attendees = Math.max(0, currentCount - totalRemoved).toString();
events.value[eventIndex].user_rsvp = undefined;
// Trigger reactivity
events.value[eventIndex] = { ...events.value[eventIndex] };
}
// Clear cache and refresh
cache.clear();
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to cancel RSVP');
}
} catch (err: any) {
error.value = err.message || 'Failed to cancel RSVP';
console.error('Error canceling RSVP:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Update attendance for an event (board/admin only)
*/
const updateAttendance = async (eventId: string, memberId: string, attended: boolean) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; data?: any; message: string }>(`/api/events/${eventId}/attendees`, {
method: 'PATCH',
body: {
event_id: eventId,
member_id: memberId,
attended
}
});
if (response.success) {
// Update local event data
const eventIndex = events.value.findIndex(e => e.id === eventId);
if (eventIndex !== -1 && events.value[eventIndex].attendee_list) {
const attendeeIndex = events.value[eventIndex].attendee_list!.findIndex(
a => a.member_id === memberId
);
if (attendeeIndex !== -1) {
events.value[eventIndex].attendee_list![attendeeIndex].attended = attended ? 'true' : 'false';
}
}
// Return data if available, otherwise return success status
return response.data || { success: true, message: response.message };
} else {
throw new Error(response.message || 'Failed to update attendance');
}
} catch (err: any) {
error.value = err.message || 'Failed to update attendance';
console.error('Error updating attendance:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Get events for calendar display
*/
const getCalendarEvents = async (start: string, end: string) => {
try {
const response = await $fetch<EventsResponse>('/api/events', {
query: {
start_date: start,
end_date: end,
user_role: userTier.value,
calendar_format: 'true'
}
});
if (response.success) {
return response.data;
}
return [];
} catch (err) {
console.error('Error fetching calendar events:', err);
return [];
}
};
/**
* Get upcoming events for banners/widgets
*/
const getUpcomingEvents = (limit = 5): Event[] => {
const now = new Date();
return events.value
.filter(event => new Date(event.start_datetime) >= now)
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime())
.slice(0, limit);
};
/**
* Find event by ID
*/
const findEventById = (eventId: string): Event | undefined => {
return events.value.find(event => event.id === eventId);
};
/**
* Check if user has RSVP'd to an event
*/
const hasUserRSVP = (eventId: string): boolean => {
const event = findEventById(eventId);
return !!event?.user_rsvp;
};
/**
* Get user's RSVP status for an event
*/
const getUserRSVPStatus = (eventId: string): string | null => {
const event = findEventById(eventId);
return event?.user_rsvp?.rsvp_status || null;
};
/**
* Update the upcoming event reference
*/
const updateUpcomingEvent = (eventList: Event[]) => {
const upcoming = eventList
.filter(event => new Date(event.start_datetime) >= new Date())
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime());
upcomingEvent.value = upcoming.length > 0 ? upcoming[0] : null;
};
/**
* Clear cache manually
*/
const clearCache = () => {
cache.clear();
};
/**
* Delete an event (board/admin only)
*/
const deleteEvent = async (eventId: string) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; message: string; deleted: any }>(`/api/events/${eventId}`, {
method: 'DELETE'
});
if (response.success) {
// Remove event from local state
const eventIndex = events.value.findIndex(e =>
e.event_id === eventId ||
e.id === eventId ||
(e as any).Id === eventId
);
if (eventIndex !== -1) {
events.value.splice(eventIndex, 1);
}
// Clear cache and refresh
clearCache();
await fetchEvents({ force: true });
return response;
} else {
throw new Error(response.message || 'Failed to delete event');
}
} catch (err: any) {
error.value = err.message || 'Failed to delete event';
console.error('Error deleting event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Refresh events data
*/
const refreshEvents = async () => {
clearCache();
return await fetchEvents({ force: true });
};
// Utility functions for date handling
function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function endOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
return {
// Reactive state
events,
loading,
error,
upcomingEvent,
// Methods
fetchEvents,
createEvent,
deleteEvent,
rsvpToEvent,
cancelRSVP,
updateAttendance,
getCalendarEvents,
getUpcomingEvents,
findEventById,
hasUserRSVP,
getUserRSVPStatus,
clearCache,
refreshEvents
};
};

18768
docs/keycloak_api.json Normal file

File diff suppressed because it is too large Load Diff

1975
docs/minio_example_guide.md Normal file

File diff suppressed because it is too large Load Diff

214
error.vue Normal file
View File

@@ -0,0 +1,214 @@
<template>
<div class="error-page">
<v-app>
<v-main>
<v-container class="fill-height">
<v-row justify="center" align="center" class="fill-height">
<v-col cols="12" md="8" lg="6" class="text-center">
<!-- Logo -->
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="120"
height="120"
class="mx-auto mb-6"
/>
<!-- Error Code -->
<h1 class="text-h1 font-weight-bold mb-4" style="color: #a31515;">
{{ error.statusCode }}
</h1>
<!-- Error Title -->
<h2 class="text-h3 mb-4 text-grey-darken-2">
{{ getErrorTitle(error.statusCode) }}
</h2>
<!-- Error Message -->
<p class="text-h6 mb-6 text-medium-emphasis" style="max-width: 600px; margin: 0 auto;">
{{ getErrorMessage(error.statusCode) }}
</p>
<!-- Additional Info for 403 -->
<v-alert
v-if="error.statusCode === 403"
type="warning"
variant="tonal"
class="mb-6 text-left"
style="max-width: 500px; margin: 0 auto;"
>
<v-alert-title>Access Restricted</v-alert-title>
<p class="mb-2">This resource requires specific permissions:</p>
<ul class="ml-4">
<li v-if="error.statusMessage?.includes('Board')">Board membership required</li>
<li v-if="error.statusMessage?.includes('Admin')">Administrator privileges required</li>
<li v-if="!error.statusMessage?.includes('Board') && !error.statusMessage?.includes('Admin')">
Higher access level required
</li>
</ul>
</v-alert>
<!-- Action Buttons -->
<div class="d-flex flex-column flex-sm-row justify-center gap-4 mb-6">
<v-btn
color="primary"
size="large"
style="background-color: #a31515;"
@click="goHome"
>
<v-icon start>mdi-home</v-icon>
Go to Dashboard
</v-btn>
<v-btn
variant="outlined"
size="large"
style="border-color: #a31515; color: #a31515;"
@click="goBack"
>
<v-icon start>mdi-arrow-left</v-icon>
Go Back
</v-btn>
</div>
<!-- Contact Support for 403 -->
<div v-if="error.statusCode === 403" class="mt-8">
<v-divider class="mb-4" />
<p class="text-body-2 text-medium-emphasis mb-3">
Need access to this resource?
</p>
<v-btn
variant="text"
color="primary"
@click="contactSupport"
>
<v-icon start>mdi-email</v-icon>
Contact Administrator
</v-btn>
</div>
<!-- Debug Info (development only) -->
<div v-if="isDevelopment" class="mt-8 pa-4 bg-grey-lighten-4 rounded">
<p class="text-caption text-grey-darken-1 mb-2">Debug Information:</p>
<p class="text-caption font-mono">{{ error.statusMessage }}</p>
<p class="text-caption font-mono">{{ error.url }}</p>
</div>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</div>
</template>
<script setup lang="ts">
interface ErrorProps {
error: {
statusCode: number;
statusMessage: string;
url?: string;
};
}
const props = defineProps<ErrorProps>();
// Check if we're in development mode
const isDevelopment = process.dev;
// Error title mapping
const getErrorTitle = (code: number): string => {
switch (code) {
case 403: return 'Access Denied';
case 404: return 'Page Not Found';
case 500: return 'Server Error';
case 401: return 'Unauthorized';
default: return 'Something Went Wrong';
}
};
// Error message mapping
const getErrorMessage = (code: number): string => {
switch (code) {
case 403:
return 'You do not have the required permissions to access this resource. Please contact your administrator if you believe this is an error.';
case 404:
return 'The page you are looking for could not be found. It may have been moved, deleted, or you may have entered the wrong URL.';
case 500:
return 'An internal server error occurred. Our team has been notified and is working to resolve the issue. Please try again later.';
case 401:
return 'You need to be logged in to access this resource. Please sign in and try again.';
default:
return 'An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.';
}
};
// Navigation methods
const goHome = () => {
navigateTo('/dashboard');
};
const goBack = () => {
if (window.history.length > 1) {
window.history.back();
} else {
navigateTo('/dashboard');
}
};
const contactSupport = () => {
// TODO: Implement support contact (email, help desk, etc.)
window.location.href = 'mailto:support@monacousa.org?subject=Access Request&body=I need access to a restricted resource.';
};
// Set page title
useHead({
title: `Error ${props.error.statusCode} - MonacoUSA Portal`,
meta: [
{ name: 'robots', content: 'noindex' }
]
});
</script>
<style scoped>
.error-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.v-main {
background: transparent !important;
}
.font-mono {
font-family: 'Courier New', monospace;
}
.v-btn {
text-transform: none !important;
}
.v-alert {
text-align: left;
}
.v-alert ul {
margin-bottom: 0;
}
.v-alert li {
margin-bottom: 4px;
}
@media (max-width: 600px) {
.text-h1 {
font-size: 4rem !important;
}
.text-h3 {
font-size: 1.75rem !important;
}
.text-h6 {
font-size: 1.1rem !important;
}
}
</style>

18768
keycloak-rest-api.json Normal file

File diff suppressed because it is too large Load Diff

667
layouts/admin.vue Normal file
View File

@@ -0,0 +1,667 @@
<template>
<v-app>
<v-navigation-drawer
v-model="drawer"
:rail="miniVariant"
:expand-on-hover="false"
permanent
width="280"
rail-width="100"
class="enhanced-glass-drawer"
>
<!-- Enhanced Logo Section -->
<v-list-item class="pa-4 text-center enhanced-glass-logo">
<template v-if="!miniVariant">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto mb-2 shimmer-animation"
/>
<div class="text-h6 font-weight-bold text-gradient">
MonacoUSA Portal
</div>
<v-chip
size="x-small"
class="glass-badge mt-1"
>
ADMINISTRATOR
</v-chip>
</template>
<template v-else>
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="40"
height="40"
class="mx-auto shimmer-animation"
/>
</template>
</v-list-item>
<v-divider class="glass-divider mx-3" />
<!-- Enhanced Navigation Menu -->
<v-list nav density="comfortable" class="enhanced-glass-nav">
<!-- Admin Overview -->
<v-tooltip
:text="miniVariant ? 'Admin Dashboard' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/admin/dashboard"
prepend-icon="mdi-view-dashboard"
:title="!miniVariant ? 'Admin Dashboard' : undefined"
value="dashboard"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- User Management -->
<v-list-group value="users" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-account-cog"
title="User Management"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/users"
title="All Users"
value="users-list"
class="glass-nav-item-sub"
/>
<v-list-item
@click="openKeycloak"
title="Keycloak Admin"
value="keycloak"
class="glass-nav-item-sub"
>
<template v-slot:append>
<v-icon size="small" class="monaco-red-text">mdi-open-in-new</v-icon>
</template>
</v-list-item>
</v-list-group>
<!-- Member Management -->
<v-list-group value="members" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-account-group"
title="Member Management"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/members"
title="All Members"
value="members-list"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- Financial Management -->
<v-list-group value="financial" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-currency-usd"
title="Financial"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/payments"
title="Payment Management"
value="payments"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- System Configuration -->
<v-list-group value="system" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-cog"
title="System"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/settings"
title="General Settings"
value="settings"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- Events Management -->
<v-tooltip
:text="miniVariant ? 'Events Management' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/admin/events"
prepend-icon="mdi-calendar"
:title="!miniVariant ? 'Events Management' : undefined"
value="events"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<v-divider class="my-2 glass-divider" />
<!-- Portal Access -->
<v-list-subheader v-if="!miniVariant" class="monaco-subheader">Portal Access</v-list-subheader>
<v-tooltip
:text="miniVariant ? 'Board Portal' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/dashboard"
prepend-icon="mdi-shield-account"
:title="!miniVariant ? 'Board Portal' : undefined"
value="board-view"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<v-tooltip
:text="miniVariant ? 'Member Portal' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/member/dashboard"
prepend-icon="mdi-account"
:title="!miniVariant ? 'Member Portal' : undefined"
value="member-view"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
</v-list>
<!-- Enhanced Profile Card -->
<template v-slot:append>
<div class="pa-2">
<v-card class="glass-profile-card overflow-visible">
<div class="d-flex align-center" :class="miniVariant ? 'flex-column py-3 px-2' : 'pa-3'">
<!-- Avatar Section -->
<ProfileAvatar
:member-id="memberData?.member_id || memberData?.Id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:member-name="memberData?.FullName || user?.name"
:size="miniVariant ? '32' : 'small'"
:class="miniVariant ? '' : 'mr-3'"
/>
<!-- Info Section (Hidden in mini mode) -->
<div v-if="!miniVariant" class="flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">{{ user?.name || 'Administrator' }}</div>
<div class="text-caption text-medium-emphasis">{{ user?.email?.split('@')[0] || 'admin' }}</div>
<v-chip size="x-small" class="mt-1 glass-badge">Admin</v-chip>
</div>
<!-- Action Buttons -->
<div :class="miniVariant ? 'mt-2' : 'ml-auto'">
<v-menu location="top" offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
:size="miniVariant ? 'small' : 'small'"
variant="text"
class="profile-menu-btn"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list density="compact" class="glass-menu" min-width="200">
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="primary">mdi-account-circle</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="info">mdi-cog-outline</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-1 glass-divider" />
<v-list-item @click="logout" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-logout-variant</v-icon>
</template>
<v-list-item-title class="text-error">Sign Out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</v-card>
</div>
</template>
</v-navigation-drawer>
<v-app-bar elevation="0" flat class="glass-app-bar admin-bar">
<v-btn
icon
@click="toggleDrawer"
class="glass-icon-btn mr-2"
>
<v-icon>{{ miniVariant ? 'mdi-menu' : 'mdi-menu-open' }}</v-icon>
</v-btn>
<v-toolbar-title class="font-weight-bold d-flex align-center text-white">
Admin Portal
<v-chip
size="x-small"
class="ml-2 glass-chip"
>
FULL ACCESS
</v-chip>
</v-toolbar-title>
<v-spacer />
<!-- System Status Indicator with Glass Effect -->
<v-chip
:color="systemStatus === 'healthy' ? 'success' : 'warning'"
variant="flat"
size="small"
class="mr-2 glass-chip"
>
<v-icon start size="small">
{{ systemStatus === 'healthy' ? 'mdi-check-circle' : 'mdi-alert' }}
</v-icon>
System {{ systemStatus }}
</v-chip>
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="glass-icon-btn">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="250" class="glass-dropdown">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'Administrator' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-chip
size="x-small"
class="monaco-chip-gradient"
>
ADMINISTRATOR
</v-chip>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item to="/board/dashboard" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-shield-account</v-icon>
</template>
<v-list-item-title>Board Portal</v-list-item-title>
</v-list-item>
<v-list-item to="/member/dashboard" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account-switch</v-icon>
</template>
<v-list-item-title>Member Portal</v-list-item-title>
</v-list-item>
<v-list-item to="/admin/settings" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>System Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item @click="handleLogout" class="glass-dropdown-item text-error">
<template v-slot:prepend>
<v-icon color="error">mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main class="glass-main">
<v-container fluid class="pa-6">
<!-- System Alerts Banner with Glass Effect -->
<v-alert
v-if="systemAlerts.length > 0"
type="warning"
variant="tonal"
closable
class="mb-4 glass-alert"
>
<v-alert-title>System Alerts</v-alert-title>
<ul class="mt-2">
<li v-for="alert in systemAlerts" :key="alert.id">
{{ alert.message }}
</li>
</ul>
</v-alert>
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
const { user, logout } = useAuth();
const drawer = ref(true);
const miniVariant = ref(false);
const alerts = ref(0);
const systemStatus = ref<'healthy' | 'warning' | 'error'>('healthy');
const systemAlerts = ref<Array<{ id: number; message: string }>>([]);
// Fetch member data
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Load admin-specific data
onMounted(async () => {
try {
// Check system health
const healthCheck = await $fetch('/api/admin/system/health');
systemStatus.value = healthCheck?.data?.status || 'healthy';
// Get critical alerts
const alertsRes = await $fetch('/api/admin/alerts');
alerts.value = alertsRes?.data?.count || 0;
systemAlerts.value = alertsRes?.data?.alerts || [];
} catch (error) {
console.error('Error fetching admin data:', error);
systemStatus.value = 'warning';
}
});
const openKeycloak = () => {
window.open('https://auth.monacousa.org/admin', '_blank');
};
const toggleDrawer = () => {
miniVariant.value = !miniVariant.value;
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024;
}, { immediate: true });
</script>
<style scoped lang="scss">
@import '~/assets/scss/main.scss';
// Glass Drawer Styles
.glass-drawer {
@include glass-effect(0.95, 30px);
border-right: 1px solid rgba(255, 255, 255, 0.2) !important;
}
.glass-logo-section {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(255, 255, 255, 0.8) 100%);
border-radius: 16px;
margin-bottom: 8px;
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// Monaco Text Colors
.monaco-red-text {
color: #dc2626 !important;
}
.monaco-muted-text {
color: #71717a;
}
// Glass Navigation Items
.glass-nav-list {
background: transparent !important;
}
.glass-nav-item {
border-radius: 12px !important;
margin: 4px 12px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
transform: translateX(2px);
}
&.v-list-item--active {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.15) 0%,
rgba(220, 38, 38, 0.08) 100%) !important;
color: #dc2626 !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 70%;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
border-radius: 0 2px 2px 0;
}
.v-icon {
color: #dc2626 !important;
}
}
}
.glass-nav-item-sub {
padding-left: 52px !important;
border-radius: 8px !important;
margin: 2px 12px 2px 24px !important;
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.03) !important;
}
&.v-list-item--active {
background: rgba(220, 38, 38, 0.08) !important;
color: #dc2626 !important;
}
}
// Monaco Subheader
.monaco-subheader {
color: #dc2626 !important;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
// Glass Divider
.glass-divider {
opacity: 0.2;
border-color: rgba(220, 38, 38, 0.2);
}
// Admin App Bar with Gradient
.admin-bar {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.95) 0%,
rgba(153, 27, 27, 0.95) 100%) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
// Glass Icon Buttons
.glass-icon-btn {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
color: white !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
// Glass Chips
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white !important;
}
.monaco-chip-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
border: none;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.25);
}
// Glass Dropdown
.glass-dropdown {
@include glass-effect(0.95, 20px);
border-radius: 12px !important;
overflow: hidden;
}
.glass-dropdown-item {
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
}
}
// Glass Input
.glass-input {
:deep(.v-field) {
background: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(220, 38, 38, 0.2);
}
}
// Glass Alert
.glass-alert {
@include glass-effect(0.8, 15px);
border: 1px solid rgba(245, 158, 11, 0.2) !important;
}
// Glass Main Background
.glass-main {
background: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(220, 38, 38, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
}
// Responsive adjustments
@media (max-width: 1024px) {
.glass-nav-item {
margin: 2px 8px !important;
}
.glass-nav-item-sub {
margin: 2px 8px 2px 16px !important;
}
}
</style>

743
layouts/board.vue Normal file
View File

@@ -0,0 +1,743 @@
<template>
<v-app style="background-color: #fafafa;">
<v-navigation-drawer
v-model="drawer"
:rail="miniVariant"
:expand-on-hover="false"
permanent
width="280"
rail-width="100"
class="enhanced-glass-drawer"
>
<!-- Enhanced Logo Section -->
<v-list-item class="pa-4 text-center enhanced-glass-logo">
<template v-if="!miniVariant">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto mb-2 shimmer-animation"
/>
<div class="text-h6 font-weight-bold text-gradient">
MonacoUSA Portal
</div>
<v-chip
size="x-small"
class="glass-badge mt-1"
>
BOARD MEMBER
</v-chip>
</template>
<template v-else>
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="40"
height="40"
class="mx-auto shimmer-animation"
/>
</template>
</v-list-item>
<v-divider class="glass-divider mx-3" />
<!-- Enhanced Navigation Menu -->
<v-list nav density="comfortable" class="enhanced-glass-nav">
<!-- Board Overview -->
<v-tooltip
:text="miniVariant ? 'Board Dashboard' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/dashboard"
prepend-icon="mdi-view-dashboard"
:title="!miniVariant ? 'Board Dashboard' : undefined"
value="dashboard"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Member Management -->
<v-list-group value="members" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-account-group"
title="Members"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/board/members"
title="Member Directory"
value="member-list"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/members/dues"
title="Dues Management"
value="dues"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/members/applications"
title="Applications"
value="applications"
class="glass-nav-item-sub"
>
<template v-slot:append>
<v-badge
:content="pendingApplications"
:value="pendingApplications > 0"
color="error"
class="glass-badge"
/>
</template>
</v-list-item>
</v-list-group>
<!-- Member Management (Collapsed) -->
<v-tooltip
v-if="miniVariant"
text="Members"
location="end"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/members"
prepend-icon="mdi-account-group"
value="members-collapsed"
class="glass-nav-item animated-nav-item"
>
<template v-if="pendingApplications > 0" v-slot:append>
<v-badge
:content="pendingApplications"
color="error"
class="glass-badge"
/>
</template>
</v-list-item>
</template>
</v-tooltip>
<!-- Events & Meetings -->
<v-list-group value="events" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-calendar"
title="Events & Meetings"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/board/events"
title="All Events"
value="events"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/meetings"
title="Board Meetings"
value="meetings"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/meetings/minutes"
title="Meeting Minutes"
value="minutes"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- Events & Meetings (Collapsed) -->
<v-tooltip
v-if="miniVariant"
text="Events & Meetings"
location="end"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/events"
prepend-icon="mdi-calendar"
value="events-collapsed"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Reports & Analytics -->
<v-tooltip
:text="miniVariant ? 'Reports & Analytics' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/reports"
prepend-icon="mdi-chart-box"
:title="!miniVariant ? 'Reports & Analytics' : undefined"
value="reports"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Governance -->
<v-tooltip
:text="miniVariant ? 'Governance' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/governance"
prepend-icon="mdi-gavel"
:title="!miniVariant ? 'Governance' : undefined"
value="governance"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Communications -->
<v-tooltip
:text="miniVariant ? 'Communications' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/communications"
prepend-icon="mdi-email-newsletter"
:title="!miniVariant ? 'Communications' : undefined"
value="communications"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<v-divider class="my-2 glass-divider" />
<!-- Member Section Access -->
<v-list-subheader v-if="!miniVariant" class="monaco-subheader">Member Portal</v-list-subheader>
<v-tooltip
:text="miniVariant ? 'Member View' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/member/dashboard"
prepend-icon="mdi-account"
:title="!miniVariant ? 'Member View' : undefined"
value="member-view"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
</v-list>
<!-- Enhanced Profile Card -->
<template v-slot:append>
<div class="pa-2">
<v-card class="glass-profile-card overflow-visible" style="background: linear-gradient(135deg, rgba(220, 38, 38, 0.08), rgba(255, 255, 255, 0.95)); border: 1px solid rgba(220, 38, 38, 0.2);">
<div class="d-flex align-center" :class="miniVariant ? 'flex-column py-3 px-2' : 'pa-3'">
<!-- Avatar Section -->
<div style="position: relative;">
<ProfileAvatar
:member-id="memberData?.member_id || memberData?.Id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:member-name="memberData?.FullName || user?.name"
:size="miniVariant ? '32' : '48'"
:class="miniVariant ? '' : 'mr-3'"
show-border
style="border: 2px solid #dc2626; box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2);"
/>
<v-icon
v-if="!miniVariant"
size="16"
color="green"
style="position: absolute; bottom: 0; right: 12px; background: white; border-radius: 50%; padding: 2px;"
>
mdi-check-circle
</v-icon>
</div>
<!-- Info Section (Hidden in mini mode) -->
<div v-if="!miniVariant" class="flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">{{ user?.name || 'Board Member' }}</div>
<div class="text-caption text-medium-emphasis">{{ user?.email?.split('@')[0] || 'board' }}</div>
<v-chip size="x-small" class="mt-1" style="background: linear-gradient(135deg, #dc2626, #b91c1c); color: white;">
<v-icon start size="12">mdi-shield-check</v-icon>
Board
</v-chip>
</div>
<!-- Action Buttons -->
<div :class="miniVariant ? 'mt-2' : 'ml-auto'">
<v-menu location="top" offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
:size="miniVariant ? 'small' : 'small'"
variant="tonal"
color="primary"
class="profile-menu-btn"
style="background: rgba(220, 38, 38, 0.1);"
>
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
<v-list density="compact" class="glass-menu" min-width="200">
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="primary">mdi-account-circle</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="info">mdi-cog-outline</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-1 glass-divider" />
<v-list-item @click="logout" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-logout-variant</v-icon>
</template>
<v-list-item-title class="text-error">Sign Out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</v-card>
</div>
</template>
</v-navigation-drawer>
<v-app-bar elevation="0" flat class="glass-app-bar board-bar">
<v-toolbar-title class="font-weight-bold text-white">
Board Portal
</v-toolbar-title>
<v-spacer />
<!-- Quick Actions with Glass Effects -->
<v-btn
icon
class="glass-icon-btn"
@click="toggleSearch"
>
<v-icon>mdi-magnify</v-icon>
</v-btn>
<!-- Move hamburger menu to the right side -->
<v-btn
icon
@click="toggleDrawer"
class="glass-icon-btn ml-2"
>
<v-icon>{{ miniVariant ? 'mdi-menu' : 'mdi-menu-open' }}</v-icon>
</v-btn>
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="glass-icon-btn">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="250" class="glass-dropdown">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'Board Member' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-chip
size="x-small"
class="monaco-chip-gradient"
>
BOARD MEMBER
</v-chip>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item to="/board/profile" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>Board Profile</v-list-item-title>
</v-list-item>
<v-list-item to="/member/dashboard" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account-switch</v-icon>
</template>
<v-list-item-title>Member Portal</v-list-item-title>
</v-list-item>
<v-list-item to="/board/settings" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item @click="handleLogout" class="glass-dropdown-item text-error">
<template v-slot:prepend>
<v-icon color="error">mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<!-- Search Overlay with Glass Effect -->
<v-dialog v-model="searchOpen" max-width="600" persistent>
<v-card class="glass-card">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2 monaco-red-text">mdi-magnify</v-icon>
Search Members
<v-spacer />
<v-btn icon @click="searchOpen = false" class="glass-icon-btn-dark">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-text-field
v-model="searchQuery"
label="Search by name, email, or member ID"
prepend-inner-icon="mdi-magnify"
variant="outlined"
autofocus
@keyup.enter="performSearch"
class="glass-input"
/>
</v-card-text>
</v-card>
</v-dialog>
<v-main class="glass-main">
<v-container fluid class="pa-6">
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
const { user, logout } = useAuth();
const drawer = ref(true);
const miniVariant = ref(false);
const notifications = ref(0);
const pendingApplications = ref(0);
const searchOpen = ref(false);
const searchQuery = ref('');
// Fetch member data
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Load board-specific notifications
onMounted(async () => {
try {
const [notificationsRes, applicationsRes] = await Promise.all([
$fetch('/api/board/notifications/count'),
$fetch('/api/board/applications/pending/count')
]);
notifications.value = notificationsRes?.data?.count || 0;
pendingApplications.value = applicationsRes?.data?.count || 0;
} catch (error) {
console.error('Error fetching board data:', error);
}
});
const toggleDrawer = () => {
miniVariant.value = !miniVariant.value;
};
const toggleSearch = () => {
searchOpen.value = true;
};
const performSearch = () => {
if (searchQuery.value) {
navigateTo(`/board/members?search=${encodeURIComponent(searchQuery.value)}`);
searchOpen.value = false;
searchQuery.value = '';
}
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024;
}, { immediate: true });
</script>
<style scoped lang="scss">
@import '~/assets/scss/main.scss';
// Glass Drawer Styles
.glass-drawer {
@include glass-effect(0.95, 30px);
border-right: 1px solid rgba(255, 255, 255, 0.2) !important;
}
.glass-logo-section {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(255, 255, 255, 0.8) 100%);
border-radius: 16px;
margin-bottom: 8px;
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// Monaco Text Colors
.monaco-red-text {
color: #dc2626 !important;
}
// Glass Navigation Items
.glass-nav-list {
background: transparent !important;
}
.glass-nav-item {
border-radius: 12px !important;
margin: 4px 12px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
transform: translateX(2px);
}
&.v-list-item--active {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.15) 0%,
rgba(220, 38, 38, 0.08) 100%) !important;
color: #dc2626 !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 70%;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
border-radius: 0 2px 2px 0;
}
.v-icon {
color: #dc2626 !important;
}
}
}
.glass-nav-item-sub {
padding-left: 52px !important;
border-radius: 8px !important;
margin: 2px 12px 2px 24px !important;
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.03) !important;
}
&.v-list-item--active {
background: rgba(220, 38, 38, 0.08) !important;
color: #dc2626 !important;
}
}
// Monaco Subheader
.monaco-subheader {
color: #dc2626 !important;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
// Glass Divider
.glass-divider {
opacity: 0.2;
border-color: rgba(220, 38, 38, 0.2);
}
// Board App Bar with Gradient
.board-bar {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.9) 0%,
rgba(124, 45, 18, 0.9) 100%) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
// Glass Icon Buttons
.glass-icon-btn {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
color: white !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.glass-icon-btn-dark {
background: rgba(0, 0, 0, 0.05) !important;
backdrop-filter: blur(10px);
color: #71717a !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(0, 0, 0, 0.1) !important;
transform: translateY(-1px);
}
}
// Glass Badge
.glass-badge {
:deep(.v-badge__badge) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
}
// Monaco Chip
.monaco-chip-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
border: none;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.25);
}
// Glass Dropdown
.glass-dropdown {
@include glass-effect(0.95, 20px);
border-radius: 12px !important;
overflow: hidden;
}
.glass-dropdown-item {
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
}
}
// Glass Input
.glass-input {
:deep(.v-field) {
background: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(220, 38, 38, 0.2);
}
}
// Glass Main Background
.glass-main {
background-color: #fafafa; // Solid fallback for Edge and other browsers
background-image: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
background: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(220, 38, 38, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
}
// Responsive adjustments
@media (max-width: 1024px) {
.glass-nav-item {
margin: 2px 8px !important;
}
.glass-nav-item-sub {
margin: 2px 8px 2px 16px !important;
}
}
</style>

280
layouts/dashboard.vue Normal file
View File

@@ -0,0 +1,280 @@
<template>
<v-app>
<v-navigation-drawer v-model="drawer" app width="280">
<!-- Logo Section -->
<v-list-item class="pa-4 text-center">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto mb-2"
/>
<div class="text-h6 font-weight-bold" style="color: #a31515;">
MonacoUSA Portal
</div>
</v-list-item>
<v-divider />
<!-- Navigation Menu -->
<v-list nav>
<!-- Always visible items -->
<v-list-item
to="/dashboard"
prepend-icon="mdi-view-dashboard"
title="Dashboard"
value="dashboard"
/>
<v-list-item
to="/dashboard/events"
prepend-icon="mdi-calendar"
title="Events"
value="events"
/>
<v-list-item
to="/dashboard/user"
prepend-icon="mdi-account"
title="My Profile"
value="profile"
/>
<!-- Board-only items -->
<template v-if="isBoard || isAdmin">
<v-divider class="my-2" />
<v-list-subheader>Board Tools</v-list-subheader>
<v-list-item
to="/dashboard/member-list"
prepend-icon="mdi-account-group"
title="Member List"
value="members"
/>
<v-list-item
to="/dashboard/board"
prepend-icon="mdi-shield-account"
title="Board Dashboard"
value="board-dashboard"
/>
</template>
<!-- Admin-only items -->
<template v-if="isAdmin">
<v-divider class="my-2" />
<v-list-subheader>Administration</v-list-subheader>
<v-list-item
@click="openUserManagement"
prepend-icon="mdi-account-cog"
title="Manage Users"
value="admin-users"
/>
<v-list-item
to="/dashboard/admin"
prepend-icon="mdi-cog"
title="Admin Panel"
value="admin-panel"
/>
</template>
</v-list>
<!-- Footer -->
<template v-slot:append>
<div class="pa-4 text-center">
<v-chip
:color="getTierColor(userTier)"
size="small"
variant="elevated"
>
<v-icon start :icon="getTierIcon(userTier)" />
{{ userTier.toUpperCase() }}
</v-chip>
</div>
</template>
</v-navigation-drawer>
<v-app-bar app color="primary" elevation="2">
<!-- MonacoUSA Logo -->
<MonacoUSALogo
size="small"
variant="white"
class="mr-2"
/>
<v-toolbar-title class="text-white font-weight-bold">
MonacoUSA Portal
</v-toolbar-title>
<v-spacer />
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" color="white">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="200">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'User' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-subtitle>
<v-chip
:color="getTierColor(userTier)"
size="x-small"
variant="flat"
>
{{ userTier.toUpperCase() }} TIER
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-divider />
<v-list-item @click="navigateToProfile">
<v-list-item-title>
<v-icon start>mdi-account</v-icon>
Profile
</v-list-item-title>
</v-list-item>
<v-list-item @click="navigateToSettings">
<v-list-item-title>
<v-icon start>mdi-cog</v-icon>
Settings
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item @click="handleLogout" class="text-error">
<v-list-item-title>
<v-icon start>mdi-logout</v-icon>
Logout
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main>
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<v-container fluid>
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
const { user, userTier, isBoard, isAdmin, logout } = useAuth();
const drawer = ref(true);
// Fetch complete member data for profile avatar
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Helper functions
const getTierColor = (tier: string) => {
switch (tier) {
case 'admin': return 'error';
case 'board': return 'primary';
case 'user': return 'info';
default: return 'grey';
}
};
const getTierIcon = (tier: string) => {
switch (tier) {
case 'admin': return 'mdi-shield-crown';
case 'board': return 'mdi-shield-account';
case 'user': return 'mdi-account';
default: return 'mdi-account';
}
};
// Navigation methods
const openUserManagement = () => {
window.open('https://auth.monacousa.org', '_blank');
};
const navigateToProfile = () => {
navigateTo('/dashboard/profile');
};
const navigateToSettings = () => {
navigateTo('/dashboard/admin');
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024; // Show drawer on desktop by default
}, { immediate: true });
</script>
<style scoped>
.v-navigation-drawer {
border-right: 1px solid rgba(0, 0, 0, 0.12);
}
.v-list-item {
border-radius: 8px;
margin: 2px 8px;
}
.v-list-item--active {
background-color: rgba(163, 21, 21, 0.1) !important;
color: #a31515 !important;
}
.v-list-item--active .v-icon {
color: #a31515 !important;
}
.v-list-subheader {
color: #a31515 !important;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.v-app-bar {
background: linear-gradient(135deg, #a31515 0%, #8b1212 100%) !important;
}
.v-main {
background-color: #f8f9fa;
}
</style>

640
layouts/member.vue Normal file
View File

@@ -0,0 +1,640 @@
<template>
<v-app style="background-color: #fafafa;">
<v-navigation-drawer
v-model="drawer"
:rail="miniVariant"
:expand-on-hover="false"
permanent
width="280"
rail-width="100"
class="enhanced-glass-drawer"
>
<!-- Logo Section with Enhanced Glass Effect -->
<v-list-item class="logo-section">
<template v-if="!miniVariant">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto logo-image mb-2"
/>
<div class="logo-text">
<div class="text-h6 font-weight-bold monaco-red-text">
MonacoUSA Portal
</div>
<v-chip
size="x-small"
class="monaco-chip-gradient mt-1"
>
MEMBER
</v-chip>
</div>
</template>
<template v-else>
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="40"
height="40"
class="mx-auto logo-image"
/>
</template>
</v-list-item>
<v-divider class="glass-divider" />
<!-- Navigation Menu with Enhanced Effects -->
<v-list nav class="enhanced-nav-list">
<template v-for="item in navigationItems" :key="item.value">
<v-tooltip
:text="item.title"
location="right"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
:to="item.to"
:prepend-icon="item.icon"
:title="!miniVariant ? item.title : undefined"
:value="item.value"
class="nav-item-enhanced"
v-bind="props"
>
<template v-if="item.badge" v-slot:append>
<v-badge
:content="item.badge"
color="error"
inline
:dot="miniVariant"
/>
</template>
</v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
<!-- Enhanced Profile Footer -->
<template v-slot:append>
<div class="pa-2">
<v-card class="glass-profile-card overflow-visible">
<div class="d-flex align-center" :class="miniVariant ? 'flex-column py-3 px-2' : 'pa-3'">
<!-- Avatar Section -->
<div class="position-relative">
<ProfileAvatar
v-if="memberData"
:member-id="memberData?.member_id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:size="miniVariant ? '32' : 'small'"
:class="miniVariant ? '' : 'mr-3'"
:show-badge="false"
/>
<div v-if="!miniVariant" class="online-indicator" />
</div>
<!-- Info Section (Hidden in mini mode) -->
<div v-if="!miniVariant" class="flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">{{ fullName }}</div>
<div class="text-caption text-medium-emphasis">{{ email?.split('@')[0] || 'member' }}</div>
<v-chip size="x-small" class="mt-1 glass-badge">Member</v-chip>
</div>
<!-- Action Buttons -->
<div :class="miniVariant ? 'mt-2' : 'ml-auto'">
<v-menu location="top" offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
:size="miniVariant ? 'small' : 'small'"
variant="text"
class="profile-menu-btn"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list density="compact" class="glass-menu" min-width="200">
<v-list-item to="/member/profile" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="primary">mdi-account-circle</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item to="/member/settings" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="info">mdi-cog-outline</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-1 glass-divider" />
<v-list-item @click="handleLogout" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-logout-variant</v-icon>
</template>
<v-list-item-title class="text-error">Sign Out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</v-card>
</div>
</template>
</v-navigation-drawer>
<v-app-bar elevation="0" flat class="glass-app-bar member-bar">
<v-btn
icon
@click="toggleDrawer"
class="glass-icon-btn mr-2"
>
<v-icon>{{ miniVariant ? 'mdi-menu' : 'mdi-menu-open' }}</v-icon>
</v-btn>
<v-toolbar-title class="font-weight-bold text-white">
Member Portal
</v-toolbar-title>
<v-spacer />
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="glass-icon-btn">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="250" class="glass-dropdown">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'Member' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-chip
size="x-small"
class="monaco-chip-gradient"
>
MEMBER
</v-chip>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item to="/member/profile" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item to="/member/settings" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item @click="handleLogout" class="glass-dropdown-item text-error">
<template v-slot:prepend>
<v-icon color="error">mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main class="glass-main">
<!-- Dues Payment Banner with Glass Effect -->
<DuesPaymentBanner />
<v-container fluid class="pa-6">
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
const { user, logout } = useAuth();
const drawer = ref(true);
const miniVariant = ref(false);
const notifications = ref(0);
// Navigation items configuration
const navigationItems = ref([
{
to: '/member/dashboard',
icon: 'mdi-view-dashboard',
title: 'Dashboard',
value: 'dashboard'
},
{
to: '/member/profile',
icon: 'mdi-account',
title: 'My Profile',
value: 'profile'
},
{
to: '/member/events',
icon: 'mdi-calendar',
title: 'Events',
value: 'events',
badge: '3' // Example badge
},
{
to: '/member/payments',
icon: 'mdi-credit-card',
title: 'Payments & Dues',
value: 'payments'
},
{
to: '/member/resources',
icon: 'mdi-book-open-variant',
title: 'Resources',
value: 'resources'
}
]);
// Fetch member data
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Computed properties for profile
const fullName = computed(() => {
if (memberData.value) {
return `${memberData.value.first_name} ${memberData.value.last_name}`;
}
return user.value?.name || 'Member';
});
const email = computed(() => memberData.value?.email || user.value?.email || '');
// Check for notifications
onMounted(async () => {
// Check for upcoming events, dues reminders, etc.
try {
const { data } = await $fetch('/api/member/notifications/count');
notifications.value = data?.count || 0;
} catch (error) {
console.error('Error fetching notifications:', error);
}
});
const toggleDrawer = () => {
miniVariant.value = !miniVariant.value;
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024;
}, { immediate: true });
</script>
<style scoped lang="scss">
@import '~/assets/scss/main.scss';
// Enhanced Glass Drawer Styles
.enhanced-glass-drawer {
@include enhanced-glass(0.95, 30px);
border-right: 1px solid rgba(255, 255, 255, 0.2) !important;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logo-section {
position: relative;
padding: 1.5rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(248, 248, 248, 0.9) 100%);
border-radius: 16px;
margin: 0.5rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
&--collapsed {
padding: 0.75rem;
.logo-image {
margin: 0 auto;
}
}
.logo-image {
transition: all 0.3s ease;
}
.logo-text {
text-align: center;
}
.collapse-btn {
position: absolute;
right: -0.5rem;
top: 50%;
transform: translateY(-50%);
background: white;
border: 1px solid rgba(220, 38, 38, 0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
background: rgba(220, 38, 38, 0.05);
}
}
}
// Monaco Text Colors
.monaco-red-text {
color: #dc2626 !important;
}
// Enhanced Navigation Items
.enhanced-nav-list {
background: transparent !important;
padding: 0.5rem;
}
.nav-item-enhanced {
border-radius: 12px !important;
margin: 4px 8px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative;
overflow: hidden;
@include ripple-effect();
&:hover {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.08) 0%,
rgba(220, 38, 38, 0.04) 100%) !important;
transform: translateX(4px);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(220, 38, 38, 0.05) 50%,
transparent 100%);
animation: shimmer 1s ease-in-out;
}
}
&.v-list-item--active {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.15) 0%,
rgba(220, 38, 38, 0.08) 100%) !important;
color: #dc2626 !important;
@include sliding-indicator();
.v-icon {
color: #dc2626 !important;
animation: pulse 2s ease-in-out infinite;
}
}
.v-list-item__prepend {
.v-icon {
transition: all 0.3s ease;
}
}
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
// Glass Divider
.glass-divider {
opacity: 0.2;
border-color: rgba(220, 38, 38, 0.2);
}
// Member App Bar with Gradient
.member-bar {
background: linear-gradient(135deg,
rgba(239, 68, 68, 0.9) 0%,
rgba(220, 38, 38, 0.9) 100%) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
// Glass Icon Buttons
.glass-icon-btn {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
color: white !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
// Monaco Chip
.monaco-chip-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
border: none;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.25);
}
// Glass Dropdown
.glass-dropdown {
@include glass-effect(0.95, 20px);
border-radius: 12px !important;
overflow: hidden;
}
.glass-dropdown-item {
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
}
}
// Glass Main Background
.glass-main {
background-color: #fafafa; // Solid fallback for Edge and other browsers
background-image: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
background: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(220, 38, 38, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
}
// Profile Footer Styles
.profile-footer {
padding: 0.5rem;
}
.profile-card-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
border-radius: 12px;
margin: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
&:hover {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
.profile-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
position: relative;
.profile-menu-btn {
position: absolute;
top: -0.5rem;
right: -0.5rem;
opacity: 0.6;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
}
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
.online-indicator {
position: absolute;
bottom: -2px;
right: -2px;
width: 10px;
height: 10px;
background: #22c55e;
border: 2px solid white;
border-radius: 50%;
animation: pulse-online 2s ease-in-out infinite;
}
}
.profile-info {
text-align: center;
width: 100%;
.profile-name {
font-size: 0.925rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-email {
font-size: 0.8rem;
color: rgb(107, 114, 128);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@keyframes pulse-online {
0%, 100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(34, 197, 94, 0);
}
}
// Fade transition
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// Responsive adjustments
@media (max-width: 1024px) {
.nav-item-enhanced {
margin: 2px 8px !important;
}
}
</style>

17
middleware/admin.ts Normal file
View File

@@ -0,0 +1,17 @@
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isAdmin } = useAuth();
// Check if user is authenticated
if (!isAuthenticated.value) {
return navigateTo('/login');
}
// Check if user has admin privileges
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Administrator privileges required.'
});
}
});

14
middleware/auth-admin.ts Normal file
View File

@@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Administrator privileges required.'
});
}
});

14
middleware/auth-board.ts Normal file
View File

@@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isBoard, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
if (!isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Board membership required.'
});
}
});

7
middleware/auth-user.ts Normal file
View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
});

View File

@@ -1,17 +1,18 @@
export default defineNuxtRouteMiddleware((to) => {
export default defineNuxtRouteMiddleware(async (to) => {
// Skip auth for public pages
if (to.meta.auth === false) {
return;
}
// Check if user is authenticated
const authState = useState('auth.state', () => ({
authenticated: false,
user: null,
groups: [],
}));
// Use the same auth system as the rest of the app
const { isAuthenticated, checkAuth, user } = useAuth();
if (!authState.value.authenticated) {
// Ensure auth is checked if user isn't loaded
if (!user.value) {
await checkAuth();
}
if (!isAuthenticated.value) {
return navigateTo('/login');
}
});

15
middleware/board.ts Normal file
View File

@@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isBoard, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
// Only board members and admins can access board pages
if (!isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Board member privileges required.'
});
}
});

8
middleware/guest.ts Normal file
View File

@@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { user } = useAuth();
// If user is already authenticated, redirect to dashboard
if (user.value) {
return navigateTo('/dashboard');
}
});

15
middleware/member.ts Normal file
View File

@@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isUser, isBoard, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
// Members, board members, and admins can all access member pages
if (!isUser.value && !isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Member privileges required.'
});
}
});

View File

@@ -31,6 +31,13 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
}
location ^~ /.well-known/acme-challenge/ {

View File

@@ -14,129 +14,63 @@ export default defineNuxtConfig({
console.log(`🌐 Server listening on http://${host}:${port}`)
}
},
modules: ["vuetify-nuxt-module", "@vite-pwa/nuxt", "motion-v/nuxt"],
modules: ["vuetify-nuxt-module", "@vueuse/motion/nuxt"],
css: ["~/assets/scss/main.scss"],
app: {
head: {
titleTemplate: "%s • MonacoUSA Portal",
title: "MonacoUSA Portal",
meta: [
{ property: "og:title", content: "MonacoUSA Portal" },
{ property: "og:image", content: "/og-image.png" },
{ property: "og:image", content: "/MONACOUSA-Flags_376x376.png" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-status-bar-style", content: "default" },
{ name: "apple-mobile-web-app-title", content: "MonacoUSA Portal" },
{ name: "theme-color", content: "#a31515" },
],
link: [
{ rel: "icon", type: "image/png", sizes: "32x32", href: "/favicon-32x32.png" },
{ rel: "icon", type: "image/png", sizes: "192x192", href: "/icon-192x192.png" },
{ rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" },
{ rel: "shortcut icon", href: "/favicon-32x32.png" },
],
htmlAttrs: {
lang: "en",
},
},
},
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'MonacoUSA Portal',
short_name: 'MonacoUSA',
description: 'MonacoUSA Portal - Unified dashboard for tools and services',
theme_color: '#a31515',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
scope: '/',
icons: [
{
src: '/icons/icon-72x72.png',
sizes: '72x72',
type: 'image/png'
},
{
src: '/icons/icon-96x96.png',
sizes: '96x96',
type: 'image/png'
},
{
src: '/icons/icon-128x128.png',
sizes: '128x128',
type: 'image/png'
},
{
src: '/icons/icon-144x144.png',
sizes: '144x144',
type: 'image/png'
},
{
src: '/icons/icon-152x152.png',
sizes: '152x152',
type: 'image/png'
},
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png'
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\.monacousa\.org\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
],
skipWaiting: true,
clientsClaim: true
},
client: {
installPrompt: true,
periodicSyncForUpdates: 20
},
devOptions: {
enabled: true,
type: 'module'
}
},
nitro: {
experimental: {
wasm: true
}
},
vite: {
optimizeDeps: {
exclude: ['sharp']
}
},
runtimeConfig: {
// Server-side configuration
keycloak: {
issuer: process.env.NUXT_KEYCLOAK_ISSUER || "",
clientId: process.env.NUXT_KEYCLOAK_CLIENT_ID || "monacousa-portal",
clientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET || "",
callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL || "https://monacousa.org/auth/callback",
callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL || "https://portal.monacousa.org/auth/callback",
},
keycloakAdmin: {
issuer: process.env.NUXT_KEYCLOAK_ISSUER || "",
clientId: process.env.NUXT_KEYCLOAK_ADMIN_CLIENT_ID || "admin-cli",
clientSecret: process.env.NUXT_KEYCLOAK_ADMIN_CLIENT_SECRET || "",
},
nocodb: {
url: process.env.NUXT_NOCODB_URL || "",
token: process.env.NUXT_NOCODB_TOKEN || "",
baseId: process.env.NUXT_NOCODB_BASE_ID || "",
eventsBaseId: process.env.NUXT_NOCODB_EVENTS_BASE_ID || "",
eventsTableId: process.env.NUXT_NOCODB_EVENTS_TABLE_ID || "",
rsvpTableId: process.env.NUXT_NOCODB_RSVP_TABLE_ID || "",
},
minio: {
endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org",
@@ -148,10 +82,76 @@ export default defineNuxtConfig({
},
sessionSecret: process.env.NUXT_SESSION_SECRET || "",
encryptionKey: process.env.NUXT_ENCRYPTION_KEY || "",
jwtSecret: process.env.NUXT_JWT_SECRET || process.env.NUXT_SESSION_SECRET || "",
public: {
// Client-side configuration
appName: "MonacoUSA Portal",
domain: process.env.NUXT_PUBLIC_DOMAIN || "monacousa.org",
domain: process.env.NUXT_PUBLIC_DOMAIN || "https://portal.monacousa.org",
keycloakIssuer: process.env.NUXT_KEYCLOAK_ISSUER || "https://auth.monacousa.org/realms/monacousa",
motion: {
directives: {
'pop-bottom': {
initial: {
scale: 0,
opacity: 0,
y: 100
},
visible: {
scale: 1,
opacity: 1,
y: 0,
transition: {
type: 'spring',
stiffness: 250,
damping: 25
}
}
},
'fade-in': {
initial: {
opacity: 0
},
enter: {
opacity: 1,
transition: {
duration: 600
}
}
},
'slide-up': {
initial: {
y: 100,
opacity: 0
},
enter: {
y: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 100,
damping: 20
}
}
},
'glass-fade': {
initial: {
opacity: 0,
scale: 0.95,
filter: 'blur(10px)'
},
enter: {
opacity: 1,
scale: 1,
filter: 'blur(0px)',
transition: {
duration: 500,
type: 'spring',
stiffness: 200
}
}
}
}
}
},
},
vuetify: {
@@ -160,16 +160,149 @@ export default defineNuxtConfig({
defaultTheme: "monacousa",
themes: {
monacousa: {
dark: false,
colors: {
primary: "#a31515",
secondary: "#ffffff",
accent: "#f5f5f5",
error: "#ff5252",
warning: "#ff9800",
info: "#2196f3",
success: "#4caf50",
// Refined Monaco Red Spectrum
primary: "#dc2626", // Professional primary
'primary-50': "#fef2f2",
'primary-100': "#fee2e2",
'primary-200': "#fecaca",
'primary-300': "#fca5a5",
'primary-400': "#f87171",
'primary-500': "#ef4444",
'primary-600': "#dc2626", // Primary Brand Color
'primary-700': "#b91c1c",
'primary-800': "#991b1b",
'primary-900': "#7f1d1d",
// Improved Neutral Palette
secondary: "#64748b", // Neutral gray for secondary
accent: "#dc2626", // Monaco red as accent
background: "#fafafa", // Light gray background
surface: "#ffffff", // Pure white surfaces
'on-background': "#1f2937", // Darker text on background
'on-surface': "#1f2937", // Darker text on surface
// Semantic Colors - More Professional
error: "#dc2626",
warning: "#f59e0b",
info: "#3b82f6",
success: "#22c55e",
// Custom Properties for Glass Effects
'glass-bg': "rgba(255, 255, 255, 0.85)",
'glass-border': "rgba(255, 255, 255, 0.18)",
'glass-dark': "rgba(17, 24, 39, 0.6)",
},
variables: {
'border-color': '#e5e7eb',
'border-opacity': 0.08,
'high-emphasis-opacity': 0.95,
'medium-emphasis-opacity': 0.70,
'disabled-opacity': 0.45,
'idle-opacity': 0.02,
'hover-opacity': 0.04,
'focus-opacity': 0.08,
'selected-opacity': 0.08,
'activated-opacity': 0.10,
'pressed-opacity': 0.12,
'dragged-opacity': 0.06,
'shadow-glass': '0 8px 32px rgba(31, 41, 55, 0.08)',
'shadow-monaco': '0 10px 40px rgba(185, 28, 28, 0.1)',
'shadow-elevated': '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
},
},
monacousa_dark: {
dark: true,
colors: {
// Dark theme aligned with design system
primary: "#ef4444", // Brighter red for dark mode
'primary-600': "#dc2626",
'primary-700': "#b91c1c",
secondary: "#fafafa",
accent: "#3f3f46",
background: "#18181b", // gray-900
surface: "#27272a", // gray-800
'on-background': "#fafafa",
'on-surface': "#f4f4f5",
error: "#f87171",
warning: "#fbbf24",
info: "#38bdf8",
success: "#34d399",
'glass-bg': "rgba(0, 0, 0, 0.7)",
'glass-border': "rgba(255, 255, 255, 0.1)",
},
},
},
variations: {
colors: ['primary', 'secondary', 'accent'],
lighten: 4,
darken: 4,
},
},
defaults: {
VCard: {
elevation: 0,
rounded: 'xl',
class: 'card-modern',
},
VBtn: {
elevation: 0,
rounded: 'lg',
class: 'text-none font-medium',
size: 'default',
density: 'comfortable',
},
VNavigationDrawer: {
elevation: 0,
class: 'sidebar-modern',
},
VAppBar: {
elevation: 0,
flat: true,
class: 'appbar-modern',
density: 'comfortable',
},
VTextField: {
variant: 'outlined',
rounded: 'lg',
density: 'comfortable',
class: 'input-modern',
},
VSelect: {
variant: 'outlined',
rounded: 'lg',
density: 'comfortable',
class: 'select-modern',
},
VDataTable: {
class: 'table-modern',
fixedHeader: true,
hover: true,
},
VChip: {
rounded: 'lg',
size: 'default',
class: 'chip-modern',
},
VDialog: {
class: 'dialog-modern',
maxWidth: '600',
},
VAlert: {
rounded: 'lg',
variant: 'tonal',
class: 'alert-modern',
},
VProgressLinear: {
rounded: true,
height: '6',
},
VProgressCircular: {
width: '3',
},
},
},

1924
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,16 +10,40 @@
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@nuxt/ui": "^3.2.0",
"@vite-pwa/nuxt": "^0.10.6",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19",
"@headlessui/vue": "^1.7.23",
"@tailwindcss/forms": "^0.5.10",
"@types/handlebars": "^4.0.40",
"@types/jsonwebtoken": "^9.0.10",
"@types/nodemailer": "^6.4.17",
"@vite-pwa/nuxt": "^0.10.8",
"@vueuse/core": "^13.8.0",
"@vueuse/motion": "^3.0.3",
"autoprefixer": "^10.4.21",
"chart.js": "^4.5.0",
"cookie": "^0.6.0",
"formidable": "^3.5.4",
"framer-motion": "^12.23.12",
"gsap": "^3.13.0",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.12.10",
"lottie-web": "^5.13.0",
"lucide-vue-next": "^0.542.0",
"mime-types": "^3.0.1",
"minio": "^8.0.5",
"motion-v": "^1.6.1",
"nodemailer": "^7.0.5",
"nuxt": "^3.15.4",
"sharp": "^0.34.2",
"postcss": "^8.5.6",
"sharp": "^0.34.3",
"tailwindcss": "^4.1.12",
"vue": "latest",
"vue-chartjs": "^5.3.2",
"vue-country-flag-next": "^2.3.2",
"vue-router": "latest",
"vuetify-nuxt-module": "^0.18.3"
},
@@ -27,6 +51,7 @@
"@types/cookie": "^0.6.0",
"@types/formidable": "^3.4.5",
"@types/mime-types": "^3.0.1",
"@types/node": "^20.0.0"
"@types/node": "^20.0.0",
"sass": "^1.91.0"
}
}

View File

@@ -0,0 +1,721 @@
<template>
<div class="admin-dashboard-v2">
<!-- Neumorphic Header -->
<div class="dashboard-header">
<h1 class="dashboard-title">System Administration</h1>
<p class="dashboard-subtitle">Complete platform control and management</p>
</div>
<!-- Stats Grid with Neumorphic Cards -->
<div class="stats-grid">
<div class="stat-card neumorphic-card" v-for="stat in stats" :key="stat.id">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-change" :class="stat.changeType">
<Icon :name="stat.changeIcon" class="change-icon" />
<span>{{ stat.changeText }}</span>
</div>
</div>
<div class="stat-icon-wrapper neumorphic-inset">
<Icon :name="stat.icon" class="stat-icon" :style="{ color: stat.color }" />
</div>
</div>
</div>
</div>
<!-- Main Management Sections -->
<div class="management-grid">
<!-- User Management -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:account-group" class="header-icon" />
<h2>User Management</h2>
</div>
<p class="card-description">Manage user accounts, roles, and permissions</p>
<!-- Morphing Dropdown for User Filters -->
<div class="morphing-select-wrapper">
<div class="select-trigger neumorphic-button" @click="toggleUserFilter">
<span>{{ selectedUserFilter }}</span>
<Icon name="mdi:chevron-down" class="dropdown-icon" :class="{ 'rotate': showUserFilter }" />
</div>
<Transition name="morph">
<div v-if="showUserFilter" class="morphing-dropdown">
<div
v-for="option in userFilterOptions"
:key="option"
class="dropdown-option"
@click="selectUserFilter(option)"
>
{{ option }}
</div>
</div>
</Transition>
</div>
<div class="action-buttons">
<button class="neumorphic-button primary" @click="showCreateUserDialog = true">
<Icon name="mdi:account-plus" />
Create User
</button>
<button class="neumorphic-button" @click="manageRoles">
<Icon name="mdi:shield-account" />
Manage Roles
</button>
</div>
</div>
<!-- System Maintenance -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:cog" class="header-icon" />
<h2>System Maintenance</h2>
</div>
<p class="card-description">Backend operations and system health</p>
<!-- System Status Indicator -->
<div class="system-status neumorphic-inset">
<div class="status-indicator" :class="systemStatus.type"></div>
<span>{{ systemStatus.text }}</span>
</div>
<div class="action-buttons">
<button class="neumorphic-button" @click="assignMemberIds">
Assign Member IDs
</button>
<button class="neumorphic-button" @click="backfillEventIds">
Backfill Event IDs
</button>
</div>
</div>
<!-- Reports & Analytics -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:chart-line" class="header-icon" />
<h2>Reports & Analytics</h2>
</div>
<p class="card-description">Generate insights and track metrics</p>
<!-- Report Type Dropdown -->
<div class="morphing-select-wrapper">
<div class="select-trigger neumorphic-button" @click="toggleReportType">
<span>{{ selectedReportType }}</span>
<Icon name="mdi:chevron-down" class="dropdown-icon" :class="{ 'rotate': showReportType }" />
</div>
<Transition name="morph">
<div v-if="showReportType" class="morphing-dropdown">
<div
v-for="type in reportTypes"
:key="type"
class="dropdown-option"
@click="selectReportType(type)"
>
{{ type }}
</div>
</div>
</Transition>
</div>
<button class="neumorphic-button primary full-width" @click="generateReport">
<Icon name="mdi:file-chart" />
Generate Report
</button>
</div>
<!-- Configuration -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:tune" class="header-icon" />
<h2>Configuration</h2>
</div>
<p class="card-description">Portal settings and integrations</p>
<div class="config-grid">
<button class="config-button neumorphic-button" @click="showMembershipConfig = true">
<Icon name="mdi:card-account-details" />
<span>Membership</span>
</button>
<button class="config-button neumorphic-button" @click="showRecaptchaConfig = true">
<Icon name="mdi:robot" />
<span>reCAPTCHA</span>
</button>
<button class="config-button neumorphic-button" @click="openEmailConfig">
<Icon name="mdi:email" />
<span>Email</span>
</button>
<button class="config-button neumorphic-button" @click="showNocoDBSettings = true">
<Icon name="mdi:database" />
<span>Database</span>
</button>
</div>
</div>
</div>
<!-- Activity Feed -->
<div class="activity-section neumorphic-card">
<div class="card-header">
<Icon name="mdi:timeline" class="header-icon" />
<h2>Recent Activity</h2>
<button class="neumorphic-button small" @click="refreshActivity">
<Icon name="mdi:refresh" />
</button>
</div>
<div class="activity-list">
<div v-for="activity in recentActivity" :key="activity.id" class="activity-item neumorphic-inset">
<Icon :name="activity.icon" class="activity-icon" :style="{ color: activity.color }" />
<div class="activity-content">
<p class="activity-text">{{ activity.text }}</p>
<span class="activity-time">{{ activity.time }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// Define page meta
definePageMeta({
layout: 'admin',
middleware: 'auth'
})
// Stats data
const stats = ref([
{
id: 1,
label: 'Total Members',
value: '1,247',
changeType: 'positive',
changeIcon: 'mdi:trending-up',
changeText: '+12% this month',
icon: 'mdi:account-group',
color: '#CC0000'
},
{
id: 2,
label: 'Active Sessions',
value: '342',
changeType: 'neutral',
changeIcon: 'mdi:circle',
changeText: 'Live now',
icon: 'mdi:monitor-dashboard',
color: '#10B981'
},
{
id: 3,
label: 'Revenue MTD',
value: '$48,392',
changeType: 'positive',
changeIcon: 'mdi:trending-up',
changeText: '+8% vs last month',
icon: 'mdi:currency-usd',
color: '#3B82F6'
},
{
id: 4,
label: 'System Health',
value: '98.5%',
changeType: 'positive',
changeIcon: 'mdi:check-circle',
changeText: 'All systems operational',
icon: 'mdi:shield-check',
color: '#10B981'
}
])
// Dropdown states
const showUserFilter = ref(false)
const selectedUserFilter = ref('All Users')
const userFilterOptions = ref(['All Users', 'Active Users', 'Inactive Users', 'Admins', 'Members'])
const showReportType = ref(false)
const selectedReportType = ref('Financial Report')
const reportTypes = ref(['Financial Report', 'Member Report', 'Activity Report', 'Usage Report'])
// System status
const systemStatus = ref({
type: 'healthy',
text: 'All systems operational'
})
// Recent activity
const recentActivity = ref([
{
id: 1,
icon: 'mdi:account-plus',
text: 'New member registration: John Doe',
time: '2 minutes ago',
color: '#10B981'
},
{
id: 2,
icon: 'mdi:credit-card',
text: 'Payment received from Jane Smith',
time: '15 minutes ago',
color: '#3B82F6'
},
{
id: 3,
icon: 'mdi:calendar-check',
text: 'Event created: Annual Gala 2024',
time: '1 hour ago',
color: '#F59E0B'
},
{
id: 4,
icon: 'mdi:account-edit',
text: 'Profile updated: Mike Johnson',
time: '3 hours ago',
color: '#6B7280'
}
])
// Dialog states
const showCreateUserDialog = ref(false)
const showMembershipConfig = ref(false)
const showRecaptchaConfig = ref(false)
const showNocoDBSettings = ref(false)
// Methods
const toggleUserFilter = () => {
showUserFilter.value = !showUserFilter.value
showReportType.value = false
}
const selectUserFilter = (option) => {
selectedUserFilter.value = option
showUserFilter.value = false
}
const toggleReportType = () => {
showReportType.value = !showReportType.value
showUserFilter.value = false
}
const selectReportType = (type) => {
selectedReportType.value = type
showReportType.value = false
}
const manageRoles = () => {
console.log('Managing roles...')
}
const assignMemberIds = () => {
console.log('Assigning member IDs...')
}
const backfillEventIds = () => {
console.log('Backfilling event IDs...')
}
const generateReport = () => {
console.log('Generating report:', selectedReportType.value)
}
const openEmailConfig = () => {
console.log('Opening email configuration...')
}
const refreshActivity = () => {
console.log('Refreshing activity...')
}
onMounted(() => {
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.morphing-select-wrapper')) {
showUserFilter.value = false
showReportType.value = false
}
})
})
</script>
<style lang="scss" scoped>
@import '@/assets/scss/design-system-v2.scss';
.admin-dashboard-v2 {
padding: 2rem;
background: linear-gradient(135deg, $neutral-50 0%, $neutral-100 100%);
min-height: 100vh;
}
// Header
.dashboard-header {
text-align: center;
margin-bottom: 3rem;
.dashboard-title {
font-size: $text-4xl;
font-weight: $font-bold;
color: $neutral-800;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, $primary-600, $primary-800);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dashboard-subtitle {
color: $neutral-600;
font-size: $text-lg;
}
}
// Stats Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.stat-card {
@include neumorphic-card('md');
padding: 1.5rem;
transition: all $transition-base;
&:hover {
@include neumorphic-card('lg');
transform: translateY(-2px);
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-info {
flex: 1;
}
.stat-label {
color: $neutral-600;
font-size: $text-sm;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: $text-3xl;
font-weight: $font-bold;
color: $neutral-800;
margin-bottom: 0.5rem;
}
.stat-change {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: $text-sm;
&.positive {
color: $success-500;
}
&.neutral {
color: $neutral-600;
}
.change-icon {
width: 14px;
height: 14px;
}
}
.stat-icon-wrapper {
width: 60px;
height: 60px;
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-inset-sm;
.stat-icon {
width: 28px;
height: 28px;
}
}
}
// Management Grid
.management-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.management-card {
@include neumorphic-card('md');
padding: 2rem;
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
.header-icon {
width: 24px;
height: 24px;
color: $primary-600;
}
h2 {
font-size: $text-xl;
font-weight: $font-semibold;
color: $neutral-800;
}
}
.card-description {
color: $neutral-600;
margin-bottom: 1.5rem;
font-size: $text-sm;
}
.action-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.config-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
font-size: $text-sm;
svg {
width: 20px;
height: 20px;
}
}
}
// Morphing Dropdown
.morphing-select-wrapper {
position: relative;
margin-bottom: 1.5rem;
.select-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
.dropdown-icon {
transition: transform 0.3s $spring-smooth;
&.rotate {
transform: rotate(180deg);
}
}
}
}
.morphing-dropdown {
@include morphing-dropdown();
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: $z-dropdown;
.dropdown-option {
padding: 0.75rem 1rem;
cursor: pointer;
transition: all $transition-fast;
color: $neutral-700;
&:hover {
background: rgba($blue-500, 0.1);
color: $blue-600;
padding-left: 1.25rem;
}
}
}
// Neumorphic Elements
.neumorphic-card {
background: linear-gradient(145deg, #ffffff, #f0f0f0);
border-radius: $radius-xl;
box-shadow: $shadow-soft-md;
}
.neumorphic-button {
@include neumorphic-button();
padding: 0.75rem 1.5rem;
border: none;
border-radius: $radius-lg;
font-weight: $font-medium;
color: $neutral-700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&.primary {
background: linear-gradient(145deg, $primary-600, $primary-700);
color: white;
&:hover {
background: linear-gradient(145deg, $primary-700, $primary-800);
}
}
&.small {
padding: 0.5rem 0.75rem;
font-size: $text-sm;
}
&.full-width {
width: 100%;
justify-content: center;
}
svg {
width: 18px;
height: 18px;
}
}
.neumorphic-inset {
box-shadow: $shadow-inset-sm;
background: linear-gradient(145deg, #e6e6e6, #ffffff);
}
// System Status
.system-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: $radius-lg;
margin-bottom: 1.5rem;
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
&.healthy {
background-color: $success-500;
}
&.warning {
background-color: $warning-500;
}
&.error {
background-color: $error-500;
}
}
}
// Activity Section
.activity-section {
@include neumorphic-card('lg');
padding: 2rem;
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
h2 {
flex: 1;
}
}
.activity-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: $radius-lg;
.activity-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.activity-content {
flex: 1;
.activity-text {
color: $neutral-800;
font-size: $text-sm;
margin-bottom: 0.25rem;
}
.activity-time {
color: $neutral-500;
font-size: $text-xs;
}
}
}
}
// Transitions
.morph-enter-active,
.morph-leave-active {
transition: all 0.3s $spring-smooth;
}
.morph-enter-from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
.morph-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
// Responsive
@include responsive($breakpoint-md) {
.admin-dashboard-v2 {
padding: 3rem;
}
}
@include responsive($breakpoint-lg) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
.management-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">Event Management</h1>
<p class="text-body-1 text-medium-emphasis">Create and manage association events</p>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-calendar-plus"
@click="showCreateDialog = true"
>
Create Event
</v-btn>
</v-col>
</v-row>
<!-- Stats Cards -->
<v-row class="mb-6">
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.upcoming }}</div>
<div class="text-body-2 text-medium-emphasis">Upcoming Events</div>
</div>
<v-icon size="32" color="primary">mdi-calendar-clock</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.totalRegistrations }}</div>
<div class="text-body-2 text-medium-emphasis">Total Registrations</div>
</div>
<v-icon size="32" color="success">mdi-account-check</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">${{ stats.revenue }}</div>
<div class="text-body-2 text-medium-emphasis">Total Revenue</div>
</div>
<v-icon size="32" color="warning">mdi-cash</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.avgAttendance }}%</div>
<div class="text-body-2 text-medium-emphasis">Avg Attendance</div>
</div>
<v-icon size="32" color="info">mdi-chart-line</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Filters -->
<v-card class="mb-6" elevation="0">
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<v-text-field
v-model="searchQuery"
label="Search events"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="typeFilter"
label="Event Type"
:items="typeOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="dateRange"
label="Date Range"
:items="dateRangeOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Events List -->
<v-card elevation="2">
<v-data-table
:headers="headers"
:items="filteredEvents"
:search="searchQuery"
:loading="loading"
class="elevation-0"
hover
>
<template v-slot:item.title="{ item }">
<div class="py-2">
<div class="font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.type }}</div>
</div>
</template>
<template v-slot:item.date="{ item }">
<div>
<div class="text-body-2">{{ formatDate(item.date) }}</div>
<div class="text-caption text-medium-emphasis">{{ item.time }}</div>
</div>
</template>
<template v-slot:item.registrations="{ item }">
<div class="d-flex align-center">
<v-progress-linear
:model-value="(item.registrations / item.capacity) * 100"
:color="getCapacityColor(item.registrations, item.capacity)"
height="6"
rounded
class="mr-2"
style="min-width: 60px"
/>
<span class="text-body-2">
{{ item.registrations }}/{{ item.capacity }}
</span>
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="tonal"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click="viewEvent(item)"
/>
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editEvent(item)"
/>
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
>
<v-menu activator="parent">
<v-list density="compact">
<v-list-item @click="duplicateEvent(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-content-copy</v-icon>
Duplicate
</v-list-item-title>
</v-list-item>
<v-list-item @click="viewAttendees(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-account-group</v-icon>
View Attendees
</v-list-item-title>
</v-list-item>
<v-list-item @click="exportEvent(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-download</v-icon>
Export Data
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
@click="cancelEvent(item)"
class="text-error"
:disabled="item.status === 'cancelled'"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-cancel</v-icon>
Cancel Event
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
</v-data-table>
</v-card>
<!-- Create/Edit Event Dialog -->
<v-dialog v-model="showCreateDialog" max-width="800">
<v-card>
<v-card-title>
{{ editingEvent ? 'Edit Event' : 'Create New Event' }}
</v-card-title>
<v-card-text>
<v-form ref="eventForm">
<v-row>
<v-col cols="12">
<v-text-field
v-model="eventForm.title"
label="Event Title"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="eventForm.description"
label="Description"
variant="outlined"
rows="3"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventForm.type"
label="Event Type"
:items="typeOptions"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="eventForm.location"
label="Location"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.date"
label="Date"
type="date"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.time"
label="Time"
type="time"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.duration"
label="Duration (hours)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.capacity"
label="Capacity"
type="number"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.price"
label="Price"
prefix="$"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="eventForm.registrationType"
label="Registration"
:items="['Open', 'Members Only', 'Invite Only']"
variant="outlined"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCreateDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="saveEvent">
{{ editingEvent ? 'Update' : 'Create' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const loading = ref(false);
const showCreateDialog = ref(false);
const editingEvent = ref(null);
const searchQuery = ref('');
const statusFilter = ref(null);
const typeFilter = ref(null);
const dateRange = ref(null);
// Stats
const stats = ref({
upcoming: 8,
totalRegistrations: 342,
revenue: 15420,
avgAttendance: 78
});
// Form data
const eventForm = ref({
title: '',
description: '',
type: '',
location: '',
date: '',
time: '',
duration: 2,
capacity: 50,
price: 0,
registrationType: 'Open'
});
// Options
const statusOptions = [
'Upcoming',
'Ongoing',
'Completed',
'Cancelled'
];
const typeOptions = [
'Conference',
'Workshop',
'Networking',
'Social',
'Fundraiser',
'Meeting'
];
const dateRangeOptions = [
'This Week',
'This Month',
'Next Month',
'This Quarter',
'This Year'
];
// Table configuration
const headers = [
{ title: 'Event', key: 'title', sortable: true },
{ title: 'Date & Time', key: 'date', sortable: true },
{ title: 'Location', key: 'location', sortable: true },
{ title: 'Registrations', key: 'registrations', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
];
// Mock data
const events = ref([
{
id: 1,
title: 'Annual Gala Dinner',
type: 'Fundraiser',
date: new Date('2024-02-15'),
time: '19:00',
location: 'Grand Ballroom',
registrations: 145,
capacity: 200,
status: 'Upcoming',
price: 250
},
{
id: 2,
title: 'Business Networking Event',
type: 'Networking',
date: new Date('2024-01-22'),
time: '18:00',
location: 'Conference Center',
registrations: 48,
capacity: 50,
status: 'Upcoming',
price: 0
},
{
id: 3,
title: 'Digital Marketing Workshop',
type: 'Workshop',
date: new Date('2024-01-10'),
time: '14:00',
location: 'Training Room A',
registrations: 22,
capacity: 30,
status: 'Completed',
price: 75
},
{
id: 4,
title: 'Board Meeting',
type: 'Meeting',
date: new Date('2024-01-05'),
time: '10:00',
location: 'Board Room',
registrations: 12,
capacity: 15,
status: 'Completed',
price: 0
}
]);
// Computed
const filteredEvents = computed(() => {
let filtered = [...events.value];
if (statusFilter.value) {
filtered = filtered.filter(e => e.status === statusFilter.value);
}
if (typeFilter.value) {
filtered = filtered.filter(e => e.type === typeFilter.value);
}
return filtered;
});
// Methods
const getStatusColor = (status: string) => {
switch (status) {
case 'Upcoming': return 'info';
case 'Ongoing': return 'success';
case 'Completed': return 'default';
case 'Cancelled': return 'error';
default: return 'default';
}
};
const getCapacityColor = (registrations: number, capacity: number) => {
const percentage = (registrations / capacity) * 100;
if (percentage >= 90) return 'error';
if (percentage >= 70) return 'warning';
return 'success';
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const viewEvent = (event: any) => {
console.log('View event:', event);
};
const editEvent = (event: any) => {
editingEvent.value = event;
eventForm.value = {
title: event.title,
description: '',
type: event.type,
location: event.location,
date: event.date.toISOString().split('T')[0],
time: event.time,
duration: 2,
capacity: event.capacity,
price: event.price,
registrationType: 'Open'
};
showCreateDialog.value = true;
};
const duplicateEvent = (event: any) => {
console.log('Duplicate event:', event);
};
const viewAttendees = (event: any) => {
console.log('View attendees:', event);
};
const exportEvent = (event: any) => {
console.log('Export event:', event);
};
const cancelEvent = (event: any) => {
console.log('Cancel event:', event);
event.status = 'Cancelled';
};
const saveEvent = () => {
console.log('Save event:', eventForm.value);
showCreateDialog.value = false;
editingEvent.value = null;
};
</script>

View File

@@ -0,0 +1,996 @@
<template>
<v-container fluid class="pa-6">
<!-- Animated Header with Gradient -->
<div class="header-section mb-8">
<v-row align="center" justify="space-between">
<v-col cols="auto">
<div class="d-flex align-center">
<v-avatar size="56" class="gradient-avatar mr-4 elevation-3">
<v-icon size="32" color="white">mdi-account-group</v-icon>
</v-avatar>
<div>
<h1 class="text-h3 font-weight-bold gradient-text">Member Directory</h1>
<p class="text-body-1 text-medium-emphasis mt-1">
<v-icon size="18" class="mr-1">mdi-account-multiple</v-icon>
{{ stats.total }} total members {{ stats.active }} active
</p>
</div>
</div>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
size="large"
elevation="3"
rounded="lg"
prepend-icon="mdi-account-plus"
@click="showCreateDialog = true"
class="pulse-animation"
>
Add New Member
</v-btn>
</v-col>
</v-row>
</div>
<!-- Enhanced Stats Cards with Glassmorphism -->
<v-row class="mb-8">
<v-col v-for="stat in statsCards" :key="stat.title" cols="12" sm="6" md="3">
<v-card
class="stat-card glass-card"
elevation="0"
:style="`border-left: 4px solid ${stat.color}`"
>
<v-card-text class="pa-5">
<div class="d-flex align-center justify-space-between mb-3">
<v-avatar :color="stat.color" size="48" class="elevation-2">
<v-icon color="white">{{ stat.icon }}</v-icon>
</v-avatar>
<v-chip
v-if="stat.change"
:color="stat.changeType === 'increase' ? 'success' : 'error'"
size="small"
variant="tonal"
>
<v-icon size="14">
{{ stat.changeType === 'increase' ? 'mdi-trending-up' : 'mdi-trending-down' }}
</v-icon>
{{ stat.change }}
</v-chip>
</div>
<div class="text-h3 font-weight-bold mb-1">{{ stat.value }}</div>
<div class="text-body-2 text-medium-emphasis">{{ stat.title }}</div>
<v-progress-linear
v-if="stat.progress"
:model-value="stat.progress"
:color="stat.color"
height="4"
rounded
class="mt-3"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Enhanced Search & Filters Bar -->
<v-card class="filter-card glass-card mb-6" elevation="0">
<v-card-text class="pa-5">
<v-row align="center">
<v-col cols="12" md="5">
<v-text-field
v-model="searchQuery"
label="Search members"
placeholder="Name, email, or ID..."
prepend-inner-icon="mdi-magnify"
variant="solo"
density="comfortable"
clearable
hide-details
class="search-field"
>
<template v-slot:append-inner>
<v-badge
v-if="searchQuery"
:content="filteredMembers.length"
color="primary"
inline
/>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="7">
<div class="d-flex gap-2 flex-wrap">
<v-chip-group
v-model="quickFilter"
selected-class="chip-active"
>
<v-chip filter variant="outlined" value="all">
<v-icon start size="18">mdi-all-inclusive</v-icon>
All Members
</v-chip>
<v-chip filter variant="outlined" value="active">
<v-icon start size="18" color="success">mdi-check-circle</v-icon>
Active
</v-chip>
<v-chip filter variant="outlined" value="dues-pending">
<v-icon start size="18" color="warning">mdi-clock-alert</v-icon>
Dues Pending
</v-chip>
<v-chip filter variant="outlined" value="new">
<v-icon start size="18" color="info">mdi-new-box</v-icon>
New This Month
</v-chip>
</v-chip-group>
<v-btn
icon
variant="outlined"
@click="showAdvancedFilters = !showAdvancedFilters"
>
<v-icon>mdi-filter-variant</v-icon>
<v-tooltip activator="parent">Advanced Filters</v-tooltip>
</v-btn>
<v-btn
icon
variant="outlined"
@click="exportMembers"
>
<v-icon>mdi-download</v-icon>
<v-tooltip activator="parent">Export</v-tooltip>
</v-btn>
</div>
</v-col>
</v-row>
<!-- Advanced Filters (Collapsible) -->
<v-expand-transition>
<v-row v-if="showAdvancedFilters" class="mt-4">
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="solo"
density="comfortable"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="membershipFilter"
label="Membership Type"
:items="membershipOptions"
variant="solo"
density="comfortable"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="nationalityFilter"
label="Nationality"
:items="countryOptions"
item-title="name"
item-value="code"
variant="solo"
density="comfortable"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="duesFilter"
label="Dues Status"
:items="['Paid', 'Unpaid', 'Overdue']"
variant="solo"
density="comfortable"
clearable
hide-details
/>
</v-col>
</v-row>
</v-expand-transition>
</v-card-text>
</v-card>
<!-- View Mode Toggle -->
<div class="d-flex justify-space-between align-center mb-4">
<div class="text-body-1">
Showing <strong>{{ filteredMembers.length }}</strong> of {{ members.length }} members
</div>
<v-btn-toggle
v-model="viewMode"
mandatory
density="comfortable"
rounded="lg"
color="primary"
class="elevation-2"
>
<v-btn value="cards" prepend-icon="mdi-view-grid">
Cards
</v-btn>
<v-btn value="table" prepend-icon="mdi-table">
Table
</v-btn>
</v-btn-toggle>
</div>
<!-- Enhanced Card View -->
<transition-group
v-if="viewMode === 'cards'"
name="card-list"
tag="div"
class="row"
>
<v-col
v-for="member in paginatedMembers"
:key="member.member_id"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-card
class="member-card glass-card h-100"
elevation="0"
@click="viewMember(member)"
>
<!-- Card Header with Gradient Background -->
<div class="card-header gradient-bg pa-4 text-center">
<ProfileAvatar
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="80"
class="mb-3 mx-auto elevation-4 white-border"
/>
<h3 class="text-h6 font-weight-bold white--text">
{{ member.first_name }} {{ member.last_name }}
</h3>
<div class="text-caption white--text opacity-90">
{{ member.member_id || 'Pending ID' }}
</div>
</div>
<v-card-text class="pa-4">
<!-- Contact Info -->
<div class="info-row mb-3">
<v-icon size="18" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-body-2 text-truncate">{{ member.email }}</span>
</div>
<!-- Nationality with Flag -->
<div class="info-row mb-3">
<v-icon size="18" class="mr-2 text-medium-emphasis">mdi-flag</v-icon>
<MultipleCountryFlags
:nationality="member.nationality"
:show-name="true"
size="small"
fallback-text="Not specified"
/>
</div>
<!-- Member Since -->
<div class="info-row mb-3">
<v-icon size="18" class="mr-2 text-medium-emphasis">mdi-calendar</v-icon>
<span class="text-body-2">Since {{ formatDate(member.join_date) }}</span>
</div>
<!-- Status Badges -->
<div class="d-flex flex-wrap gap-2 mb-3">
<v-chip
:color="member.status === 'active' ? 'success' : 'error'"
size="small"
variant="tonal"
label
>
<v-icon start size="14">
{{ member.status === 'active' ? 'mdi-check' : 'mdi-close' }}
</v-icon>
{{ member.status }}
</v-chip>
<v-chip
:color="getDuesChipColor(member)"
size="small"
variant="tonal"
label
>
<v-icon start size="14">mdi-cash</v-icon>
{{ member.dues_paid_this_year ? 'Dues Paid' : 'Dues Pending' }}
</v-chip>
<v-chip
v-if="member.membership_type !== 'Standard'"
color="purple"
size="small"
variant="tonal"
label
>
{{ member.membership_type }}
</v-chip>
</div>
</v-card-text>
<!-- Quick Actions -->
<v-card-actions class="pa-3 pt-0">
<v-btn
v-if="!member.dues_paid_this_year"
color="success"
variant="flat"
size="small"
block
rounded
@click.stop="markDuesPaid(member)"
>
<v-icon start>mdi-check</v-icon>
Mark Dues Paid
</v-btn>
<v-row v-else dense>
<v-col cols="4">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
block
@click.stop="viewMember(member)"
>
<v-tooltip activator="parent">View</v-tooltip>
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
block
@click.stop="editMember(member)"
>
<v-tooltip activator="parent">Edit</v-tooltip>
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
icon="mdi-email"
size="small"
variant="text"
block
@click.stop="sendEmail(member)"
>
<v-tooltip activator="parent">Email</v-tooltip>
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</v-card>
</v-col>
</transition-group>
<!-- Enhanced Table View -->
<v-card v-else-if="viewMode === 'table'" class="glass-card" elevation="0">
<v-data-table
:headers="tableHeaders"
:items="filteredMembers"
:search="searchQuery"
:loading="loading"
class="modern-table"
hover
:items-per-page="15"
@click:row="(e, { item }) => viewMember(item)"
>
<template v-slot:item.member="{ item }">
<div class="d-flex align-center py-3">
<ProfileAvatar
:member-id="item.member_id"
:first-name="item.first_name"
:last-name="item.last_name"
size="40"
class="mr-3"
/>
<div>
<div class="font-weight-medium">
{{ item.first_name }} {{ item.last_name }}
</div>
<div class="text-caption text-medium-emphasis">
{{ item.member_id || 'Pending ID' }}
</div>
</div>
</div>
</template>
<template v-slot:item.contact="{ item }">
<div class="py-2">
<div class="d-flex align-center mb-1">
<v-icon size="14" class="mr-1">mdi-email</v-icon>
<a :href="`mailto:${item.email}`" class="text-primary text-decoration-none" @click.stop>
{{ item.email }}
</a>
</div>
<div v-if="item.phone" class="d-flex align-center text-caption">
<v-icon size="14" class="mr-1">mdi-phone</v-icon>
{{ item.phone }}
</div>
</div>
</template>
<template v-slot:item.nationality="{ item }">
<MultipleCountryFlags
:nationality="item.nationality"
:show-name="true"
size="small"
fallback-text=""
/>
</template>
<template v-slot:item.membership="{ item }">
<div class="py-2">
<v-chip
:color="getMembershipColor(item.membership_type)"
size="small"
variant="tonal"
label
class="mb-1"
>
{{ item.membership_type }}
</v-chip>
<div class="text-caption text-medium-emphasis">
Since {{ formatDate(item.join_date) }}
</div>
</div>
</template>
<template v-slot:item.status="{ item }">
<div class="d-flex gap-2">
<v-chip
:color="item.status === 'active' ? 'success' : 'error'"
size="small"
variant="tonal"
label
>
{{ item.status }}
</v-chip>
<v-chip
:color="getDuesChipColor(item)"
size="small"
variant="tonal"
label
>
{{ item.dues_paid_this_year ? 'Paid' : 'Due' }}
</v-chip>
</div>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex gap-1">
<v-btn
icon="mdi-eye"
size="x-small"
variant="text"
@click.stop="viewMember(item)"
/>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
@click.stop="editMember(item)"
/>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
size="x-small"
variant="text"
v-bind="props"
@click.stop
/>
</template>
<v-list density="compact">
<v-list-item @click="sendEmail(item)">
<template v-slot:prepend>
<v-icon size="small">mdi-email</v-icon>
</template>
<v-list-item-title>Send Email</v-list-item-title>
</v-list-item>
<v-list-item
v-if="!item.dues_paid_this_year"
@click="markDuesPaid(item)"
>
<template v-slot:prepend>
<v-icon size="small" color="success">mdi-check</v-icon>
</template>
<v-list-item-title>Mark Dues Paid</v-list-item-title>
</v-list-item>
<v-list-item @click="viewPaymentHistory(item)">
<template v-slot:prepend>
<v-icon size="small">mdi-history</v-icon>
</template>
<v-list-item-title>Payment History</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
@click="toggleStatus(item)"
:class="item.status === 'active' ? 'text-error' : 'text-success'"
>
<template v-slot:prepend>
<v-icon size="small">
{{ item.status === 'active' ? 'mdi-account-off' : 'mdi-account-check' }}
</v-icon>
</template>
<v-list-item-title>
{{ item.status === 'active' ? 'Deactivate' : 'Activate' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
</v-data-table>
</v-card>
<!-- Pagination -->
<v-card
v-if="viewMode === 'cards' && filteredMembers.length > itemsPerPage"
class="mt-6 glass-card"
elevation="0"
>
<v-card-text>
<v-pagination
v-model="currentPage"
:length="Math.ceil(filteredMembers.length / itemsPerPage)"
:total-visible="7"
rounded="circle"
color="primary"
/>
</v-card-text>
</v-card>
<!-- Dialogs -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
@mark-dues-paid="handleMarkDuesPaid"
/>
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
</v-container>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { countries } from '~/utils/countries';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const loading = ref(false);
const members = ref<Member[]>([]);
const searchQuery = ref('');
const quickFilter = ref('all');
const statusFilter = ref(null);
const membershipFilter = ref(null);
const nationalityFilter = ref(null);
const duesFilter = ref(null);
const viewMode = ref('cards');
const currentPage = ref(1);
const itemsPerPage = 12;
const showAdvancedFilters = ref(false);
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const showCreateDialog = ref(false);
const selectedMember = ref<Member | null>(null);
// Stats
const stats = ref({
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0,
newThisMonth: 0
});
// Computed stats cards
const statsCards = computed(() => [
{
title: 'Total Members',
value: stats.value.total,
icon: 'mdi-account-group',
color: '#3b82f6',
change: '+12',
changeType: 'increase'
},
{
title: 'Active Members',
value: stats.value.active,
icon: 'mdi-account-check',
color: '#10b981',
progress: Math.round((stats.value.active / stats.value.total) * 100)
},
{
title: 'Dues Paid',
value: stats.value.paidThisYear,
icon: 'mdi-cash-check',
color: '#8b5cf6',
progress: Math.round((stats.value.paidThisYear / stats.value.total) * 100)
},
{
title: 'New This Month',
value: stats.value.newThisMonth,
icon: 'mdi-account-plus',
color: '#f59e0b',
change: '+8',
changeType: 'increase'
}
]);
// Options
const statusOptions = ['active', 'inactive'];
const membershipOptions = ['Standard', 'Premium', 'VIP', 'Lifetime'];
const countryOptions = countries;
// Table headers
const tableHeaders = [
{ title: 'Member', key: 'member', sortable: true },
{ title: 'Contact', key: 'contact', sortable: true },
{ title: 'Nationality', key: 'nationality', sortable: true },
{ title: 'Membership', key: 'membership', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: '', key: 'actions', sortable: false, align: 'end' }
];
// Computed
const filteredMembers = computed(() => {
let filtered = [...members.value];
// Apply quick filter
if (quickFilter.value === 'active') {
filtered = filtered.filter(m => m.status === 'active');
} else if (quickFilter.value === 'dues-pending') {
filtered = filtered.filter(m => !m.dues_paid_this_year);
} else if (quickFilter.value === 'new') {
const thisMonth = new Date().getMonth();
filtered = filtered.filter(m => {
const joinDate = new Date(m.join_date);
return joinDate.getMonth() === thisMonth;
});
}
// Apply advanced filters
if (statusFilter.value) {
filtered = filtered.filter(m => m.status === statusFilter.value);
}
if (membershipFilter.value) {
filtered = filtered.filter(m => m.membership_type === membershipFilter.value);
}
if (nationalityFilter.value) {
filtered = filtered.filter(m => m.nationality === nationalityFilter.value);
}
if (duesFilter.value) {
if (duesFilter.value === 'Paid') {
filtered = filtered.filter(m => m.dues_paid_this_year);
} else if (duesFilter.value === 'Unpaid' || duesFilter.value === 'Overdue') {
filtered = filtered.filter(m => !m.dues_paid_this_year);
}
}
return filtered;
});
const paginatedMembers = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredMembers.value.slice(start, end);
});
// Methods
const formatDate = (date: string) => {
if (!date) return 'N/A';
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) return 'N/A';
return parsedDate.toLocaleDateString('en-US', {
month: 'short',
year: 'numeric'
});
};
const getMembershipColor = (type: string) => {
switch (type) {
case 'VIP': return 'error';
case 'Premium': return 'warning';
case 'Lifetime': return 'purple';
default: return 'info';
}
};
const getDuesChipColor = (member: Member) => {
return member.dues_paid_this_year ? 'success' : 'warning';
};
const viewMember = (member: Member) => {
selectedMember.value = member;
showViewDialog.value = true;
};
const editMember = (member: Member) => {
selectedMember.value = member;
showEditDialog.value = true;
};
const handleEditMember = (member: Member) => {
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const handleMemberUpdated = (member: Member) => {
const index = members.value.findIndex(m => m.member_id === member.member_id);
if (index > -1) {
members.value[index] = member;
}
showEditDialog.value = false;
};
const markDuesPaid = async (member: Member) => {
try {
member.dues_paid_this_year = true;
member.dues_status = 'Paid';
member.last_dues_paid = new Date().toISOString();
stats.value.paidThisYear++;
stats.value.duesOutstanding--;
} catch (error) {
console.error('Error marking dues as paid:', error);
}
};
const handleMarkDuesPaid = (member: Member) => {
markDuesPaid(member);
};
const sendEmail = (member: Member) => {
window.location.href = `mailto:${member.email}`;
};
const viewPaymentHistory = (member: Member) => {
// TODO: Navigate to payment history
};
const toggleStatus = (member: Member) => {
member.status = member.status === 'active' ? 'inactive' : 'active';
// TODO: Make API call
};
const exportMembers = () => {
// TODO: Export to CSV/Excel
};
// Load members data
const loadMembers = async () => {
loading.value = true;
try {
const response = await $fetch('/api/members');
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
if (membersList && membersList.length > 0) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth();
members.value = membersList.map((member: any) => {
const lastPaid = member.last_dues_paid ? new Date(member.last_dues_paid) : null;
const duesPaidThisYear = lastPaid && lastPaid.getFullYear() === currentYear;
const joinDate = member.member_since || member.created_at;
const joinMonth = joinDate ? new Date(joinDate).getMonth() : null;
return {
...member,
member_id: member.member_id || '',
first_name: member.first_name,
last_name: member.last_name,
name: `${member.last_name || ''}, ${member.first_name || ''}`.trim(),
email: member.email,
nationality: member.nationality || member.country_code || '',
membership_type: member.membership_type || 'Standard',
status: member.membership_status === 'Active' ? 'active' : 'inactive',
dues_status: member.dues_status || (duesPaidThisYear ? 'Paid' : 'Due'),
dues_paid_this_year: duesPaidThisYear,
last_dues_paid: member.last_dues_paid,
join_date: joinDate,
phone: member.phone_number || member.phone || ''
};
}).sort((a, b) => {
const aLastName = (a.last_name || '').toLowerCase();
const bLastName = (b.last_name || '').toLowerCase();
const aFirstName = (a.first_name || '').toLowerCase();
const bFirstName = (b.first_name || '').toLowerCase();
const lastNameCompare = aLastName.localeCompare(bLastName);
if (lastNameCompare !== 0) return lastNameCompare;
return aFirstName.localeCompare(bFirstName);
});
// Calculate stats
const currentYearMembers = members.value.filter(m => m.dues_paid_this_year);
const newThisMonth = members.value.filter(m => {
const joinDate = new Date(m.join_date);
return joinDate.getMonth() === currentMonth && joinDate.getFullYear() === currentYear;
});
stats.value = {
total: members.value.length,
active: members.value.filter(m => m.status === 'active').length,
paidThisYear: currentYearMembers.length,
duesOutstanding: members.value.length - currentYearMembers.length,
newThisMonth: newThisMonth.length
};
}
} catch (error) {
console.error('Error loading members:', error);
} finally {
loading.value = false;
}
};
// Load on mount
onMounted(async () => {
await loadMembers();
});
</script>
<style scoped>
/* Glassmorphism effect */
.glass-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px !important;
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient avatar */
.gradient-avatar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Gradient background for card headers */
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Stat card hover effect */
.stat-card {
transition: all 0.3s ease;
cursor: default;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
/* Member card effects */
.member-card {
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
}
.member-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.member-card .card-header {
position: relative;
overflow: hidden;
}
.member-card .card-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transform: translateX(-100%);
transition: transform 0.5s;
}
.member-card:hover .card-header::before {
transform: translateX(100%);
}
/* White border for avatar */
.white-border {
border: 3px solid white;
}
/* Info row styling */
.info-row {
display: flex;
align-items: center;
}
/* Search field styling */
.search-field :deep(.v-field) {
border-radius: 12px !important;
}
/* Chip active state */
.chip-active {
background-color: rgba(var(--v-theme-primary), 0.12) !important;
border-color: rgb(var(--v-theme-primary)) !important;
}
/* Table styling */
.modern-table :deep(tbody tr) {
cursor: pointer;
}
.modern-table :deep(tbody tr:hover) {
background-color: rgba(var(--v-theme-primary), 0.04);
}
/* Animation for cards */
.card-list-move,
.card-list-enter-active,
.card-list-leave-active {
transition: all 0.5s ease;
}
.card-list-enter-from {
opacity: 0;
transform: translateY(30px);
}
.card-list-leave-to {
opacity: 0;
transform: translateY(-30px);
}
/* Pulse animation for add button */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-primary), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0);
}
}
.pulse-animation {
animation: pulse 2s infinite;
}
/* Filter card styling */
.filter-card {
border-left: 4px solid rgb(var(--v-theme-primary));
}
</style>

View File

@@ -0,0 +1,800 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">Member Management</h1>
<p class="text-body-1 text-medium-emphasis">Manage association members and their information</p>
</v-col>
<v-col cols="auto" class="d-flex align-center gap-2">
<!-- View Toggle -->
<v-btn-toggle
v-model="viewMode"
mandatory
density="comfortable"
color="primary"
>
<v-btn icon value="list">
<v-icon>mdi-view-list</v-icon>
<v-tooltip activator="parent" location="bottom">List View</v-tooltip>
</v-btn>
<v-btn icon value="grid">
<v-icon>mdi-view-grid</v-icon>
<v-tooltip activator="parent" location="bottom">Grid View</v-tooltip>
</v-btn>
</v-btn-toggle>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-account-plus"
@click="showCreateDialog = true"
>
Add Member
</v-btn>
</v-col>
</v-row>
<!-- Stats Cards -->
<v-row class="mb-6">
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.total }}</div>
<div class="text-body-2 text-medium-emphasis">Total Members</div>
</div>
<v-icon size="32" color="primary">mdi-account-group</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.active }}</div>
<div class="text-body-2 text-medium-emphasis">Active Members</div>
</div>
<v-icon size="32" color="success">mdi-account-check</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold text-success">{{ stats.paidThisYear }}</div>
<div class="text-body-2 text-medium-emphasis">Dues Paid This Year</div>
</div>
<v-icon size="32" color="success">mdi-cash-check</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold text-warning">{{ stats.duesOutstanding }}</div>
<div class="text-body-2 text-medium-emphasis">Dues Outstanding</div>
</div>
<v-icon size="32" color="warning">mdi-clock-alert-outline</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Filters -->
<v-card class="mb-6" elevation="0">
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<v-text-field
v-model="searchQuery"
label="Search members"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="membershipFilter"
label="Membership Type"
:items="membershipOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="duesFilter"
label="Dues Status"
:items="['Paid', 'Unpaid', 'Overdue']"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-btn
variant="outlined"
color="primary"
block
@click="exportMembers"
>
<v-icon start>mdi-download</v-icon>
Export List
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- List View -->
<v-card v-if="viewMode === 'list'" elevation="2">
<v-data-table
:headers="enhancedHeaders"
:items="filteredMembers"
:search="searchQuery"
:loading="loading"
class="elevation-0 member-list-table"
hover
:items-per-page="10"
@click:row="(e, { item }) => viewMember(item)"
>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2 cursor-pointer">
<ProfileAvatar
:member-id="item.member_id"
:first-name="item.first_name"
:last-name="item.last_name"
size="40"
class="mr-3"
/>
<div>
<div class="font-weight-medium">{{ item.first_name }} {{ item.last_name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.member_id ? `ID: ${item.member_id}` : 'ID Pending' }}</div>
</div>
</div>
</template>
<template v-slot:item.email="{ item }">
<a :href="`mailto:${item.email}`" class="text-primary text-decoration-none" @click.stop>
{{ item.email }}
</a>
</template>
<template v-slot:item.nationality="{ item }">
<div class="d-flex align-center">
<MultipleCountryFlags
:nationality="item.nationality"
:show-name="true"
size="small"
fallback-text="Not specified"
/>
</div>
</template>
<template v-slot:item.dues_paid="{ item }">
<v-chip
:color="item.dues_paid_this_year ? 'success' : 'warning'"
size="small"
variant="flat"
>
{{ item.dues_paid_this_year ? 'Yes' : 'No' }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center gap-1">
<v-btn
v-if="!item.dues_paid_this_year"
color="success"
size="small"
variant="tonal"
@click.stop="markDuesPaid(item)"
>
<v-icon start size="16">mdi-check</v-icon>
Mark Paid
</v-btn>
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click.stop="viewMember(item)"
>
<v-tooltip activator="parent" location="top">View Details</v-tooltip>
</v-btn>
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click.stop="editMember(item)"
>
<v-tooltip activator="parent" location="top">Edit Member</v-tooltip>
</v-btn>
<v-btn
icon="mdi-email"
size="small"
variant="text"
@click.stop="sendEmail(item)"
>
<v-tooltip activator="parent" location="top">Send Email</v-tooltip>
</v-btn>
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
@click.stop
>
<v-menu activator="parent">
<v-list density="compact">
<v-list-item @click="viewPaymentHistory(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-history</v-icon>
Payment History
</v-list-item-title>
</v-list-item>
<v-list-item @click="generateInvoice(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-file-document</v-icon>
Generate Invoice
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
@click="toggleStatus(item)"
:class="item.status === 'active' ? 'text-error' : 'text-success'"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">
{{ item.status === 'active' ? 'mdi-account-off' : 'mdi-account-check' }}
</v-icon>
{{ item.status === 'active' ? 'Deactivate' : 'Activate' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
</v-data-table>
</v-card>
<!-- Grid View -->
<v-row v-else-if="viewMode === 'grid'">
<v-col
v-for="member in paginatedGridMembers"
:key="member.member_id"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-card
elevation="2"
class="member-card h-100"
@click="viewMember(member)"
>
<v-card-text class="text-center pt-6 pb-4">
<!-- Profile Avatar -->
<ProfileAvatar
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="80"
class="mb-3 mx-auto elevation-2"
/>
<!-- Member Name and Nationality -->
<h3 class="text-h6 font-weight-bold mb-1">
{{ member.first_name }} {{ member.last_name }}
</h3>
<div class="d-flex align-center justify-center mb-3">
<MultipleCountryFlags
:nationality="member.nationality"
:show-name="true"
size="small"
fallback-text="No nationality"
class="text-body-2 text-medium-emphasis"
/>
</div>
<!-- Email -->
<div class="text-body-2 text-medium-emphasis mb-3">
<v-icon size="small" class="mr-1">mdi-email</v-icon>
{{ member.email }}
</div>
<!-- Status Badges -->
<div class="d-flex justify-center gap-2 mb-3">
<v-chip
:color="member.status === 'active' ? 'success' : 'error'"
size="small"
variant="tonal"
>
{{ member.status }}
</v-chip>
<v-chip
:color="member.dues_paid_this_year ? 'success' : 'warning'"
size="small"
variant="flat"
>
Dues: {{ member.dues_paid_this_year ? 'Paid' : 'Unpaid' }}
</v-chip>
</div>
<!-- Mark as Paid Button -->
<v-btn
v-if="!member.dues_paid_this_year"
color="success"
variant="flat"
block
class="mb-2"
@click.stop="markDuesPaid(member)"
>
<v-icon start>mdi-cash-check</v-icon>
Mark Dues Paid
</v-btn>
<!-- Action Buttons -->
<div class="d-flex justify-center gap-2">
<v-btn
icon="mdi-eye"
size="small"
variant="tonal"
@click.stop="viewMember(member)"
>
<v-tooltip activator="parent" location="top">View</v-tooltip>
</v-btn>
<v-btn
icon="mdi-pencil"
size="small"
variant="tonal"
@click.stop="editMember(member)"
>
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
</v-btn>
<v-btn
icon="mdi-email"
size="small"
variant="tonal"
@click.stop="sendEmail(member)"
>
<v-tooltip activator="parent" location="top">Email</v-tooltip>
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Grid Pagination -->
<v-card v-if="viewMode === 'grid' && filteredMembers.length > gridItemsPerPage" class="mt-4">
<v-card-text>
<v-pagination
v-model="gridPage"
:length="Math.ceil(filteredMembers.length / gridItemsPerPage)"
:total-visible="7"
/>
</v-card-text>
</v-card>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
@mark-dues-paid="handleMarkDuesPaid"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
<!-- Create Member Dialog -->
<v-dialog v-model="showCreateDialog" max-width="600">
<v-card>
<v-card-title>Add New Member</v-card-title>
<v-card-text>
<v-form ref="memberForm">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="memberForm.first_name"
label="First Name"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="memberForm.last_name"
label="Last Name"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="memberForm.email"
label="Email"
variant="outlined"
type="email"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="memberForm.membership_type"
label="Membership Type"
:items="membershipOptions"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="memberForm.phone"
label="Phone"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-select
v-model="memberForm.nationality"
label="Nationality"
:items="countryOptions"
item-title="name"
item-value="code"
variant="outlined"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCreateDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="saveMember">Create</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { countries } from '~/utils/countries';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const loading = ref(false);
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const showCreateDialog = ref(false);
const selectedMember = ref<Member | null>(null);
const searchQuery = ref('');
const statusFilter = ref(null);
const membershipFilter = ref(null);
const duesFilter = ref(null);
const viewMode = ref('list'); // 'list' or 'grid'
const gridPage = ref(1);
const gridItemsPerPage = 12;
// Stats
const stats = ref({
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0
});
// Form data
const memberForm = ref({
first_name: '',
last_name: '',
email: '',
membership_type: 'Standard',
phone: '',
nationality: ''
});
// Options
const statusOptions = ['active', 'inactive'];
const membershipOptions = ['Standard', 'Premium', 'VIP', 'Lifetime'];
const countryOptions = countries;
// Enhanced table configuration for new columns
const enhancedHeaders = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: true },
{ title: 'Nationality', key: 'nationality', sortable: true },
{ title: 'Dues Paid This Year', key: 'dues_paid', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'center', width: '250' }
];
// Real data from API
const members = ref<Member[]>([]);
// Computed
const filteredMembers = computed(() => {
let filtered = [...members.value];
if (statusFilter.value) {
filtered = filtered.filter(m => m.status === statusFilter.value);
}
if (membershipFilter.value) {
filtered = filtered.filter(m => m.membership_type === membershipFilter.value);
}
if (duesFilter.value) {
if (duesFilter.value === 'Paid') {
filtered = filtered.filter(m => m.dues_paid_this_year);
} else if (duesFilter.value === 'Unpaid' || duesFilter.value === 'Overdue') {
filtered = filtered.filter(m => !m.dues_paid_this_year);
}
}
return filtered;
});
// Paginated grid members
const paginatedGridMembers = computed(() => {
const start = (gridPage.value - 1) * gridItemsPerPage;
const end = start + gridItemsPerPage;
return filteredMembers.value.slice(start, end);
});
// Methods
const getCountryName = (code: string) => {
if (!code) return null;
const country = countries.find(c => c.code === code);
return country ? country.name : code;
};
const getMembershipColor = (type: string) => {
switch (type) {
case 'VIP': return 'error';
case 'Premium': return 'warning';
case 'Lifetime': return 'purple';
default: return 'info';
}
};
const getDuesColor = (status: string) => {
switch (status) {
case 'Paid': return 'success';
case 'Due': return 'warning';
case 'Overdue': return 'error';
default: return 'default';
}
};
const formatDate = (date: string) => {
if (!date) return 'N/A';
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) return 'N/A';
return parsedDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const viewMember = (member: Member) => {
selectedMember.value = member;
showViewDialog.value = true;
};
const editMember = (member: Member) => {
selectedMember.value = member;
showEditDialog.value = true;
};
const handleEditMember = (member: Member) => {
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const handleMemberUpdated = (member: Member) => {
const index = members.value.findIndex(m => m.member_id === member.member_id);
if (index > -1) {
members.value[index] = member;
}
showEditDialog.value = false;
};
const markDuesPaid = async (member: Member) => {
try {
// Update member dues status
member.dues_paid_this_year = true;
member.dues_status = 'Paid';
member.last_dues_paid = new Date().toISOString();
// Update stats
stats.value.paidThisYear++;
stats.value.duesOutstanding--;
// TODO: Make API call to update in database
} catch (error) {
console.error('Error marking dues as paid:', error);
}
};
const handleMarkDuesPaid = (member: Member) => {
markDuesPaid(member);
};
const sendEmail = (member: Member) => {
window.location.href = `mailto:${member.email}`;
};
const viewPaymentHistory = (member: Member) => {
// TODO: Navigate to payment history or open dialog
};
const generateInvoice = (member: Member) => {
// TODO: Generate and download invoice
};
const toggleStatus = (member: Member) => {
member.status = member.status === 'active' ? 'inactive' : 'active';
// TODO: Make API call to update status
};
const exportMembers = () => {
// TODO: Export to CSV/Excel
};
const saveMember = () => {
showCreateDialog.value = false;
// TODO: Make API call to create member
};
// Load real members data from API
const loadMembers = async () => {
loading.value = true;
try {
// Fetch members from API
const response = await $fetch('/api/members');
// Check for both possible response structures
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
if (membersList && membersList.length > 0) {
// Transform the data to match our interface with enhanced fields
const currentYear = new Date().getFullYear();
members.value = membersList.map((member: any) => {
// Determine if dues are paid this year (simplified logic)
const lastPaid = member.last_dues_paid ? new Date(member.last_dues_paid) : null;
const duesPaidThisYear = lastPaid && lastPaid.getFullYear() === currentYear;
return {
...member, // Keep all original fields including Id for API calls
member_id: member.member_id || '', // Use the actual member_id field
first_name: member.first_name,
last_name: member.last_name,
name: `${member.last_name || ''}, ${member.first_name || ''}`.trim(),
email: member.email,
nationality: member.nationality || member.country_code || '',
membership_type: member.membership_type || 'Standard',
status: member.membership_status === 'Active' ? 'active' : 'inactive',
dues_status: member.dues_status || (duesPaidThisYear ? 'Paid' : 'Due'),
dues_paid_this_year: duesPaidThisYear,
last_dues_paid: member.last_dues_paid,
membership_date_paid: member.membership_date_paid,
payment_due_date: member.payment_due_date,
join_date: member.member_since || member.created_at,
phone: member.phone_number || member.phone || ''
};
});
// Sort by last name, then first name by default
members.value.sort((a, b) => {
const aLastName = (a.last_name || '').toLowerCase();
const bLastName = (b.last_name || '').toLowerCase();
const aFirstName = (a.first_name || '').toLowerCase();
const bFirstName = (b.first_name || '').toLowerCase();
const lastNameCompare = aLastName.localeCompare(bLastName);
if (lastNameCompare !== 0) return lastNameCompare;
return aFirstName.localeCompare(bFirstName);
});
// Calculate stats from real data
const currentYearMembers = members.value.filter(m => m.dues_paid_this_year);
stats.value = {
total: members.value.length,
active: members.value.filter(m => m.status === 'active').length,
paidThisYear: currentYearMembers.length,
duesOutstanding: members.value.length - currentYearMembers.length
};
} else {
members.value = [];
stats.value = {
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0
};
}
} catch (error) {
members.value = [];
stats.value = {
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0
};
} finally {
loading.value = false;
}
};
// Load data on mount
onMounted(async () => {
await loadMembers();
});
</script>
<style scoped>
.member-list-table :deep(tbody tr) {
cursor: pointer;
}
.member-list-table :deep(tbody tr:hover) {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.member-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.member-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,557 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">Payment Management</h1>
<p class="text-body-1 text-medium-emphasis">Track and manage all payments and transactions</p>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-cash-plus"
@click="showRecordPaymentDialog = true"
>
Record Payment
</v-btn>
</v-col>
</v-row>
<!-- Stats Cards -->
<v-row class="mb-6">
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">${{ stats.totalRevenue.toLocaleString() }}</div>
<div class="text-body-2 text-medium-emphasis">Total Revenue</div>
</div>
<v-icon size="32" color="success">mdi-cash</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">${{ stats.pendingPayments.toLocaleString() }}</div>
<div class="text-body-2 text-medium-emphasis">Pending</div>
</div>
<v-icon size="32" color="warning">mdi-clock-outline</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.failedTransactions }}</div>
<div class="text-body-2 text-medium-emphasis">Failed</div>
</div>
<v-icon size="32" color="error">mdi-alert-circle-outline</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.successfulTransactions }}</div>
<div class="text-body-2 text-medium-emphasis">Successful</div>
</div>
<v-icon size="32" color="info">mdi-swap-horizontal</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Filters -->
<v-card class="mb-6" elevation="0">
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<v-text-field
v-model="searchQuery"
label="Search payments"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="typeFilter"
label="Type"
:items="typeOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-text-field
v-model="dateFrom"
label="From Date"
type="date"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-text-field
v-model="dateTo"
label="To Date"
type="date"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" md="1">
<v-btn
variant="outlined"
color="primary"
block
@click="exportPayments"
>
Export
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Payments Table -->
<v-card elevation="2">
<v-data-table
:headers="headers"
:items="filteredPayments"
:search="searchQuery"
:loading="loading"
class="elevation-0"
hover
:items-per-page="10"
>
<template v-slot:item.transaction_id="{ item }">
<code class="text-caption">{{ item.transaction_id }}</code>
</template>
<template v-slot:item.member="{ item }">
<div class="py-2">
<div class="font-weight-medium">{{ item.member_name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.member_email }}</div>
</div>
</template>
<template v-slot:item.amount="{ item }">
<span class="font-weight-medium">${{ item.amount.toFixed(2) }}</span>
</template>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeColor(item.type)"
size="small"
variant="tonal"
>
{{ item.type }}
</v-chip>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="tonal"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.date="{ item }">
<span class="text-body-2">{{ formatDate(item.date) }}</span>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click="viewPayment(item)"
/>
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
>
<v-menu activator="parent">
<v-list density="compact">
<v-list-item @click="viewReceipt(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-receipt</v-icon>
View Receipt
</v-list-item-title>
</v-list-item>
<v-list-item @click="sendReceipt(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-email</v-icon>
Email Receipt
</v-list-item-title>
</v-list-item>
<v-list-item
@click="refundPayment(item)"
:disabled="item.status !== 'Completed'"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-cash-refund</v-icon>
Issue Refund
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
@click="markAsPaid(item)"
:disabled="item.status === 'Completed'"
class="text-success"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-check</v-icon>
Mark as Paid
</v-list-item-title>
</v-list-item>
<v-list-item
@click="voidPayment(item)"
class="text-error"
:disabled="item.status === 'Voided'"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-cancel</v-icon>
Void Payment
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
</v-data-table>
</v-card>
<!-- Record Payment Dialog -->
<v-dialog v-model="showRecordPaymentDialog" max-width="600">
<v-card>
<v-card-title>Record Payment</v-card-title>
<v-card-text>
<v-form ref="paymentForm">
<v-row>
<v-col cols="12">
<v-autocomplete
v-model="paymentForm.member_id"
label="Member"
:items="membersList"
item-title="name"
item-value="id"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="paymentForm.type"
label="Payment Type"
:items="typeOptions"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="paymentForm.amount"
label="Amount"
prefix="$"
type="number"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="paymentForm.method"
label="Payment Method"
:items="['Credit Card', 'Check', 'Cash', 'Bank Transfer']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="paymentForm.reference"
label="Reference Number"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="paymentForm.notes"
label="Notes"
variant="outlined"
rows="2"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRecordPaymentDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="savePayment">Record</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const loading = ref(false);
const showRecordPaymentDialog = ref(false);
const searchQuery = ref('');
const statusFilter = ref(null);
const typeFilter = ref(null);
const dateFrom = ref('');
const dateTo = ref('');
// Stats
const stats = ref({
totalRevenue: 0,
pendingPayments: 0,
successfulTransactions: 0,
failedTransactions: 0
});
// Form data
const paymentForm = ref({
member_id: '',
type: '',
amount: 0,
method: '',
reference: '',
notes: ''
});
// Options
const statusOptions = ['Completed', 'Pending', 'Failed', 'Refunded', 'Voided'];
const typeOptions = ['Membership', 'Event', 'Donation', 'Other'];
// Mock members list
const membersList = [
{ id: '1', name: 'John Smith' },
{ id: '2', name: 'Sarah Johnson' },
{ id: '3', name: 'Michael Williams' }
];
// Table configuration
const headers = [
{ title: 'Transaction ID', key: 'transaction_id', sortable: true },
{ title: 'Member', key: 'member', sortable: true },
{ title: 'Amount', key: 'amount', sortable: true },
{ title: 'Type', key: 'type', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Date', key: 'date', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
];
// Real dues payment data
const payments = ref([]);
// Computed
const filteredPayments = computed(() => {
let filtered = [...payments.value];
if (statusFilter.value) {
filtered = filtered.filter(p => p.status === statusFilter.value);
}
if (typeFilter.value) {
filtered = filtered.filter(p => p.type === typeFilter.value);
}
if (dateFrom.value) {
const from = new Date(dateFrom.value);
filtered = filtered.filter(p => new Date(p.date) >= from);
}
if (dateTo.value) {
const to = new Date(dateTo.value);
filtered = filtered.filter(p => new Date(p.date) <= to);
}
return filtered;
});
// Methods
const getStatusColor = (status: string) => {
switch (status) {
case 'Completed': return 'success';
case 'Pending': return 'warning';
case 'Failed': return 'error';
case 'Refunded': return 'info';
case 'Voided': return 'default';
default: return 'default';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'Membership': return 'primary';
case 'Event': return 'info';
case 'Donation': return 'success';
default: return 'default';
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const viewPayment = (payment: any) => {
console.log('View payment:', payment);
};
const viewReceipt = (payment: any) => {
console.log('View receipt:', payment);
};
const sendReceipt = (payment: any) => {
console.log('Send receipt:', payment);
};
const refundPayment = (payment: any) => {
console.log('Refund payment:', payment);
};
const markAsPaid = (payment: any) => {
payment.status = 'Completed';
};
const voidPayment = (payment: any) => {
payment.status = 'Voided';
};
const exportPayments = () => {
console.log('Export payments');
};
const savePayment = () => {
console.log('Save payment:', paymentForm.value);
showRecordPaymentDialog.value = false;
};
// Load dues payment data from members
const loadPayments = async () => {
try {
// Fetch members from API
const response = await $fetch('/api/members');
// Check for both possible response structures
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
if (membersList && membersList.length > 0) {
const paymentRecords = [];
let transactionCounter = 1;
// Generate payment records from member dues data
for (const member of membersList) {
// If member has last_dues_paid, create a payment record
if (member.last_dues_paid) {
paymentRecords.push({
id: transactionCounter++,
transaction_id: `TXN-${new Date(member.last_dues_paid).getFullYear()}-${String(transactionCounter).padStart(3, '0')}`,
member_name: `${member.first_name} ${member.last_name}`,
member_email: member.email,
amount: member.dues_amount || 50, // Default annual dues
type: 'Membership Dues',
status: 'Completed',
date: new Date(member.last_dues_paid),
method: member.last_payment_method || 'Unknown'
});
}
// If member has dues due/overdue, create a pending payment record
if (member.dues_status === 'Due' || member.dues_status === 'Overdue') {
const dueDate = member.payment_due_date ? new Date(member.payment_due_date) : null;
if (dueDate) {
paymentRecords.push({
id: transactionCounter++,
transaction_id: `TXN-PENDING-${String(transactionCounter).padStart(3, '0')}`,
member_name: `${member.first_name} ${member.last_name}`,
member_email: member.email,
amount: member.dues_amount || 50,
type: 'Membership Dues',
status: member.dues_status === 'Overdue' ? 'Overdue' : 'Pending',
date: dueDate,
method: 'Awaiting Payment'
});
}
}
}
// Sort by date descending (most recent first)
paymentRecords.sort((a, b) => b.date.getTime() - a.date.getTime());
payments.value = paymentRecords;
// Calculate stats
const completed = paymentRecords.filter(p => p.status === 'Completed');
const pending = paymentRecords.filter(p => p.status === 'Pending' || p.status === 'Overdue');
stats.value = {
totalRevenue: completed.reduce((sum, p) => sum + p.amount, 0),
pendingPayments: pending.reduce((sum, p) => sum + p.amount, 0),
successfulTransactions: completed.length,
failedTransactions: paymentRecords.filter(p => p.status === 'Failed').length
};
console.log(`[admin-payments] Generated ${paymentRecords.length} payment records from member dues data`);
}
} catch (error) {
console.error('Error loading payments:', error);
// Keep empty array if load fails
}
};
// Load data on mount
onMounted(async () => {
await loadPayments();
});
</script>

View File

@@ -0,0 +1,818 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">System Settings</h1>
<p class="text-body-1 text-medium-emphasis">Configure system preferences and options</p>
</v-col>
</v-row>
<!-- Settings Tabs -->
<v-card elevation="2">
<v-tabs v-model="activeTab" color="primary">
<v-tab value="general">
<v-icon start>mdi-cog</v-icon>
General
</v-tab>
<v-tab value="email">
<v-icon start>mdi-email</v-icon>
Email
</v-tab>
<v-tab value="nocodb">
<v-icon start>mdi-database</v-icon>
NocoDB
</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<!-- General Settings -->
<v-window-item value="general">
<v-card-text>
<!-- Edit Mode Toggle -->
<v-row class="mb-4">
<v-col>
<v-alert
v-if="!generalEditMode"
type="info"
variant="tonal"
density="compact"
>
<template v-slot:text>
Click "Edit Settings" to modify these values
</template>
</v-alert>
</v-col>
<v-col cols="auto">
<v-btn
v-if="!generalEditMode"
color="primary"
variant="outlined"
@click="generalEditMode = true"
>
<v-icon start>mdi-pencil</v-icon>
Edit Settings
</v-btn>
<v-btn-group v-else>
<v-btn
color="success"
variant="flat"
@click="saveGeneralSettings"
>
<v-icon start>mdi-check</v-icon>
Save
</v-btn>
<v-btn
color="error"
variant="outlined"
@click="cancelGeneralEdit"
>
<v-icon start>mdi-close</v-icon>
Cancel
</v-btn>
</v-btn-group>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<h3 class="text-h6 mb-4">Organization Information</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.general.orgName"
label="Organization Name"
variant="outlined"
:readonly="!generalEditMode"
:disabled="!generalEditMode"
autocomplete="off"
:class="{ 'readonly-field': !generalEditMode }"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.general.orgEmail"
label="Contact Email"
variant="outlined"
type="email"
:readonly="!generalEditMode"
:disabled="!generalEditMode"
autocomplete="off"
:class="{ 'readonly-field': !generalEditMode }"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="settings.general.orgDescription"
label="Description"
variant="outlined"
rows="3"
:readonly="!generalEditMode"
:disabled="!generalEditMode"
autocomplete="off"
:class="{ 'readonly-field': !generalEditMode }"
/>
</v-col>
<v-col cols="12">
<v-divider class="my-4" />
</v-col>
<v-col cols="12">
<h3 class="text-h6 mb-4">Regional Settings</h3>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="settings.general.timezone"
label="Timezone"
:items="timezones"
variant="outlined"
:readonly="!generalEditMode"
:disabled="!generalEditMode"
:class="{ 'readonly-field': !generalEditMode }"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="settings.general.dateFormat"
label="Date Format"
:items="dateFormats"
variant="outlined"
:readonly="!generalEditMode"
:disabled="!generalEditMode"
:class="{ 'readonly-field': !generalEditMode }"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="settings.general.currency"
label="Currency"
:items="currencies"
variant="outlined"
:readonly="!generalEditMode"
:disabled="!generalEditMode"
:class="{ 'readonly-field': !generalEditMode }"
/>
</v-col>
</v-row>
</v-card-text>
</v-window-item>
<!-- Email Settings -->
<v-window-item value="email">
<v-card-text>
<!-- Edit Mode Toggle -->
<v-row class="mb-4">
<v-col>
<v-alert
v-if="!emailEditMode"
type="info"
variant="tonal"
density="compact"
>
<template v-slot:text>
Click "Edit Email Configuration" to modify SMTP settings
</template>
</v-alert>
<v-alert
v-if="emailEditMode"
type="warning"
variant="tonal"
density="compact"
>
<template v-slot:text>
Be careful when editing email settings. Incorrect values may prevent emails from being sent.
</template>
</v-alert>
</v-col>
<v-col cols="auto">
<v-btn
v-if="!emailEditMode"
color="primary"
variant="outlined"
@click="emailEditMode = true"
>
<v-icon start>mdi-pencil</v-icon>
Edit Email Configuration
</v-btn>
<v-btn-group v-else>
<v-btn
color="success"
variant="flat"
@click="saveEmailSettings"
>
<v-icon start>mdi-check</v-icon>
Save
</v-btn>
<v-btn
color="warning"
variant="outlined"
@click="testEmailSettings"
:loading="testingEmail"
>
<v-icon start>mdi-email-check</v-icon>
Test
</v-btn>
<v-btn
color="error"
variant="outlined"
@click="cancelEmailEdit"
>
<v-icon start>mdi-close</v-icon>
Cancel
</v-btn>
</v-btn-group>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<h3 class="text-h6 mb-4">SMTP Configuration</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.email.smtpHost"
label="SMTP Host"
variant="outlined"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
autocomplete="new-password"
:type="emailEditMode ? 'text' : 'password'"
:class="{ 'readonly-field': !emailEditMode }"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.email.smtpPort"
label="SMTP Port"
variant="outlined"
type="number"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
autocomplete="off"
:class="{ 'readonly-field': !emailEditMode }"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.email.smtpUsername"
label="SMTP Username"
variant="outlined"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
autocomplete="new-password"
:type="emailEditMode ? 'text' : 'password'"
:class="{ 'readonly-field': !emailEditMode }"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.email.smtpPassword"
label="SMTP Password"
variant="outlined"
:type="showPassword ? 'text' : 'password'"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
autocomplete="new-password"
:class="{ 'readonly-field': !emailEditMode }"
>
<template v-slot:append-inner>
<v-icon
v-if="emailEditMode"
@click="showPassword = !showPassword"
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
class="cursor-pointer"
/>
</template>
</v-text-field>
</v-col>
<v-col cols="12">
<v-switch
v-model="settings.email.useTLS"
label="Use TLS/SSL"
color="primary"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
/>
</v-col>
<v-col cols="12">
<v-divider class="my-4" />
</v-col>
<v-col cols="12">
<h3 class="text-h6 mb-4">Email Templates</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.email.fromName"
label="From Name"
variant="outlined"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
autocomplete="off"
:class="{ 'readonly-field': !emailEditMode }"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="settings.email.fromEmail"
label="From Email"
variant="outlined"
type="email"
:readonly="!emailEditMode"
:disabled="!emailEditMode"
autocomplete="off"
:class="{ 'readonly-field': !emailEditMode }"
/>
</v-col>
<v-col cols="12">
<v-btn variant="outlined" color="primary">
<v-icon start>mdi-email-edit</v-icon>
Manage Email Templates
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-window-item>
<!-- NocoDB Settings -->
<v-window-item value="nocodb">
<v-card-text>
<!-- Edit Mode Toggle -->
<v-row class="mb-4">
<v-col>
<v-alert
v-if="!nocodbEditMode"
type="info"
variant="tonal"
density="compact"
>
<template v-slot:text>
Click "Edit NocoDB Configuration" to modify database settings
</template>
</v-alert>
<v-alert
v-if="nocodbEditMode"
type="warning"
variant="tonal"
density="compact"
>
<template v-slot:text>
NocoDB configuration is required for member management functionality
</template>
</v-alert>
</v-col>
<v-col cols="auto">
<v-btn
v-if="!nocodbEditMode"
color="primary"
variant="outlined"
@click="nocodbEditMode = true"
>
<v-icon start>mdi-pencil</v-icon>
Edit NocoDB Configuration
</v-btn>
<v-btn-group v-else>
<v-btn
color="success"
variant="flat"
@click="saveNocodbSettings"
>
<v-icon start>mdi-check</v-icon>
Save
</v-btn>
<v-btn
color="warning"
variant="outlined"
@click="testNocodbConnection"
:loading="testingNocodb"
>
<v-icon start>mdi-connection</v-icon>
Test Connection
</v-btn>
<v-btn
color="error"
variant="outlined"
@click="cancelNocodbEdit"
>
<v-icon start>mdi-close</v-icon>
Cancel
</v-btn>
</v-btn-group>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<h3 class="text-h6 mb-4">NocoDB Database Configuration</h3>
</v-col>
<v-col cols="12">
<v-text-field
v-model="settings.nocodb.url"
label="NocoDB URL"
variant="outlined"
placeholder="https://your-nocodb-instance.com"
:readonly="!nocodbEditMode"
:disabled="!nocodbEditMode"
autocomplete="off"
:class="{ 'readonly-field': !nocodbEditMode }"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="settings.nocodb.apiKey"
label="API Key"
variant="outlined"
:type="showNocodbApiKey ? 'text' : 'password'"
placeholder="Enter your NocoDB API token"
:readonly="!nocodbEditMode"
:disabled="!nocodbEditMode"
autocomplete="new-password"
:class="{ 'readonly-field': !nocodbEditMode }"
>
<template v-slot:append-inner>
<v-icon
v-if="nocodbEditMode"
@click="showNocodbApiKey = !showNocodbApiKey"
:icon="showNocodbApiKey ? 'mdi-eye-off' : 'mdi-eye'"
class="cursor-pointer"
/>
</template>
</v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
v-model="settings.nocodb.baseId"
label="Base ID"
variant="outlined"
placeholder="Your NocoDB base ID"
:readonly="!nocodbEditMode"
:disabled="!nocodbEditMode"
autocomplete="off"
:class="{ 'readonly-field': !nocodbEditMode }"
/>
</v-col>
<v-col cols="12">
<v-divider class="my-4" />
</v-col>
<v-col cols="12">
<h3 class="text-h6 mb-4">Table Mappings</h3>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="settings.nocodb.tables.members"
label="Members Table"
variant="outlined"
:readonly="!nocodbEditMode"
:disabled="!nocodbEditMode"
autocomplete="off"
:class="{ 'readonly-field': !nocodbEditMode }"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="settings.nocodb.tables.events"
label="Events Table"
variant="outlined"
:readonly="!nocodbEditMode"
:disabled="!nocodbEditMode"
autocomplete="off"
:class="{ 'readonly-field': !nocodbEditMode }"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="settings.nocodb.tables.rsvps"
label="RSVPs Table"
variant="outlined"
:readonly="!nocodbEditMode"
:disabled="!nocodbEditMode"
autocomplete="off"
:class="{ 'readonly-field': !nocodbEditMode }"
/>
</v-col>
</v-row>
<!-- Connection Status -->
<v-row v-if="nocodbConnectionStatus" class="mt-4">
<v-col>
<v-alert
:type="nocodbConnectionStatus.success ? 'success' : 'error'"
variant="tonal"
>
{{ nocodbConnectionStatus.message }}
</v-alert>
</v-col>
</v-row>
</v-card-text>
</v-window-item>
</v-window>
</v-card>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn
variant="text"
@click="snackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const activeTab = ref('general');
const generalEditMode = ref(false);
const emailEditMode = ref(false);
const nocodbEditMode = ref(false);
const showPassword = ref(false);
const showNocodbApiKey = ref(false);
const testingEmail = ref(false);
const testingNocodb = ref(false);
const nocodbConnectionStatus = ref<{ success: boolean; message: string } | null>(null);
const snackbar = ref(false);
const snackbarText = ref('');
const snackbarColor = ref('success');
// Original settings backup for cancel functionality
const originalSettings = ref<any>(null);
// Settings data
const settings = ref({
general: {
orgName: 'MonacoUSA',
orgEmail: 'info@monacousa.org',
orgDescription: 'Monaco USA Association - Connecting Monaco and USA',
timezone: 'America/New_York',
dateFormat: 'MM/DD/YYYY',
currency: 'EUR'
},
email: {
smtpHost: 'smtp.gmail.com',
smtpPort: 587,
smtpUsername: '',
smtpPassword: '',
useTLS: true,
fromName: 'MonacoUSA',
fromEmail: 'noreply@monacousa.org'
},
nocodb: {
url: '',
apiKey: '',
baseId: '',
tables: {
members: 'Members',
events: 'Events',
rsvps: 'RSVPs'
}
}
});
// Options
const timezones = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/Monaco'
];
const dateFormats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD'
];
const currencies = [
'EUR',
'USD',
'GBP'
];
// Load settings on mount
onMounted(async () => {
await loadSettings();
await loadNocodbSettings();
});
// Methods
const loadSettings = async () => {
try {
// Load settings from API
// For now, we'll keep the defaults
console.log('Loading settings...');
} catch (error) {
console.error('Error loading settings:', error);
showNotification('Failed to load settings', 'error');
}
};
const saveGeneralSettings = async () => {
try {
console.log('Saving general settings:', settings.value.general);
// TODO: Save to API
generalEditMode.value = false;
showNotification('General settings saved successfully', 'success');
} catch (error) {
console.error('Error saving general settings:', error);
showNotification('Failed to save general settings', 'error');
}
};
const cancelGeneralEdit = () => {
if (originalSettings.value) {
settings.value.general = { ...originalSettings.value.general };
}
generalEditMode.value = false;
};
const saveEmailSettings = async () => {
try {
console.log('Saving email settings:', settings.value.email);
// TODO: Save to API
emailEditMode.value = false;
showPassword.value = false;
showNotification('Email settings saved successfully', 'success');
} catch (error) {
console.error('Error saving email settings:', error);
showNotification('Failed to save email settings', 'error');
}
};
const testEmailSettings = async () => {
testingEmail.value = true;
try {
console.log('Testing email settings...');
// TODO: Test email configuration via API
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
showNotification('Test email sent successfully', 'success');
} catch (error) {
console.error('Error testing email:', error);
showNotification('Failed to send test email', 'error');
} finally {
testingEmail.value = false;
}
};
const cancelEmailEdit = () => {
if (originalSettings.value) {
settings.value.email = { ...originalSettings.value.email };
}
emailEditMode.value = false;
showPassword.value = false;
};
const showNotification = (text: string, color: string = 'success') => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
const loadNocodbSettings = async () => {
try {
const response = await $fetch<{ success: boolean; data?: any }>('/api/admin/nocodb-config');
if (response.success && response.data) {
settings.value.nocodb = {
url: response.data.url || '',
apiKey: response.data.apiKey || '',
baseId: response.data.baseId || '',
tables: response.data.tables || {
members: 'Members',
events: 'Events',
rsvps: 'RSVPs'
}
};
}
} catch (error) {
console.error('Error loading NocoDB settings:', error);
showNotification('Failed to load NocoDB settings', 'error');
}
};
const saveNocodbSettings = async () => {
try {
const response = await $fetch('/api/admin/nocodb-config', {
method: 'POST',
body: settings.value.nocodb
});
nocodbEditMode.value = false;
showNocodbApiKey.value = false;
showNotification('NocoDB settings saved successfully', 'success');
// Reload settings to ensure they're persistent
await loadNocodbSettings();
} catch (error) {
console.error('Error saving NocoDB settings:', error);
showNotification('Failed to save NocoDB settings', 'error');
}
};
const testNocodbConnection = async () => {
testingNocodb.value = true;
nocodbConnectionStatus.value = null;
try {
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/nocodb-test', {
method: 'POST',
body: settings.value.nocodb
});
nocodbConnectionStatus.value = {
success: response.success,
message: response.message
};
if (response.success) {
showNotification('NocoDB connection successful', 'success');
} else {
showNotification(response.message, 'error');
}
} catch (error: any) {
nocodbConnectionStatus.value = {
success: false,
message: error.data?.message || 'Failed to connect to NocoDB'
};
showNotification('Connection test failed', 'error');
} finally {
testingNocodb.value = false;
}
};
const cancelNocodbEdit = () => {
if (originalSettings.value) {
settings.value.nocodb = { ...originalSettings.value.nocodb };
}
nocodbEditMode.value = false;
showNocodbApiKey.value = false;
nocodbConnectionStatus.value = null;
};
// Watch for edit mode changes to backup original settings
watch(generalEditMode, (newVal) => {
if (newVal) {
originalSettings.value = {
general: { ...settings.value.general }
};
}
});
watch(emailEditMode, (newVal) => {
if (newVal) {
originalSettings.value = {
email: { ...settings.value.email }
};
}
});
watch(nocodbEditMode, (newVal) => {
if (newVal) {
originalSettings.value = {
nocodb: { ...settings.value.nocodb }
};
}
});
// Prevent browser autofill on mount
onMounted(() => {
// Disable autofill for all inputs initially
const inputs = document.querySelectorAll('input');
inputs.forEach(input => {
input.setAttribute('autocomplete', 'off');
input.setAttribute('data-lpignore', 'true'); // LastPass
input.setAttribute('data-form-type', 'other'); // Dashlane
});
});
</script>
<style scoped>
.readonly-field :deep(.v-field) {
background-color: rgba(0, 0, 0, 0.02);
}
.readonly-field :deep(.v-field__input) {
cursor: default !important;
}
.cursor-pointer {
cursor: pointer;
}
/* Prevent browser autofill styling */
:deep(input:-webkit-autofill),
:deep(input:-webkit-autofill:hover),
:deep(input:-webkit-autofill:focus),
:deep(input:-webkit-autofill:active) {
-webkit-box-shadow: 0 0 0 30px white inset !important;
box-shadow: 0 0 0 30px white inset !important;
}
</style>

415
pages/admin/users/index.vue Normal file
View File

@@ -0,0 +1,415 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">User Management</h1>
<p class="text-body-1 text-medium-emphasis">Manage system users and permissions</p>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-account-plus"
@click="showCreateDialog = true"
>
Add User
</v-btn>
</v-col>
</v-row>
<!-- Filters -->
<v-card class="mb-6" elevation="0">
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model="searchQuery"
label="Search users"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="roleFilter"
label="Role"
:items="roleOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-btn
variant="outlined"
color="primary"
block
@click="resetFilters"
>
Reset Filters
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Users Table -->
<v-card elevation="2">
<v-data-table
:headers="headers"
:items="filteredUsers"
:search="searchQuery"
:loading="loading"
class="elevation-0"
hover
>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2">
<ProfileAvatar
:member-name="item.name"
size="small"
class="mr-3"
/>
<div>
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.email }}</div>
</div>
</div>
</template>
<template v-slot:item.role="{ item }">
<v-chip
:color="getRoleColor(item.role)"
size="small"
variant="tonal"
>
{{ item.role }}
</v-chip>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="item.status === 'active' ? 'success' : 'error'"
size="small"
variant="tonal"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.lastLogin="{ item }">
<span class="text-body-2">{{ formatDate(item.lastLogin) }}</span>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editUser(item)"
/>
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
>
<v-menu activator="parent">
<v-list density="compact">
<v-list-item @click="viewUser(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-eye</v-icon>
View Details
</v-list-item-title>
</v-list-item>
<v-list-item @click="resetPassword(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-lock-reset</v-icon>
Reset Password
</v-list-item-title>
</v-list-item>
<v-list-item
@click="toggleStatus(item)"
:class="item.status === 'active' ? 'text-error' : 'text-success'"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">
{{ item.status === 'active' ? 'mdi-account-off' : 'mdi-account-check' }}
</v-icon>
{{ item.status === 'active' ? 'Deactivate' : 'Activate' }}
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item @click="deleteUser(item)" class="text-error">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-delete</v-icon>
Delete User
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
<template v-slot:bottom>
<v-divider />
<div class="d-flex align-center justify-space-between pa-4">
<div class="text-body-2 text-medium-emphasis">
Showing {{ filteredUsers.length }} of {{ totalUsers }} users
</div>
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="5"
density="compact"
/>
</div>
</template>
</v-data-table>
</v-card>
<!-- Create/Edit Dialog -->
<v-dialog v-model="showCreateDialog" max-width="600">
<v-card>
<v-card-title>
{{ editingUser ? 'Edit User' : 'Create New User' }}
</v-card-title>
<v-card-text>
<v-form ref="userForm">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="userForm.firstName"
label="First Name"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="userForm.lastName"
label="Last Name"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="userForm.email"
label="Email"
variant="outlined"
type="email"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="userForm.role"
label="Role"
:items="roleOptions"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="userForm.status"
label="Status"
:items="statusOptions"
variant="outlined"
required
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCreateDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="saveUser">
{{ editingUser ? 'Update' : 'Create' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import ProfileAvatar from '~/components/ProfileAvatar.vue';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const loading = ref(false);
const showCreateDialog = ref(false);
const editingUser = ref(null);
const searchQuery = ref('');
const roleFilter = ref(null);
const statusFilter = ref(null);
const currentPage = ref(1);
// Form data
const userForm = ref({
firstName: '',
lastName: '',
email: '',
role: 'member',
status: 'active'
});
// Options
const roleOptions = [
{ title: 'Admin', value: 'admin' },
{ title: 'Board', value: 'board' },
{ title: 'Member', value: 'member' }
];
const statusOptions = [
{ title: 'Active', value: 'active' },
{ title: 'Inactive', value: 'inactive' }
];
// Table configuration
const headers = [
{ title: 'User', key: 'name', sortable: true },
{ title: 'Role', key: 'role', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Last Login', key: 'lastLogin', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
];
// Real data from Keycloak
const users = ref([]);
// Computed
const filteredUsers = computed(() => {
let filtered = [...users.value];
if (roleFilter.value) {
filtered = filtered.filter(u => u.role === roleFilter.value);
}
if (statusFilter.value) {
filtered = filtered.filter(u => u.status === statusFilter.value);
}
return filtered;
});
const totalUsers = computed(() => users.value.length);
const totalPages = computed(() => Math.ceil(filteredUsers.value.length / 10));
// Methods
const getRoleColor = (role: string) => {
switch (role) {
case 'admin': return 'error';
case 'board': return 'warning';
case 'member': return 'info';
default: return 'default';
}
};
const formatDate = (date: Date) => {
if (!date) return 'Never';
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const resetFilters = () => {
searchQuery.value = '';
roleFilter.value = null;
statusFilter.value = null;
};
const editUser = (user: any) => {
editingUser.value = user;
userForm.value = {
firstName: user.name.split(' ')[0],
lastName: user.name.split(' ')[1] || '',
email: user.email,
role: user.role,
status: user.status
};
showCreateDialog.value = true;
};
const viewUser = (user: any) => {
console.log('View user:', user);
};
const resetPassword = (user: any) => {
console.log('Reset password for:', user);
};
const toggleStatus = (user: any) => {
user.status = user.status === 'active' ? 'inactive' : 'active';
};
const deleteUser = (user: any) => {
console.log('Delete user:', user);
};
const saveUser = () => {
console.log('Save user:', userForm.value);
showCreateDialog.value = false;
editingUser.value = null;
};
// Load real users from Keycloak
const loadUsers = async () => {
loading.value = true;
try {
// Fetch users from Keycloak API
const response = await $fetch('/api/admin/users');
if (response?.success && response.data?.users) {
// Transform Keycloak users to our format
users.value = response.data.users.map((user: any) => ({
id: user.id,
name: `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.username,
email: user.email,
role: user.groups?.[0]?.name || 'member', // Use primary group as role
status: user.enabled ? 'active' : 'inactive',
lastLogin: user.lastLogin ? new Date(user.lastLogin) : null,
avatar: null
}));
console.log(`[admin-users] Loaded ${users.value.length} users from Keycloak`);
}
} catch (error) {
console.error('Error loading users:', error);
// Keep empty array if load fails
} finally {
loading.value = false;
}
};
// Load data on mount
onMounted(async () => {
await loadUsers();
});
</script>

View File

@@ -0,0 +1,406 @@
<template>
<div class="auth-page">
<div class="auth-container auth-container--small">
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0 }"
class="auth-content"
>
<!-- Logo -->
<div class="auth-logo">
<img src="/MONACOUSA-Flags_376x376.png" alt="MonacoUSA" class="logo" />
<h1>MonacoUSA Portal</h1>
</div>
<!-- Step 1: Request Reset -->
<div v-if="!emailSent" class="reset-step">
<div class="auth-header">
<Icon name="lock" class="auth-header__icon" />
<h2>Forgot Your Password?</h2>
<p>No worries! Enter your email and we'll send you reset instructions.</p>
</div>
<form class="auth-form" @submit.prevent="handleResetRequest">
<FloatingInput
v-model="email"
label="Email Address"
type="email"
variant="glass"
leftIcon="mail"
helperText="Enter the email associated with your account"
:error="error"
required
/>
<MonacoButton
type="submit"
variant="primary"
size="lg"
block
:loading="loading"
>
Send Reset Instructions
</MonacoButton>
<MonacoButton
variant="ghost"
size="lg"
block
@click="goBack"
>
Back to Login
</MonacoButton>
</form>
</div>
<!-- Step 2: Email Sent Confirmation -->
<div v-else class="success-step">
<div class="success-icon">
<Icon name="mail" />
</div>
<div class="auth-header">
<h2>Check Your Email</h2>
<p>We've sent password reset instructions to:</p>
<p class="email-display">{{ email }}</p>
</div>
<div class="instructions">
<h3>What's next?</h3>
<ol>
<li>Check your email inbox (and spam folder)</li>
<li>Click the reset link in the email</li>
<li>Create your new password</li>
</ol>
</div>
<div class="resend-section">
<p>Didn't receive the email?</p>
<button
class="resend-button"
@click="handleResend"
:disabled="resendCooldown > 0"
>
{{ resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend Email' }}
</button>
</div>
<MonacoButton
variant="primary"
size="lg"
block
@click="goBack"
>
Return to Login
</MonacoButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import FloatingInput from '~/components/ui/FloatingInput.vue'
import MonacoButton from '~/components/ui/MonacoButton.vue'
import Icon from '~/components/ui/Icon.vue'
const email = ref('')
const error = ref('')
const loading = ref(false)
const emailSent = ref(false)
const resendCooldown = ref(0)
let cooldownInterval: number | null = null
const handleResetRequest = async () => {
error.value = ''
loading.value = true
// Simulate API call
setTimeout(() => {
loading.value = false
emailSent.value = true
startResendCooldown()
}, 2000)
}
const handleResend = () => {
if (resendCooldown.value > 0) return
// Simulate resending email
console.log('Resending to:', email.value)
startResendCooldown()
}
const startResendCooldown = () => {
resendCooldown.value = 60
if (cooldownInterval) {
clearInterval(cooldownInterval)
}
cooldownInterval = setInterval(() => {
resendCooldown.value--
if (resendCooldown.value <= 0 && cooldownInterval) {
clearInterval(cooldownInterval)
cooldownInterval = null
}
}, 1000)
}
const goBack = () => {
window.location.href = '/auth/login'
}
onUnmounted(() => {
if (cooldownInterval) {
clearInterval(cooldownInterval)
}
})
</script>
<style scoped lang="scss">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%);
padding: 2rem;
position: relative;
overflow: hidden;
// Background decoration
&::before,
&::after {
content: '';
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(220, 38, 38, 0.02) 100%);
}
&::before {
width: 500px;
height: 500px;
top: -250px;
left: -250px;
}
&::after {
width: 400px;
height: 400px;
bottom: -200px;
right: -200px;
}
}
.auth-container {
position: relative;
max-width: 500px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: 1;
&--small {
max-width: 450px;
}
}
.auth-content {
padding: 3rem;
}
.auth-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
img {
width: 48px;
height: 48px;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #dc2626;
}
}
.auth-header {
text-align: center;
margin-bottom: 2rem;
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: 1rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%);
border-radius: 16px;
color: #dc2626;
svg {
width: 32px;
height: 32px;
}
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.75rem;
font-weight: 700;
color: #27272a;
}
p {
margin: 0;
color: #6b7280;
line-height: 1.5;
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.success-step {
text-align: center;
}
.success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 50%;
color: white;
animation: successPulse 2s ease-in-out infinite;
svg {
width: 40px;
height: 40px;
}
}
@keyframes successPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 20px rgba(16, 185, 129, 0);
}
}
.email-display {
margin-top: 0.5rem;
padding: 0.75rem;
background: rgba(220, 38, 38, 0.05);
border-radius: 8px;
font-weight: 600;
color: #dc2626;
}
.instructions {
margin: 2rem 0;
padding: 1.5rem;
background: rgba(107, 114, 128, 0.05);
border-radius: 12px;
text-align: left;
h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #27272a;
}
ol {
margin: 0;
padding-left: 1.5rem;
li {
margin-bottom: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
&:last-child {
margin-bottom: 0;
}
}
}
}
.resend-section {
margin: 2rem 0;
padding: 1.5rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.03) 0%,
rgba(220, 38, 38, 0.01) 100%);
border-radius: 12px;
p {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
}
.resend-button {
padding: 0.5rem 1rem;
background: none;
border: 2px solid #dc2626;
border-radius: 8px;
color: #dc2626;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #dc2626;
color: white;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Responsive
@media (max-width: 640px) {
.auth-content {
padding: 2rem;
}
.auth-logo {
flex-direction: column;
h1 {
font-size: 1.25rem;
}
}
}
</style>

466
pages/auth/login-mockup.vue Normal file
View File

@@ -0,0 +1,466 @@
<template>
<div class="auth-page">
<div class="auth-container">
<!-- Left Panel - Form -->
<div
v-motion
:initial="{ opacity: 0, x: -50 }"
:enter="{ opacity: 1, x: 0 }"
class="auth-panel auth-panel--form"
>
<div class="auth-logo">
<img src="/MONACOUSA-Flags_376x376.png" alt="MonacoUSA" class="logo" />
<h1>MonacoUSA Portal</h1>
</div>
<div class="auth-header">
<h2>Welcome Back</h2>
<p>Sign in to access your Monaco community</p>
</div>
<form class="auth-form" @submit.prevent="handleLogin">
<FloatingInput
v-model="form.email"
label="Email Address"
type="email"
variant="glass"
leftIcon="mail"
:error="errors.email"
required
/>
<FloatingInput
v-model="form.password"
label="Password"
type="password"
variant="glass"
leftIcon="lock"
:error="errors.password"
required
/>
<div class="auth-options">
<label class="checkbox-label">
<input type="checkbox" v-model="form.remember" />
<span>Remember me</span>
</label>
<a href="/auth/forgot-password" class="link">Forgot password?</a>
</div>
<MonacoButton
type="submit"
variant="primary"
size="lg"
block
:loading="loading"
>
Sign In
</MonacoButton>
<div class="auth-divider">
<span>or continue with</span>
</div>
<div class="social-buttons">
<button type="button" class="social-button">
<Icon name="globe" />
<span>Google</span>
</button>
<button type="button" class="social-button">
<Icon name="briefcase" />
<span>LinkedIn</span>
</button>
</div>
</form>
<div class="auth-footer">
<p>Don't have an account? <a href="/auth/signup" class="link">Sign up</a></p>
</div>
</div>
<!-- Right Panel - Visual -->
<div
v-motion
:initial="{ opacity: 0, x: 50 }"
:enter="{ opacity: 1, x: 0, transition: { delay: 200 } }"
class="auth-panel auth-panel--visual"
>
<div class="visual-content">
<div class="visual-gradient"></div>
<div class="visual-pattern"></div>
<div class="visual-text">
<h3>Connect with Monaco's Elite Business Community</h3>
<p>Join exclusive events, network with leaders, and grow your business in the heart of luxury and innovation.</p>
<div class="stats">
<div class="stat">
<span class="stat__value">500+</span>
<span class="stat__label">Members</span>
</div>
<div class="stat">
<span class="stat__value">50+</span>
<span class="stat__label">Events/Year</span>
</div>
<div class="stat">
<span class="stat__value">25+</span>
<span class="stat__label">Countries</span>
</div>
</div>
</div>
<div class="visual-decoration">
<div class="decoration-circle decoration-circle--1"></div>
<div class="decoration-circle decoration-circle--2"></div>
<div class="decoration-circle decoration-circle--3"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FloatingInput from '~/components/ui/FloatingInput.vue'
import MonacoButton from '~/components/ui/MonacoButton.vue'
import Icon from '~/components/ui/Icon.vue'
const form = ref({
email: '',
password: '',
remember: false
})
const errors = ref({
email: '',
password: ''
})
const loading = ref(false)
const handleLogin = async () => {
loading.value = true
errors.value = { email: '', password: '' }
// Simulate API call
setTimeout(() => {
loading.value = false
console.log('Login with:', form.value)
}, 2000)
}
</script>
<style scoped lang="scss">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%);
padding: 2rem;
}
.auth-container {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 100%;
min-height: 700px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.auth-panel {
padding: 3rem;
display: flex;
flex-direction: column;
&--form {
justify-content: center;
}
&--visual {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
position: relative;
overflow: hidden;
}
}
.auth-logo {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 3rem;
img {
width: 48px;
height: 48px;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #dc2626;
}
}
.auth-header {
margin-bottom: 2rem;
h2 {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 700;
color: #27272a;
}
p {
margin: 0;
color: #6b7280;
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.auth-options {
display: flex;
justify-content: space-between;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
accent-color: #dc2626;
cursor: pointer;
}
span {
font-size: 0.875rem;
color: #6b7280;
}
}
.link {
color: #dc2626;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
}
.auth-divider {
position: relative;
text-align: center;
margin: 1rem 0;
span {
position: relative;
padding: 0 1rem;
background: white;
color: #a3a3a3;
font-size: 0.875rem;
}
&::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: #e5e5e5;
}
}
.social-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.social-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: white;
border: 2px solid #e5e5e5;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
color: #27272a;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #dc2626;
transform: translateY(-2px);
}
}
.auth-footer {
margin-top: 2rem;
text-align: center;
p {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
}
.visual-content {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
color: white;
z-index: 1;
}
.visual-gradient {
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
transparent 100%
);
z-index: 1;
}
.visual-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 35px,
rgba(255, 255, 255, 0.1) 35px,
rgba(255, 255, 255, 0.1) 70px
);
z-index: 0;
}
.visual-text {
position: relative;
z-index: 2;
h3 {
margin: 0 0 1rem;
font-size: 2rem;
font-weight: 700;
}
p {
margin: 0 0 3rem;
font-size: 1.125rem;
opacity: 0.95;
line-height: 1.6;
}
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.stat {
display: flex;
flex-direction: column;
&__value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
&__label {
font-size: 0.875rem;
opacity: 0.9;
}
}
.visual-decoration {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
&--1 {
width: 300px;
height: 300px;
top: -150px;
right: -150px;
}
&--2 {
width: 200px;
height: 200px;
bottom: -100px;
left: -100px;
}
&--3 {
width: 150px;
height: 150px;
top: 50%;
right: 10%;
transform: translateY(-50%);
}
}
// Responsive
@media (max-width: 1024px) {
.auth-container {
grid-template-columns: 1fr;
max-width: 500px;
}
.auth-panel--visual {
display: none;
}
}
@media (max-width: 640px) {
.auth-panel {
padding: 2rem;
}
.social-buttons {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,489 @@
<template>
<div :class="containerClasses">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<div class="mb-6">
<v-icon
color="primary"
size="80"
class="mb-4"
>
mdi-lock-plus
</v-icon>
<h1 class="text-h4 font-weight-bold text-primary mb-3">
Set Your Password
</h1>
<p class="text-body-1 text-medium-emphasis mb-2" v-if="email">
Complete your registration by setting a secure password for <strong>{{ email }}</strong>
</p>
<p class="text-body-1 text-medium-emphasis">
Choose a strong password to secure your MonacoUSA Portal account.
</p>
</div>
<!-- Password Setup Form -->
<v-form ref="formRef" v-model="formValid" @submit.prevent="setupPassword">
<!-- Email Input (shown when email is not provided in URL) -->
<v-text-field
v-if="showEmailInput"
v-model="email"
label="Email Address"
variant="outlined"
density="comfortable"
:rules="emailRules"
:error="!!errorMessage"
prepend-inner-icon="mdi-email"
class="mb-3"
autocomplete="email"
type="email"
placeholder="Enter your email address"
/>
<v-text-field
v-model="password"
:type="showPassword ? 'text' : 'password'"
label="New Password"
variant="outlined"
density="comfortable"
:rules="passwordRules"
:error="!!errorMessage"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="togglePasswordVisibility('password')"
class="mb-3 password-field"
autocomplete="new-password"
:autofocus="false"
/>
<v-text-field
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
label="Confirm Password"
variant="outlined"
density="comfortable"
:rules="confirmPasswordRules"
:error="!!errorMessage"
:append-inner-icon="showConfirmPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="togglePasswordVisibility('confirm')"
class="mb-4 password-field"
autocomplete="new-password"
:autofocus="false"
/>
<!-- Password Strength Indicator -->
<v-progress-linear
:model-value="passwordStrength"
:color="passwordStrengthColor"
height="6"
class="mb-2"
/>
<p class="text-caption text-medium-emphasis mb-4">
Password strength: {{ passwordStrengthLabel }}
</p>
<!-- Error Alert -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4 text-start"
icon="mdi-alert"
>
{{ errorMessage }}
</v-alert>
<!-- Success Alert -->
<v-alert
v-if="successMessage"
type="success"
variant="tonal"
class="mb-4 text-start"
icon="mdi-check-circle"
>
{{ successMessage }}
</v-alert>
<div class="d-flex flex-column gap-3">
<v-btn
type="submit"
color="primary"
size="large"
variant="elevated"
block
:loading="loading"
:disabled="!formValid || loading"
class="text-none"
>
<v-icon start>mdi-check</v-icon>
Set Password & Continue
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
block
:to="{ path: '/login' }"
:disabled="loading"
class="text-none"
>
<v-icon start>mdi-login</v-icon>
I Already Have a Password
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
:disabled="loading"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
</v-form>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
Need help? Contact support at:
</p>
<v-chip
size="small"
variant="outlined"
prepend-icon="mdi-email"
>
support@monacousa.org
</v-chip>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: 'guest'
});
// Device detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
// Initialize device detection on mount
onMounted(() => {
if (process.client) {
const userAgent = navigator.userAgent;
isMobile.value = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
isMobileSafari.value = /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
}
});
// CSS classes based on device detection
const containerClasses = computed(() => {
const classes = ['password-setup-page'];
if (isMobile.value) classes.push('is-mobile');
if (isMobileSafari.value) classes.push('is-mobile-safari', 'performance-mode');
return classes.join(' ');
});
// Reactive state
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const formValid = ref(false);
const showPassword = ref(false);
const showConfirmPassword = ref(false);
// Form data
const password = ref('');
const confirmPassword = ref('');
// Get query parameters - static to prevent reload loops
const route = useRoute();
const email = ref((route.query.email as string) || '');
const token = ref((route.query.token as string) || '');
const showEmailInput = ref(!email.value); // Show email input if email is not provided
// Form ref
const formRef = ref();
// Password strength calculation
const passwordStrength = computed(() => {
if (!password.value) return 0;
let score = 0;
// Length
if (password.value.length >= 8) score += 20;
if (password.value.length >= 12) score += 10;
// Character types
if (/[a-z]/.test(password.value)) score += 15;
if (/[A-Z]/.test(password.value)) score += 15;
if (/[0-9]/.test(password.value)) score += 15;
if (/[^A-Za-z0-9]/.test(password.value)) score += 25;
return Math.min(score, 100);
});
const passwordStrengthColor = computed(() => {
if (passwordStrength.value < 40) return 'error';
if (passwordStrength.value < 70) return 'warning';
return 'success';
});
const passwordStrengthLabel = computed(() => {
if (passwordStrength.value < 40) return 'Weak';
if (passwordStrength.value < 70) return 'Good';
return 'Strong';
});
// Validation rules
const passwordRules = [
(v: string) => !!v || 'Password is required',
(v: string) => v.length >= 8 || 'Password must be at least 8 characters',
(v: string) => /[A-Z]/.test(v) || 'Password must contain at least one uppercase letter',
(v: string) => /[a-z]/.test(v) || 'Password must contain at least one lowercase letter',
(v: string) => /[0-9]/.test(v) || 'Password must contain at least one number',
];
const confirmPasswordRules = [
(v: string) => !!v || 'Please confirm your password',
(v: string) => v === password.value || 'Passwords do not match',
];
const emailRules = [
(v: string) => !!v || 'Email address is required',
(v: string) => /.+@.+\..+/.test(v) || 'Please enter a valid email address',
];
// Set page title with mobile viewport optimization
useHead({
title: 'Set Your Password - MonacoUSA Portal',
meta: [
{
name: 'description',
content: 'Set your password to complete your MonacoUSA Portal registration.'
},
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover' }
]
});
// Toggle password visibility - simplified for static detection
const togglePasswordVisibility = (field: 'password' | 'confirm') => {
if (field === 'password') {
showPassword.value = !showPassword.value;
} else {
showConfirmPassword.value = !showConfirmPassword.value;
}
};
// Setup password function
const setupPassword = async () => {
if (!formValid.value) return;
if (!email.value) {
errorMessage.value = 'No email address provided. Please check the link from your email.';
return;
}
try {
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
// Call our password setup API
const response = await $fetch('/api/auth/setup-password', {
method: 'POST',
body: {
email: email.value,
password: password.value,
token: token.value
}
});
console.log('[setup-password] Password setup successful:', response);
successMessage.value = 'Password set successfully! Redirecting to login...';
// Wait a moment to show success message, then redirect
setTimeout(() => {
navigateTo({
path: '/login',
query: { email: email.value, passwordSet: 'true' }
});
}, 2000);
} catch (err: any) {
console.error('[setup-password] Password setup failed:', err);
if (err.statusCode === 400) {
errorMessage.value = 'Invalid request. Please check your information and try again.';
} else if (err.statusCode === 404) {
errorMessage.value = 'User not found. The link may be invalid or expired.';
} else if (err.statusCode === 409) {
errorMessage.value = 'Password has already been set. You can log in with your existing password.';
} else if (err.statusCode === 422) {
errorMessage.value = 'Password does not meet security requirements. Please choose a stronger password.';
} else {
errorMessage.value = err.message || 'Failed to set password. Please try again or contact support.';
}
loading.value = false;
}
};
// Component initialization
onMounted(async () => {
console.log('[setup-password] Password setup page loaded for:', email.value);
// Check if we have required parameters
if (!email.value) {
errorMessage.value = 'No email address provided. Please use the link from your verification email.';
}
});
</script>
<style scoped>
.password-setup-page {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
overflow-x: hidden; /* Prevent horizontal scroll on mobile */
}
/* Mobile Safari optimizations */
.password-setup-page.is-mobile-safari {
min-height: 100vh;
min-height: -webkit-fill-available;
}
.password-setup-page.performance-mode {
will-change: auto;
transform: translateZ(0); /* Lighter hardware acceleration */
}
.fill-height {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
}
/* Mobile Safari fill-height optimization */
.is-mobile-safari .fill-height {
min-height: -webkit-fill-available;
}
.border-t {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.gap-3 {
gap: 12px;
}
/* Custom scrollbar for mobile */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(163, 21, 21, 0.5);
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.password-setup-page {
padding: 16px;
}
.v-card {
margin: 0;
}
/* Optimize button spacing on mobile */
.gap-3 {
gap: 8px;
}
}
/* Improve touch targets on mobile */
@media (hover: none) and (pointer: coarse) {
.v-btn {
min-height: 48px; /* Ensure touch-friendly button size */
}
}
/* Performance mode optimizations */
.performance-mode .v-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; /* Lighter shadow */
}
.performance-mode .v-btn {
transition: none; /* Remove button transitions for better performance */
}
/* Form styling improvements */
.v-text-field {
margin-bottom: 8px;
}
.v-progress-linear {
border-radius: 3px;
}
/* Password field specific optimizations for mobile */
.password-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on iOS */
-webkit-text-fill-color: currentColor !important;
}
/* Prevent auto-zoom on focus for mobile Safari */
@media screen and (max-width: 768px) {
.password-field :deep(input) {
font-size: 16px !important;
}
.password-field :deep(.v-field__append-inner) {
/* Make eye icon easier to tap on mobile */
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
/* iOS specific fixes to prevent zoom */
@supports (-webkit-touch-callout: none) {
.password-field :deep(input) {
font-size: 16px !important;
}
}
/* Disable transitions on mobile for better performance */
.is-mobile .password-field :deep(.v-field__append-inner) {
transition: none !important;
}
.is-mobile .password-field :deep(.v-icon) {
transition: none !important;
}
</style>

View File

@@ -0,0 +1,747 @@
<template>
<div class="auth-page">
<div class="auth-container auth-container--wide">
<!-- Progress Bar -->
<div class="progress-bar">
<div
class="progress-bar__fill"
:style="{ width: `${(step / 3) * 100}%` }"
></div>
</div>
<!-- Step 1: Account Info -->
<div
v-if="step === 1"
v-motion
:initial="{ opacity: 0, x: 50 }"
:enter="{ opacity: 1, x: 0 }"
class="signup-step"
>
<div class="auth-logo">
<img src="/MONACOUSA-Flags_376x376.png" alt="MonacoUSA" class="logo" />
<h1>MonacoUSA Portal</h1>
</div>
<div class="auth-header">
<h2>Create Your Account</h2>
<p>Join Monaco's premier business community</p>
</div>
<form class="auth-form" @submit.prevent="nextStep">
<div class="form-row">
<FloatingInput
v-model="form.firstName"
label="First Name"
variant="glass"
leftIcon="user"
required
/>
<FloatingInput
v-model="form.lastName"
label="Last Name"
variant="glass"
leftIcon="user"
required
/>
</div>
<FloatingInput
v-model="form.email"
label="Email Address"
type="email"
variant="glass"
leftIcon="mail"
helperText="We'll use this for account notifications"
required
/>
<FloatingInput
v-model="form.password"
label="Password"
type="password"
variant="glass"
leftIcon="lock"
helperText="Minimum 8 characters with uppercase and number"
required
/>
<FloatingInput
v-model="form.confirmPassword"
label="Confirm Password"
type="password"
variant="glass"
leftIcon="lock"
:error="passwordError"
required
/>
<div class="password-strength">
<span class="password-strength__label">Password Strength:</span>
<div class="password-strength__bars">
<span
v-for="i in 4"
:key="i"
class="password-strength__bar"
:class="{ 'password-strength__bar--filled': i <= passwordStrength }"
></span>
</div>
<span class="password-strength__text">{{ passwordStrengthText }}</span>
</div>
<MonacoButton
type="submit"
variant="primary"
size="lg"
block
>
Continue to Profile
</MonacoButton>
</form>
<div class="auth-footer">
<p>Already have an account? <a href="/auth/login" class="link">Sign in</a></p>
</div>
</div>
<!-- Step 2: Profile Info -->
<div
v-if="step === 2"
v-motion
:initial="{ opacity: 0, x: 50 }"
:enter="{ opacity: 1, x: 0 }"
class="signup-step"
>
<div class="step-header">
<button @click="previousStep" class="back-button">
<Icon name="arrow-left" />
Back
</button>
<h2>Professional Information</h2>
</div>
<form class="auth-form" @submit.prevent="nextStep">
<FloatingInput
v-model="form.company"
label="Company Name"
variant="glass"
leftIcon="building"
required
/>
<FloatingInput
v-model="form.title"
label="Job Title"
variant="glass"
leftIcon="briefcase"
required
/>
<div class="form-row">
<FloatingInput
v-model="form.phone"
label="Phone Number"
type="tel"
variant="glass"
leftIcon="phone"
required
/>
<FloatingInput
v-model="form.linkedin"
label="LinkedIn Profile"
variant="glass"
leftIcon="link"
helperText="Optional"
/>
</div>
<div class="form-group">
<label class="form-label">Industry</label>
<select v-model="form.industry" class="form-select">
<option value="">Select your industry</option>
<option value="finance">Finance & Banking</option>
<option value="tech">Technology</option>
<option value="realestate">Real Estate</option>
<option value="hospitality">Hospitality</option>
<option value="retail">Retail & Luxury</option>
<option value="consulting">Consulting</option>
<option value="legal">Legal Services</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Bio</label>
<textarea
v-model="form.bio"
class="form-textarea"
placeholder="Tell us about yourself and your business interests..."
rows="4"
></textarea>
</div>
<MonacoButton
type="submit"
variant="primary"
size="lg"
block
>
Continue to Membership
</MonacoButton>
</form>
</div>
<!-- Step 3: Membership -->
<div
v-if="step === 3"
v-motion
:initial="{ opacity: 0, x: 50 }"
:enter="{ opacity: 1, x: 0 }"
class="signup-step"
>
<div class="step-header">
<button @click="previousStep" class="back-button">
<Icon name="arrow-left" />
Back
</button>
<h2>Choose Your Membership</h2>
</div>
<div class="membership-plans">
<div
v-for="plan in membershipPlans"
:key="plan.id"
class="plan-card"
:class="{ 'plan-card--selected': form.membershipPlan === plan.id }"
@click="form.membershipPlan = plan.id"
>
<div class="plan-card__header">
<h3 class="plan-card__name">{{ plan.name }}</h3>
<span class="plan-card__price">${{ plan.price }}/year</span>
</div>
<ul class="plan-card__features">
<li v-for="feature in plan.features" :key="feature">
<Icon name="check" />
{{ feature }}
</li>
</ul>
<span v-if="plan.popular" class="plan-card__badge">Most Popular</span>
</div>
</div>
<div class="terms-section">
<label class="checkbox-label">
<input type="checkbox" v-model="form.agreeTerms" />
<span>
I agree to the <a href="/terms" class="link">Terms of Service</a>
and <a href="/privacy" class="link">Privacy Policy</a>
</span>
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="form.agreeNewsletter" />
<span>Send me updates about events and opportunities</span>
</label>
</div>
<MonacoButton
variant="primary"
size="lg"
block
:disabled="!form.agreeTerms || !form.membershipPlan"
@click="handleSignup"
>
Complete Registration
</MonacoButton>
</div>
<!-- Success State -->
<div
v-if="step === 4"
v-motion
:initial="{ opacity: 0, scale: 0.9 }"
:enter="{ opacity: 1, scale: 1 }"
class="success-state"
>
<div class="success-icon">🎉</div>
<h2>Welcome to MonacoUSA!</h2>
<p>Your account has been created successfully.</p>
<p>Please check your email to verify your account.</p>
<MonacoButton variant="primary" size="lg" @click="goToLogin">
Go to Login
</MonacoButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import FloatingInput from '~/components/ui/FloatingInput.vue'
import MonacoButton from '~/components/ui/MonacoButton.vue'
import Icon from '~/components/ui/Icon.vue'
const step = ref(1)
const form = ref({
// Step 1
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
// Step 2
company: '',
title: '',
phone: '',
linkedin: '',
industry: '',
bio: '',
// Step 3
membershipPlan: '',
agreeTerms: false,
agreeNewsletter: true
})
const membershipPlans = [
{
id: 'basic',
name: 'Basic',
price: 250,
features: [
'Access to member directory',
'Monthly newsletter',
'Event invitations',
'Basic networking features'
]
},
{
id: 'professional',
name: 'Professional',
price: 500,
popular: true,
features: [
'Everything in Basic',
'Priority event registration',
'Enhanced profile features',
'Business matchmaking',
'Quarterly exclusive events'
]
},
{
id: 'executive',
name: 'Executive',
price: 1000,
features: [
'Everything in Professional',
'VIP event access',
'Personal concierge service',
'Board meeting participation',
'Guest passes (5/year)',
'Premium networking tools'
]
}
]
const passwordError = computed(() => {
if (form.value.confirmPassword && form.value.password !== form.value.confirmPassword) {
return 'Passwords do not match'
}
return ''
})
const passwordStrength = computed(() => {
const password = form.value.password
if (!password) return 0
let strength = 0
if (password.length >= 8) strength++
if (/[A-Z]/.test(password)) strength++
if (/[0-9]/.test(password)) strength++
if (/[^A-Za-z0-9]/.test(password)) strength++
return strength
})
const passwordStrengthText = computed(() => {
const texts = ['', 'Weak', 'Fair', 'Good', 'Strong']
return texts[passwordStrength.value]
})
const nextStep = () => {
step.value++
}
const previousStep = () => {
step.value--
}
const handleSignup = () => {
console.log('Signup with:', form.value)
step.value = 4
}
const goToLogin = () => {
window.location.href = '/auth/login'
}
</script>
<style scoped lang="scss">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%);
padding: 2rem;
}
.auth-container {
max-width: 500px;
width: 100%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
overflow: hidden;
&--wide {
max-width: 800px;
}
}
.progress-bar {
height: 4px;
background: rgba(220, 38, 38, 0.1);
&__fill {
height: 100%;
background: linear-gradient(90deg, #dc2626 0%, #b91c1c 100%);
transition: width 0.3s ease;
}
}
.signup-step {
padding: 3rem;
}
.auth-logo {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
img {
width: 48px;
height: 48px;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #dc2626;
}
}
.auth-header {
margin-bottom: 2rem;
h2 {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 700;
color: #27272a;
}
p {
margin: 0;
color: #6b7280;
}
}
.step-header {
margin-bottom: 2rem;
h2 {
margin: 0.5rem 0 0;
font-size: 1.5rem;
font-weight: 700;
color: #27272a;
}
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0;
background: none;
border: none;
color: #6b7280;
font-size: 0.875rem;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #dc2626;
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: #27272a;
}
.form-select,
.form-textarea {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
font-size: 1rem;
color: #27272a;
transition: all 0.2s;
&:focus {
outline: none;
border-color: #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
}
.form-textarea {
resize: vertical;
min-height: 100px;
font-family: inherit;
}
.password-strength {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(220, 38, 38, 0.05);
border-radius: 8px;
&__label {
font-size: 0.875rem;
color: #6b7280;
}
&__bars {
display: flex;
gap: 0.25rem;
flex: 1;
}
&__bar {
height: 4px;
flex: 1;
background: #e5e5e5;
border-radius: 2px;
transition: background 0.3s;
&--filled {
background: #dc2626;
}
}
&__text {
font-size: 0.875rem;
font-weight: 500;
color: #dc2626;
}
}
.membership-plans {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.plan-card {
position: relative;
padding: 1.5rem;
background: white;
border: 2px solid #e5e5e5;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #dc2626;
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
&--selected {
border-color: #dc2626;
background: rgba(220, 38, 38, 0.05);
}
&__header {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e5e5;
}
&__name {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: #27272a;
}
&__price {
font-size: 1.5rem;
font-weight: 700;
color: #dc2626;
}
&__features {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
svg {
width: 1rem;
height: 1rem;
color: #10b981;
flex-shrink: 0;
}
}
}
&__badge {
position: absolute;
top: -0.5rem;
right: 1rem;
padding: 0.25rem 0.75rem;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
}
.terms-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
cursor: pointer;
input[type="checkbox"] {
margin-top: 0.125rem;
width: 1.25rem;
height: 1.25rem;
accent-color: #dc2626;
cursor: pointer;
flex-shrink: 0;
}
span {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.5;
}
}
.link {
color: #dc2626;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.auth-footer {
margin-top: 2rem;
text-align: center;
p {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
}
.success-state {
padding: 4rem;
text-align: center;
.success-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
h2 {
margin: 0 0 1rem;
font-size: 2rem;
font-weight: 700;
color: #27272a;
}
p {
margin: 0 0 0.5rem;
color: #6b7280;
&:last-of-type {
margin-bottom: 2rem;
}
}
}
// Responsive
@media (max-width: 768px) {
.membership-plans {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.signup-step {
padding: 2rem;
}
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<div class="verification-expired">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<div class="mb-6">
<v-icon
color="warning"
size="80"
class="mb-4"
>
mdi-clock-alert
</v-icon>
<h1 class="text-h4 font-weight-bold text-warning mb-3">
{{ pageTitle }}
</h1>
<p class="text-body-1 text-medium-emphasis mb-6">
{{ pageDescription }}
</p>
<!-- Information alert -->
<v-alert
type="info"
variant="tonal"
class="mb-6 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>What to do next:</strong>
<ul class="mt-2 pl-4">
<li>Request a new verification email below</li>
<li>Check your spam/junk folder for emails from MonacoUSA</li>
<li>Make sure you're checking the correct email address</li>
</ul>
</div>
</v-alert>
</div>
<!-- Resend verification form -->
<v-form @submit.prevent="resendVerification" :disabled="loading">
<div class="mb-4">
<v-text-field
v-model="email"
label="Email Address"
type="email"
variant="outlined"
prepend-inner-icon="mdi-email"
:rules="emailRules"
:error-messages="emailError"
required
class="mb-3"
/>
</div>
<div class="d-flex flex-column gap-3 mb-6">
<v-btn
type="submit"
color="primary"
size="large"
variant="elevated"
block
:loading="loading"
:disabled="!email || !isValidEmail(email)"
class="text-none"
>
<v-icon start>mdi-email-send</v-icon>
{{ loading ? 'Sending...' : 'Send New Verification Email' }}
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
block
to="/login"
class="text-none"
>
<v-icon start>mdi-login</v-icon>
Back to Login
</v-btn>
</div>
</v-form>
<!-- Success message -->
<v-alert
v-if="successMessage"
type="success"
variant="tonal"
class="mb-4"
icon="mdi-check"
>
{{ successMessage }}
</v-alert>
<!-- Error message -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
icon="mdi-alert"
>
{{ errorMessage }}
</v-alert>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
Still having trouble? Contact support:
</p>
<v-chip
size="small"
variant="outlined"
prepend-icon="mdi-email"
>
support@monacousa.org
</v-chip>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: 'guest'
});
// Get query parameters
// Get query parameters - static to prevent reload loops
const route = useRoute();
const reason = ref((route.query.reason as string) || 'expired');
// Reactive data
const email = ref('');
const loading = ref(false);
const successMessage = ref('');
const errorMessage = ref('');
const emailError = ref('');
// Computed properties
const pageTitle = computed(() => {
switch (reason.value) {
case 'used':
return 'Verification Link Already Used';
case 'invalid':
return 'Invalid Verification Link';
default:
return 'Verification Link Expired';
}
});
const pageDescription = computed(() => {
switch (reason.value) {
case 'used':
return 'This verification link has already been used. If you need to verify your email again, please request a new verification link below.';
case 'invalid':
return 'The verification link you clicked is invalid or malformed. Please request a new verification link below.';
default:
return 'Your verification link has expired. Verification links are valid for 24 hours. Please request a new verification link below.';
}
});
// Validation rules
const emailRules = [
(v: string) => !!v || 'Email is required',
(v: string) => isValidEmail(v) || 'Please enter a valid email address'
];
// Helper function
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Resend verification email
async function resendVerification() {
if (!email.value || !isValidEmail(email.value)) {
return;
}
loading.value = true;
successMessage.value = '';
errorMessage.value = '';
emailError.value = '';
try {
const response = await $fetch('/api/auth/send-verification-email', {
method: 'POST',
body: { email: email.value }
});
successMessage.value = 'A new verification email has been sent! Please check your inbox and spam folder.';
email.value = ''; // Clear the form
} catch (error: any) {
console.error('[verify-expired] Failed to resend verification:', error);
if (error.status === 404) {
emailError.value = 'No account found with this email address.';
} else if (error.status === 429) {
errorMessage.value = 'Please wait a few minutes before requesting another verification email.';
} else {
errorMessage.value = error.data?.message || 'Failed to send verification email. Please try again.';
}
} finally {
loading.value = false;
}
}
// Set page title
useHead({
title: `${pageTitle.value} - MonacoUSA Portal`,
meta: [
{
name: 'description',
content: 'Request a new email verification link for your MonacoUSA Portal account.'
}
]
});
// Track page view
onMounted(() => {
console.log('[verify-expired] Page accessed', { reason: reason.value });
});
</script>
<style scoped>
.verification-expired {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.fill-height {
min-height: 100vh;
}
.border-t {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.gap-3 {
gap: 12px;
}
/* Animation for the warning icon */
.v-icon {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
/* List styling */
ul {
list-style-type: disc;
}
li {
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div :class="containerClasses">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<div class="mb-6">
<v-icon
color="success"
size="80"
class="mb-4"
>
mdi-check-circle
</v-icon>
<h1 class="text-h4 font-weight-bold text-success mb-3">
Email Verified Successfully!
</h1>
<p class="text-body-1 text-medium-emphasis mb-2" v-if="email">
Your email address <strong>{{ email }}</strong> has been verified.
</p>
<p class="text-body-1 text-medium-emphasis mb-6">
Your MonacoUSA Portal account is now active and ready to use.
You can now log in to access your dashboard and member features.
</p>
<!-- Warning message for partial verification -->
<v-alert
v-if="partialWarning"
type="warning"
variant="tonal"
class="mb-4 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>Note:</strong> Your email has been verified, but there may have been
a minor issue updating your account status. If you experience any login
problems, please contact support.
</div>
</v-alert>
</div>
<div class="d-flex flex-column gap-3">
<v-btn
color="primary"
size="large"
variant="elevated"
block
@click="goToPasswordSetup"
class="text-none"
>
<v-icon start>mdi-lock-plus</v-icon>
Set Your Password
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
Need help? Contact support at:
</p>
<v-chip
size="small"
variant="outlined"
prepend-icon="mdi-email"
>
support@monacousa.org
</v-chip>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: 'guest'
});
// Get query parameters
const route = useRoute();
const email = ref((route.query.email as string) || '');
const partialWarning = ref(route.query.warning === 'partial');
// Simple device detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
// Initialize device detection on mount
onMounted(() => {
if (process.client) {
const userAgent = navigator.userAgent;
isMobile.value = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
isMobileSafari.value = /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
}
});
// CSS classes based on device detection
const containerClasses = computed(() => {
const classes = ['verification-success'];
if (isMobile.value) classes.push('is-mobile');
if (isMobileSafari.value) classes.push('is-mobile-safari');
return classes.join(' ');
});
// Set page title with mobile viewport optimization
useHead({
title: 'Email Verified - MonacoUSA Portal',
meta: [
{
name: 'description',
content: 'Your email has been successfully verified. You can now log in to the MonacoUSA Portal.'
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
}
]
});
// Go to password setup page
const goToPasswordSetup = () => {
navigateTo({
path: '/auth/setup-password',
query: {
email: email.value
}
});
};
// Track verification
onMounted(() => {
console.log('[verify-success] Email verification completed', {
email: email.value,
partialWarning: partialWarning.value
});
});
</script>
<style scoped>
.verification-success {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
overflow-x: hidden; /* Prevent horizontal scroll on mobile */
}
/* Mobile Safari optimizations */
.verification-success.is-mobile-safari {
min-height: 100vh;
min-height: -webkit-fill-available;
}
.verification-success.performance-mode {
will-change: auto;
transform: translateZ(0); /* Lighter hardware acceleration */
}
.fill-height {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
}
/* Mobile Safari fill-height optimization */
.is-mobile-safari .fill-height {
min-height: -webkit-fill-available;
}
.border-t {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.gap-3 {
gap: 12px;
}
/* Animation for the success icon - reduced for performance mode */
.v-icon {
animation: bounce 0.6s ease-in-out;
}
.performance-mode .v-icon {
animation: none; /* Disable animations on performance mode */
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
transform: translate3d(0, 0, 0);
}
40%, 43% {
transform: translate3d(0, -8px, 0);
}
70% {
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
/* Custom scrollbar for mobile */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(163, 21, 21, 0.5);
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.verification-success {
padding: 16px;
}
.v-card {
margin: 0;
}
/* Optimize button spacing on mobile */
.gap-3 {
gap: 8px;
}
}
/* Improve touch targets on mobile */
@media (hover: none) and (pointer: coarse) {
.v-btn {
min-height: 48px; /* Ensure touch-friendly button size */
}
}
/* Performance mode optimizations */
.performance-mode .v-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; /* Lighter shadow */
}
.performance-mode .v-btn {
transition: none; /* Remove button transitions for better performance */
}
</style>

510
pages/auth/verify.vue Normal file
View File

@@ -0,0 +1,510 @@
<template>
<div :class="containerClasses">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<!-- Circuit Breaker - Too Many Attempts -->
<div v-if="isBlocked" class="mb-6">
<v-icon
color="warning"
size="80"
class="mb-4"
>
mdi-timer-sand
</v-icon>
<h1 class="text-h4 font-weight-bold text-warning mb-3">
Verification Temporarily Blocked
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
{{ statusMessage }}
</p>
<v-alert
type="warning"
variant="tonal"
class="mb-4 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>Why was this blocked?</strong>
<ul class="mt-2">
<li>Multiple failed verification attempts detected</li>
<li>This prevents server overload and potential issues</li>
<li>The block will be lifted automatically</li>
</ul>
</div>
</v-alert>
</div>
<!-- Loading State -->
<div v-else-if="verifying" class="mb-6">
<v-progress-circular
color="primary"
size="80"
width="6"
indeterminate
class="mb-4"
/>
<h1 class="text-h4 font-weight-bold text-primary mb-3">
Verifying Your Email
</h1>
<p class="text-body-1 text-medium-emphasis">
{{ statusMessage || 'Please wait while we verify your email address...' }}
</p>
<!-- Attempt Counter -->
<div v-if="attemptCount > 1" class="mt-2">
<v-chip size="small" color="primary" variant="outlined">
Attempt {{ attemptCount }}/{{ maxAttempts }}
</v-chip>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="mb-6">
<v-icon
color="error"
size="80"
class="mb-4"
>
mdi-alert-circle
</v-icon>
<h1 class="text-h4 font-weight-bold text-error mb-3">
Verification Failed
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
{{ error }}
</p>
<!-- Circuit Breaker Status -->
<div v-if="statusMessage" class="mb-4">
<v-alert
type="info"
variant="tonal"
class="text-start"
icon="mdi-information"
>
{{ statusMessage }}
</v-alert>
</div>
<v-alert
type="error"
variant="tonal"
class="mb-4 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>Common Issues:</strong>
<ul class="mt-2">
<li>The verification link may have expired</li>
<li>The link may have already been used</li>
<li>The link may be malformed</li>
<li v-if="partialSuccess">Server configuration issues (contact support)</li>
</ul>
</div>
</v-alert>
</div>
<div v-if="!verifying && !isBlocked" class="d-flex flex-column gap-3">
<v-btn
v-if="error && canRetry"
color="primary"
size="large"
variant="elevated"
block
@click="retryVerification"
:loading="verifying"
class="text-none"
>
<v-icon start>mdi-refresh</v-icon>
Retry Verification
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
block
to="/signup"
class="text-none"
>
<v-icon start>mdi-account-plus</v-icon>
Register Again
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
<div v-else-if="isBlocked" class="d-flex flex-column gap-3">
<v-btn
color="secondary"
size="large"
variant="outlined"
block
to="/signup"
class="text-none"
>
<v-icon start>mdi-account-plus</v-icon>
Register Again
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
Need help? Contact support at:
</p>
<v-chip
size="small"
variant="outlined"
prepend-icon="mdi-email"
>
support@monacousa.org
</v-chip>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: 'guest'
});
// Get route and token immediately
const route = useRoute();
const token = route.query.token as string || '';
// Reactive state - keep minimal reactivity
const verifying = ref(false);
const error = ref('');
const partialSuccess = ref(false);
// Simple retry logic
const isBlocked = ref(false);
const canRetry = ref(true);
const statusMessage = ref('');
const attemptCount = ref(0);
const maxAttempts = 3;
// Device detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
// Initialize device detection on mount
onMounted(() => {
if (process.client) {
const userAgent = navigator.userAgent;
isMobile.value = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
isMobileSafari.value = /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
}
});
// CSS classes based on device detection
const containerClasses = computed(() => {
const classes = ['verification-page'];
if (isMobile.value) classes.push('is-mobile');
if (isMobileSafari.value) classes.push('is-mobile-safari', 'performance-mode');
return classes.join(' ');
});
// Set page title with mobile viewport optimization
useHead({
title: 'Verifying Email - MonacoUSA Portal',
meta: [
{
name: 'description',
content: 'Verifying your email address for the MonacoUSA Portal.'
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
}
]
});
// Simple verification logic
const updateUIState = () => {
if (attemptCount.value >= maxAttempts) {
isBlocked.value = true;
canRetry.value = false;
statusMessage.value = `Too many failed attempts. Please wait before trying again.`;
} else {
canRetry.value = attemptCount.value < maxAttempts;
}
};
// Verify email function
const verifyEmail = async () => {
if (!token) {
error.value = 'No verification token provided. Please check your email for the correct verification link.';
return;
}
if (attemptCount.value >= maxAttempts) {
isBlocked.value = true;
return;
}
try {
verifying.value = true;
error.value = '';
partialSuccess.value = false;
attemptCount.value++;
console.log(`[auth/verify] Starting verification attempt ${attemptCount.value}/${maxAttempts}`);
// Call the API endpoint to verify the email
const response = await $fetch(`/api/auth/verify-email?token=${token}`, {
method: 'GET'
}) as any;
console.log('[auth/verify] Email verification successful:', response);
// Extract response data
const email = response?.data?.email || '';
const isPartialSuccess = response?.data?.partialSuccess || false;
const keycloakError = response?.data?.keycloakError;
if (isPartialSuccess) {
partialSuccess.value = true;
console.log('[auth/verify] Partial success - Keycloak error:', keycloakError);
}
// Construct redirect URL
let redirectUrl = `/auth/verify-success`;
const queryParams = [];
if (email) {
queryParams.push(`email=${encodeURIComponent(email)}`);
}
if (isPartialSuccess) {
queryParams.push('warning=partial');
if (keycloakError) {
queryParams.push(`error=${encodeURIComponent(keycloakError)}`);
}
}
if (queryParams.length > 0) {
redirectUrl += '?' + queryParams.join('&');
}
// Navigate to success page
console.log(`[auth/verify] Navigating to success page`);
setTimeout(async () => {
try {
await navigateTo(redirectUrl, { replace: true });
} catch (navError) {
console.error('[auth/verify] Navigation failed:', navError);
// Final fallback - direct window location
window.location.replace(redirectUrl);
}
}, 500);
} catch (err: any) {
console.error('[auth/verify] Email verification failed:', err);
updateUIState();
// Set error message based on status code
if (err.statusCode === 410) {
error.value = 'Verification link has expired. Please request a new verification email.';
} else if (err.statusCode === 409) {
error.value = 'This verification link has already been used or is invalid.';
} else if (err.statusCode === 400) {
error.value = 'Invalid verification token. Please request a new verification email.';
} else if (err.statusCode === 404) {
error.value = 'User not found. The verification token may be invalid.';
} else {
error.value = err.data?.message || err.message || 'Email verification failed';
}
verifying.value = false;
}
};
// Retry verification
const retryVerification = async () => {
if (!canRetry.value || isBlocked.value) {
console.log('[auth/verify] Retry blocked - canRetry:', canRetry.value, 'isBlocked:', isBlocked.value);
return;
}
console.log('[auth/verify] Retrying verification...');
await verifyEmail();
};
// Component initialization
onMounted(async () => {
console.log('[auth/verify] Component mounted with token:', token?.substring(0, 20) + '...');
// Check if token exists
if (!token) {
error.value = 'No verification token provided. Please check your email for the correct verification link.';
return;
}
// Start verification process with a small delay to ensure stability
setTimeout(() => {
verifyEmail();
}, 200);
});
</script>
<style scoped>
.verification-page {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
overflow-x: hidden; /* Prevent horizontal scroll on mobile */
}
/* Mobile Safari optimizations */
.verification-page.is-mobile-safari {
min-height: 100vh;
min-height: -webkit-fill-available;
}
.verification-page.performance-mode {
will-change: auto;
transform: translateZ(0); /* Lighter hardware acceleration */
}
.fill-height {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
}
/* Mobile Safari fill-height optimization */
.is-mobile-safari .fill-height {
min-height: -webkit-fill-available;
}
.border-t {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.gap-3 {
gap: 12px;
}
/* Loading animation */
.v-progress-circular {
animation: pulse 2s ease-in-out infinite;
}
.performance-mode .v-progress-circular {
animation: none; /* Disable animations on performance mode */
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
/* Custom scrollbar for mobile */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(163, 21, 21, 0.5);
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.verification-page {
padding: 16px;
}
.v-card {
margin: 0;
}
/* Optimize button spacing on mobile */
.gap-3 {
gap: 8px;
}
}
/* Improve touch targets on mobile */
@media (hover: none) and (pointer: coarse) {
.v-btn {
min-height: 48px; /* Ensure touch-friendly button size */
}
}
/* Performance mode optimizations */
.performance-mode .v-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; /* Lighter shadow */
}
.performance-mode .v-btn {
transition: none; /* Remove button transitions for better performance */
}
/* Error state styling */
.text-error {
color: rgb(var(--v-theme-error));
}
/* Better list styling */
ul {
margin: 0;
padding-left: 20px;
}
li {
margin: 4px 0;
}
</style>

View File

@@ -0,0 +1,886 @@
<template>
<div class="board-dashboard-v2">
<!-- Executive Header -->
<div class="executive-header">
<h1 class="dashboard-title">Executive Dashboard</h1>
<p class="dashboard-subtitle">Strategic insights and governance overview</p>
</div>
<!-- KPI Cards with Neumorphic Design -->
<div class="kpi-grid">
<div class="kpi-card neumorphic-card" v-for="kpi in kpis" :key="kpi.id">
<div class="kpi-header">
<div class="kpi-icon-wrapper neumorphic-inset">
<Icon :name="kpi.icon" class="kpi-icon" :style="{ color: kpi.color }" />
</div>
<div class="kpi-trend" :class="kpi.trendType">
<Icon :name="kpi.trendIcon" class="trend-icon" />
<span>{{ kpi.trendValue }}</span>
</div>
</div>
<div class="kpi-content">
<div class="kpi-value">{{ kpi.value }}</div>
<div class="kpi-label">{{ kpi.label }}</div>
<div class="kpi-progress">
<div class="progress-bar neumorphic-inset">
<div class="progress-fill" :style="{ width: kpi.progress + '%', background: kpi.color }"></div>
</div>
<span class="progress-text">{{ kpi.progress }}% of target</span>
</div>
</div>
</div>
</div>
<!-- Strategic Initiatives & Governance -->
<div class="governance-grid">
<!-- Strategic Initiatives -->
<div class="initiative-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:target" class="header-icon" />
<h2>Strategic Initiatives</h2>
<div class="morphing-select-wrapper">
<button class="select-trigger neumorphic-button small" @click="toggleQuarter">
<span>{{ selectedQuarter }}</span>
<Icon name="mdi:chevron-down" class="dropdown-icon" :class="{ 'rotate': showQuarter }" />
</button>
<Transition name="morph">
<div v-if="showQuarter" class="morphing-dropdown">
<div
v-for="quarter in quarters"
:key="quarter"
class="dropdown-option"
@click="selectQuarter(quarter)"
>
{{ quarter }}
</div>
</div>
</Transition>
</div>
</div>
<div class="initiatives-list">
<div v-for="initiative in strategicInitiatives" :key="initiative.id" class="initiative-item">
<div class="initiative-header">
<span class="initiative-name">{{ initiative.name }}</span>
<span class="initiative-status" :class="initiative.status">{{ initiative.statusText }}</span>
</div>
<div class="initiative-progress neumorphic-inset">
<div class="progress-bar-slim">
<div class="progress-fill-slim" :style="{ width: initiative.progress + '%' }"></div>
</div>
</div>
<div class="initiative-meta">
<span class="initiative-owner">Owner: {{ initiative.owner }}</span>
<span class="initiative-deadline">Due: {{ initiative.deadline }}</span>
</div>
</div>
</div>
</div>
<!-- Committee Overview -->
<div class="committee-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:account-group-outline" class="header-icon" />
<h2>Committee Activities</h2>
</div>
<div class="committee-grid">
<div v-for="committee in committees" :key="committee.id" class="committee-item neumorphic-inset">
<div class="committee-header">
<Icon :name="committee.icon" class="committee-icon" :style="{ color: committee.color }" />
<h3>{{ committee.name }}</h3>
</div>
<div class="committee-stats">
<div class="stat">
<span class="stat-value">{{ committee.members }}</span>
<span class="stat-label">Members</span>
</div>
<div class="stat">
<span class="stat-value">{{ committee.meetings }}</span>
<span class="stat-label">Meetings</span>
</div>
</div>
<button class="neumorphic-button small full-width">View Details</button>
</div>
</div>
</div>
</div>
<!-- Financial Overview -->
<div class="financial-section neumorphic-card">
<div class="card-header">
<Icon name="mdi:finance" class="header-icon" />
<h2>Financial Overview</h2>
<div class="time-selector">
<button
v-for="period in timePeriods"
:key="period"
class="time-button neumorphic-button small"
:class="{ 'active': selectedPeriod === period }"
@click="selectedPeriod = period"
>
{{ period }}
</button>
</div>
</div>
<div class="financial-grid">
<div class="revenue-chart">
<h3>Revenue Trend</h3>
<div class="chart-placeholder neumorphic-inset">
<Icon name="mdi:chart-line" class="chart-icon" />
<span>Revenue chart visualization</span>
</div>
</div>
<div class="financial-metrics">
<div v-for="metric in financialMetrics" :key="metric.id" class="metric-item">
<div class="metric-label">{{ metric.label }}</div>
<div class="metric-value" :class="metric.type">{{ metric.value }}</div>
<div class="metric-change">
<Icon :name="metric.changeIcon" class="change-icon" />
<span>{{ metric.change }} from last period</span>
</div>
</div>
</div>
</div>
</div>
<!-- Governance Actions -->
<div class="governance-actions">
<div class="action-card neumorphic-card">
<Icon name="mdi:calendar-check" class="action-icon" />
<h3>Board Meetings</h3>
<p>Schedule and manage board meetings</p>
<button class="neumorphic-button primary">Schedule Meeting</button>
</div>
<div class="action-card neumorphic-card">
<Icon name="mdi:file-document-outline" class="action-icon" />
<h3>Documents</h3>
<p>Access governance documents</p>
<button class="neumorphic-button primary">View Documents</button>
</div>
<div class="action-card neumorphic-card">
<Icon name="mdi:vote" class="action-icon" />
<h3>Resolutions</h3>
<p>Review and vote on resolutions</p>
<button class="neumorphic-button primary">View Resolutions</button>
</div>
<div class="action-card neumorphic-card">
<Icon name="mdi:chart-box-outline" class="action-icon" />
<h3>Reports</h3>
<p>Generate executive reports</p>
<button class="neumorphic-button primary">Generate Report</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// Define page meta
definePageMeta({
layout: 'board',
middleware: 'auth'
})
// KPIs
const kpis = ref([
{
id: 1,
label: 'Member Growth',
value: '24.8%',
icon: 'mdi:account-multiple-plus',
color: '#10B981',
trendType: 'positive',
trendIcon: 'mdi:trending-up',
trendValue: '+5.2%',
progress: 82
},
{
id: 2,
label: 'Revenue YTD',
value: '$2.4M',
icon: 'mdi:cash-multiple',
color: '#3B82F6',
trendType: 'positive',
trendIcon: 'mdi:trending-up',
trendValue: '+12.3%',
progress: 68
},
{
id: 3,
label: 'Member Retention',
value: '94.5%',
icon: 'mdi:account-heart',
color: '#CC0000',
trendType: 'positive',
trendIcon: 'mdi:trending-up',
trendValue: '+2.1%',
progress: 95
},
{
id: 4,
label: 'NPS Score',
value: '72',
icon: 'mdi:emoticon-happy',
color: '#F59E0B',
trendType: 'neutral',
trendIcon: 'mdi:minus',
trendValue: '0%',
progress: 72
}
])
// Strategic Initiatives
const strategicInitiatives = ref([
{
id: 1,
name: 'Digital Transformation Initiative',
status: 'on-track',
statusText: 'On Track',
progress: 65,
owner: 'John Smith',
deadline: 'Q2 2024'
},
{
id: 2,
name: 'Member Experience Enhancement',
status: 'ahead',
statusText: 'Ahead',
progress: 78,
owner: 'Sarah Johnson',
deadline: 'Q1 2024'
},
{
id: 3,
name: 'International Expansion',
status: 'at-risk',
statusText: 'At Risk',
progress: 42,
owner: 'Mike Chen',
deadline: 'Q3 2024'
}
])
// Committees
const committees = ref([
{
id: 1,
name: 'Finance',
icon: 'mdi:calculator',
color: '#3B82F6',
members: 7,
meetings: 12
},
{
id: 2,
name: 'Governance',
icon: 'mdi:gavel',
color: '#CC0000',
members: 5,
meetings: 8
},
{
id: 3,
name: 'Audit',
icon: 'mdi:magnify',
color: '#F59E0B',
members: 4,
meetings: 10
},
{
id: 4,
name: 'Compensation',
icon: 'mdi:cash',
color: '#10B981',
members: 6,
meetings: 6
}
])
// Financial Metrics
const financialMetrics = ref([
{
id: 1,
label: 'Total Revenue',
value: '$2.4M',
type: 'positive',
changeIcon: 'mdi:arrow-up',
change: '+12.3%'
},
{
id: 2,
label: 'Operating Expenses',
value: '$1.8M',
type: 'neutral',
changeIcon: 'mdi:arrow-up',
change: '+8.1%'
},
{
id: 3,
label: 'Net Profit',
value: '$620K',
type: 'positive',
changeIcon: 'mdi:arrow-up',
change: '+24.5%'
},
{
id: 4,
label: 'Cash Flow',
value: '$450K',
type: 'positive',
changeIcon: 'mdi:arrow-up',
change: '+15.2%'
}
])
// Dropdown states
const showQuarter = ref(false)
const selectedQuarter = ref('Q4 2023')
const quarters = ref(['Q1 2023', 'Q2 2023', 'Q3 2023', 'Q4 2023', 'Q1 2024'])
// Time period selector
const selectedPeriod = ref('YTD')
const timePeriods = ref(['MTD', 'QTD', 'YTD'])
// Methods
const toggleQuarter = () => {
showQuarter.value = !showQuarter.value
}
const selectQuarter = (quarter) => {
selectedQuarter.value = quarter
showQuarter.value = false
}
onMounted(() => {
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.morphing-select-wrapper')) {
showQuarter.value = false
}
})
})
</script>
<style lang="scss" scoped>
@import '@/assets/scss/design-system-v2.scss';
.board-dashboard-v2 {
padding: 2rem;
background: linear-gradient(135deg, $neutral-50 0%, $neutral-100 100%);
min-height: 100vh;
}
// Executive Header
.executive-header {
text-align: center;
margin-bottom: 3rem;
.dashboard-title {
font-size: $text-4xl;
font-weight: $font-bold;
background: linear-gradient(135deg, $primary-600, $primary-800);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.dashboard-subtitle {
color: $neutral-600;
font-size: $text-lg;
}
}
// KPI Grid
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.kpi-card {
@include neumorphic-card('md');
padding: 1.5rem;
.kpi-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.kpi-icon-wrapper {
width: 48px;
height: 48px;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-inset-sm;
.kpi-icon {
width: 24px;
height: 24px;
}
}
.kpi-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: $text-sm;
font-weight: $font-semibold;
&.positive { color: $success-500; }
&.negative { color: $error-500; }
&.neutral { color: $neutral-600; }
.trend-icon {
width: 16px;
height: 16px;
}
}
.kpi-value {
font-size: $text-3xl;
font-weight: $font-bold;
color: $neutral-800;
margin-bottom: 0.5rem;
}
.kpi-label {
color: $neutral-600;
font-size: $text-sm;
margin-bottom: 1rem;
}
.kpi-progress {
.progress-bar {
height: 8px;
border-radius: $radius-full;
overflow: hidden;
margin-bottom: 0.5rem;
.progress-fill {
height: 100%;
border-radius: $radius-full;
transition: width 0.5s $spring-smooth;
}
}
.progress-text {
font-size: $text-xs;
color: $neutral-500;
}
}
}
// Governance Grid
.governance-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
@media (max-width: $breakpoint-lg) {
grid-template-columns: 1fr;
}
}
.initiative-card,
.committee-card {
@include neumorphic-card('md');
padding: 2rem;
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
position: relative;
.header-icon {
width: 24px;
height: 24px;
color: $primary-600;
}
h2 {
font-size: $text-xl;
font-weight: $font-semibold;
color: $neutral-800;
flex: 1;
}
}
}
// Strategic Initiatives
.initiatives-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.initiative-item {
padding: 1rem;
border-radius: $radius-lg;
background: $neutral-50;
.initiative-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.initiative-name {
font-weight: $font-medium;
color: $neutral-800;
font-size: $text-sm;
}
.initiative-status {
padding: 0.25rem 0.75rem;
border-radius: $radius-full;
font-size: $text-xs;
font-weight: $font-medium;
&.on-track {
background: rgba($success-500, 0.1);
color: $success-500;
}
&.ahead {
background: rgba($blue-500, 0.1);
color: $blue-500;
}
&.at-risk {
background: rgba($warning-500, 0.1);
color: $warning-500;
}
}
.initiative-progress {
margin-bottom: 0.75rem;
padding: 0.25rem;
border-radius: $radius-full;
.progress-bar-slim {
height: 4px;
border-radius: $radius-full;
background: rgba($neutral-300, 0.3);
.progress-fill-slim {
height: 100%;
background: linear-gradient(135deg, $primary-600, $primary-700);
border-radius: $radius-full;
transition: width 0.5s $spring-smooth;
}
}
}
.initiative-meta {
display: flex;
justify-content: space-between;
font-size: $text-xs;
color: $neutral-600;
}
}
// Committee Grid
.committee-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.committee-item {
padding: 1rem;
border-radius: $radius-lg;
.committee-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
.committee-icon {
width: 20px;
height: 20px;
}
h3 {
font-size: $text-sm;
font-weight: $font-semibold;
color: $neutral-800;
}
}
.committee-stats {
display: flex;
justify-content: space-around;
margin-bottom: 1rem;
.stat {
text-align: center;
.stat-value {
display: block;
font-size: $text-xl;
font-weight: $font-bold;
color: $neutral-800;
}
.stat-label {
font-size: $text-xs;
color: $neutral-600;
}
}
}
}
// Financial Section
.financial-section {
@include neumorphic-card('lg');
padding: 2rem;
margin-bottom: 2rem;
.time-selector {
display: flex;
gap: 0.5rem;
.time-button {
&.active {
background: linear-gradient(145deg, $primary-600, $primary-700);
color: white;
}
}
}
}
.financial-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
@media (max-width: $breakpoint-md) {
grid-template-columns: 1fr;
}
}
.revenue-chart {
h3 {
font-size: $text-lg;
margin-bottom: 1rem;
color: $neutral-800;
}
.chart-placeholder {
height: 200px;
border-radius: $radius-lg;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $neutral-500;
.chart-icon {
width: 48px;
height: 48px;
margin-bottom: 0.5rem;
opacity: 0.5;
}
}
}
.financial-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.metric-item {
padding: 1rem;
.metric-label {
font-size: $text-xs;
color: $neutral-600;
margin-bottom: 0.5rem;
}
.metric-value {
font-size: $text-xl;
font-weight: $font-bold;
margin-bottom: 0.5rem;
&.positive { color: $success-500; }
&.negative { color: $error-500; }
&.neutral { color: $neutral-800; }
}
.metric-change {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: $text-xs;
color: $neutral-600;
.change-icon {
width: 14px;
height: 14px;
}
}
}
// Governance Actions
.governance-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.action-card {
@include neumorphic-card('md');
padding: 2rem;
text-align: center;
transition: all $transition-base;
&:hover {
@include neumorphic-card('lg');
transform: translateY(-2px);
}
.action-icon {
width: 48px;
height: 48px;
color: $primary-600;
margin-bottom: 1rem;
}
h3 {
font-size: $text-lg;
font-weight: $font-semibold;
color: $neutral-800;
margin-bottom: 0.5rem;
}
p {
color: $neutral-600;
font-size: $text-sm;
margin-bottom: 1.5rem;
}
}
// Morphing Dropdown
.morphing-select-wrapper {
position: relative;
.select-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
.dropdown-icon {
width: 16px;
height: 16px;
transition: transform 0.3s $spring-smooth;
&.rotate {
transform: rotate(180deg);
}
}
}
}
.morphing-dropdown {
@include morphing-dropdown();
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 150px;
z-index: $z-dropdown;
.dropdown-option {
padding: 0.5rem 1rem;
cursor: pointer;
transition: all $transition-fast;
color: $neutral-700;
font-size: $text-sm;
&:hover {
background: rgba($blue-500, 0.1);
color: $blue-600;
padding-left: 1.25rem;
}
}
}
// Neumorphic Elements
.neumorphic-card {
background: linear-gradient(145deg, #ffffff, #f0f0f0);
border-radius: $radius-xl;
box-shadow: $shadow-soft-md;
}
.neumorphic-button {
@include neumorphic-button();
padding: 0.75rem 1.5rem;
border: none;
border-radius: $radius-lg;
font-weight: $font-medium;
color: $neutral-700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&.primary {
background: linear-gradient(145deg, $primary-600, $primary-700);
color: white;
&:hover {
background: linear-gradient(145deg, $primary-700, $primary-800);
}
}
&.small {
padding: 0.5rem 0.75rem;
font-size: $text-sm;
}
&.full-width {
width: 100%;
justify-content: center;
}
}
.neumorphic-inset {
box-shadow: $shadow-inset-sm;
background: linear-gradient(145deg, #e6e6e6, #ffffff);
}
// Transitions
.morph-enter-active,
.morph-leave-active {
transition: all 0.3s $spring-smooth;
}
.morph-enter-from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
.morph-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
</style>

View File

@@ -0,0 +1,498 @@
<template>
<div class="board-dashboard">
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<!-- Enhanced Welcome Header -->
<div class="dashboard-header glass-header mb-6">
<h1 class="dashboard-title text-gradient">
Welcome Back, {{ firstName }}!
</h1>
<!-- Profile Picture Section -->
<div class="profile-picture-section my-4">
<ProfileAvatar
:member-id="memberData?.member_id || memberData?.Id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:member-name="memberData?.FullName || user?.name"
size="80"
show-border
class="profile-avatar-main"
/>
</div>
<p class="dashboard-subtitle">
MonacoUSA Board Portal
</p>
<div class="text-center">
<v-chip class="glass-badge mt-2">
<v-icon start>mdi-shield-account</v-icon>
Board Member
</v-chip>
</div>
</div>
<!-- Board Statistics with Bento Grid -->
<div class="bento-grid mb-6">
<div class="bento-item bento-item--xlarge">
<v-card class="glass-card stat-card animated-entrance" style="animation-delay: 0.2s;">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-chart-box-outline</v-icon>
Board Overview
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<v-col cols="6" md="3" class="text-center">
<div class="stat-value">{{ stats.totalMembers }}</div>
<div class="text-body-2">Total Members</div>
</v-col>
<v-col cols="6" md="3" class="text-center">
<div class="stat-value">{{ stats.activeMembers }}</div>
<div class="text-body-2">Active Members</div>
</v-col>
<v-col cols="6" md="6" class="text-center">
<div class="stat-value">{{ stats.upcomingEvents }}</div>
<div class="text-body-2">Upcoming Events</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
<div class="bento-item bento-item--medium">
<v-card class="glass-card animated-entrance" style="animation-delay: 0.3s;">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-calendar-today</v-icon>
Next Event
</v-card-title>
<v-card-text class="pa-4">
<div class="text-h6 mb-2">{{ nextEvent.title }}</div>
<div class="text-body-2 mb-2">
<v-icon size="small" class="mr-1">mdi-calendar</v-icon>
{{ nextEvent.date }}
</div>
<div class="text-body-2 mb-4">
<v-icon size="small" class="mr-1">mdi-clock</v-icon>
{{ nextEvent.time }}
</div>
<v-btn
color="primary"
variant="outlined"
size="small"
style="border-color: #a31515; color: #a31515;"
@click="viewEventDetails"
>
View Details
</v-btn>
</v-card-text>
</v-card>
</div>
</div>
<!-- Dues Management Section -->
<v-row class="mb-6">
<v-col cols="12">
<BoardDuesManagement
:refresh-trigger="duesRefreshTrigger"
@view-member="handleViewMember"
@view-all-members="navigateToMembers"
@member-updated="handleMemberUpdated"
/>
</v-col>
</v-row>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
</div>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
definePageMeta({
layout: 'board',
middleware: 'auth'
});
const { firstName, isBoard, isAdmin, user } = useAuth();
// Fetch member data for profile
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Check board access on mount
onMounted(() => {
if (!isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Board membership required.'
});
}
});
// Dues management state
const duesRefreshTrigger = ref(0);
// Member dialog state
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const selectedMember = ref<Member | null>(null);
// Real data for board dashboard
const stats = ref({
totalMembers: 0,
activeMembers: 0,
upcomingEvents: 0
});
const nextEvent = ref({
id: null,
title: 'Next Event',
date: 'Loading...',
time: 'Loading...',
location: 'TBD',
description: 'Upcoming association event'
});
const isLoading = ref(true);
// Load real data on component mount
onMounted(async () => {
await loadBoardData();
});
const loadBoardData = async () => {
try {
isLoading.value = true;
// Load board statistics
const [statsResponse, meetingResponse] = await Promise.allSettled([
$fetch('/api/board/stats'),
$fetch('/api/board/next-meeting')
]);
// Handle stats response
if (statsResponse.status === 'fulfilled') {
const statsData = statsResponse.value as any;
if (statsData?.success) {
stats.value = {
totalMembers: statsData.data.totalMembers || 0,
activeMembers: statsData.data.activeMembers || 0,
upcomingEvents: statsData.data.upcomingEvents || 0
};
}
}
// Handle next meeting response
if (meetingResponse.status === 'fulfilled') {
const meetingData = meetingResponse.value as any;
if (meetingData?.success) {
nextEvent.value = {
id: meetingData.data.id,
title: meetingData.data.title || 'Next Event',
date: meetingData.data.date || 'TBD',
time: meetingData.data.time || 'TBD',
location: meetingData.data.location || 'TBD',
description: meetingData.data.description || 'Upcoming association event'
};
}
}
} catch (error) {
console.error('Error loading board data:', error);
// Keep fallback values
} finally {
isLoading.value = false;
}
};
const recentActivity = ref([
{
id: 1,
title: 'Monthly Board Meeting',
description: 'Meeting minutes approved and distributed',
type: 'success',
status: 'Completed'
},
{
id: 2,
title: 'Budget Review',
description: 'Q4 financial report under review',
type: 'warning',
status: 'In Progress'
},
{
id: 3,
title: 'Member Application',
description: 'New member application pending approval',
type: 'info',
status: 'Pending'
}
]);
// Dues management handlers
const handleViewMember = (member: Member) => {
// Open the view dialog instead of navigating away
selectedMember.value = member;
showViewDialog.value = true;
};
const handleEditMember = (member: Member) => {
// Close the view dialog and open the edit dialog
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const handleMemberUpdated = (member: Member) => {
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
// Close edit dialog
showEditDialog.value = false;
// Trigger dues refresh to update the lists
duesRefreshTrigger.value += 1;
// You could also update stats here if needed
// stats.value = await fetchUpdatedStats();
};
// Navigation methods
const navigateToEvents = () => {
// Navigate to events page
navigateTo('/dashboard/events');
};
const navigateToMembers = () => {
// Navigate to member list page
navigateTo('/dashboard/member-list');
};
const viewEventDetails = () => {
console.log('View event details');
};
const scheduleNewMeeting = () => {
console.log('Schedule new meeting');
};
const createAnnouncement = () => {
console.log('Create announcement');
};
const generateReport = () => {
console.log('Generate report');
};
</script>
<style scoped lang="scss">
.board-dashboard {
padding: 1rem;
}
/* Enhanced Header */
.dashboard-header {
margin-bottom: 2rem;
padding: 2rem;
border-radius: 20px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.15),
inset 0 1px 2px rgba(255, 255, 255, 0.6);
animation: slide-up 0.6s ease-out;
text-align: center;
.profile-picture-section {
display: flex;
justify-content: center;
align-items: center;
.profile-avatar-main {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid white;
border-radius: 50%;
}
}
}
.dashboard-title {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
animation: fade-in 0.8s ease-out;
}
.dashboard-subtitle {
color: #71717a;
font-size: 1.1rem;
}
.glass-badge {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
font-weight: 600;
}
/* Bento Grid Layout */
.bento-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.5rem;
.bento-item {
border-radius: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--small { grid-column: span 3; }
&--medium { grid-column: span 4; }
&--large { grid-column: span 6; }
&--xlarge { grid-column: span 8; }
&--full { grid-column: span 12; }
&:hover {
transform: translateY(-4px);
}
}
}
/* Glass Card Effects */
.glass-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85),
rgba(255, 255, 255, 0.75)
) !important;
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.15),
inset 0 1px 2px rgba(255, 255, 255, 0.6),
inset 0 -1px 2px rgba(0, 0, 0, 0.05) !important;
border-radius: 20px !important;
&:hover {
transform: translateY(-4px);
box-shadow:
0 12px 40px 0 rgba(31, 38, 135, 0.25),
inset 0 1px 2px rgba(255, 255, 255, 0.8),
inset 0 -1px 2px rgba(0, 0, 0, 0.05) !important;
}
}
/* Statistics Cards */
.stat-card {
.stat-value {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
/* Animated Entrance */
.animated-entrance {
animation: slide-up 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Button Enhancements */
.v-btn {
text-transform: none !important;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.25);
}
}
h3 {
color: #333;
font-weight: 600;
}
.text-body-2 {
color: #666;
}
.v-chip {
font-weight: 600;
}
/* Responsive Design */
@media (max-width: 1280px) {
.bento-grid {
.bento-item--xlarge {
grid-column: span 12;
}
.bento-item--large {
grid-column: span 6;
}
.bento-item--medium {
grid-column: span 6;
}
}
}
@media (max-width: 960px) {
.bento-grid {
.bento-item--large {
grid-column: span 12;
}
.bento-item--medium {
grid-column: span 12;
}
}
}
</style>

View File

@@ -0,0 +1,352 @@
<template>
<div class="board-dashboard">
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">Governance Documents</h1>
<p class="text-body-1 text-medium-emphasis">Access bylaws, policies, and governance materials</p>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-file-upload"
@click="showUploadDialog = true"
>
Upload Document
</v-btn>
</v-col>
</v-row>
<!-- Document Categories -->
<v-row class="mb-6">
<v-col cols="12">
<v-chip-group
v-model="selectedCategory"
selected-class="bg-primary"
mandatory
>
<v-chip
v-for="category in categories"
:key="category.value"
:value="category.value"
variant="outlined"
>
<v-icon start>{{ category.icon }}</v-icon>
{{ category.title }}
<v-badge
:content="category.count"
color="primary"
inline
class="ml-2"
/>
</v-chip>
</v-chip-group>
</v-col>
</v-row>
<!-- Documents List -->
<v-row>
<v-col
v-for="document in filteredDocuments"
:key="document.id"
cols="12"
md="6"
lg="4"
>
<v-card elevation="2" hover>
<v-card-text>
<div class="d-flex align-start">
<v-icon
:icon="getDocumentIcon(document.type)"
size="40"
:color="getDocumentColor(document.type)"
class="mr-3"
/>
<div class="flex-grow-1">
<h3 class="text-body-1 font-weight-medium mb-1">
{{ document.title }}
</h3>
<p class="text-caption text-medium-emphasis mb-2">
{{ document.description }}
</p>
<div class="d-flex align-center text-caption">
<v-icon size="x-small" class="mr-1">mdi-calendar</v-icon>
<span class="text-medium-emphasis">
Updated {{ formatDate(document.updatedAt) }}
</span>
</div>
<div class="d-flex align-center text-caption mt-1">
<v-icon size="x-small" class="mr-1">mdi-file-outline</v-icon>
<span class="text-medium-emphasis">
{{ document.fileSize }}
</span>
</div>
</div>
</div>
</v-card-text>
<v-divider />
<v-card-actions>
<v-btn
variant="text"
color="primary"
size="small"
@click="viewDocument(document)"
>
<v-icon start>mdi-eye</v-icon>
View
</v-btn>
<v-btn
variant="text"
color="primary"
size="small"
@click="downloadDocument(document)"
>
<v-icon start>mdi-download</v-icon>
Download
</v-btn>
<v-spacer />
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
>
<v-menu activator="parent">
<v-list density="compact">
<v-list-item @click="shareDocument(document)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-share-variant</v-icon>
Share
</v-list-item-title>
</v-list-item>
<v-list-item @click="editDocument(document)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-pencil</v-icon>
Edit Details
</v-list-item-title>
</v-list-item>
<v-list-item @click="archiveDocument(document)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-archive</v-icon>
Archive
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item @click="deleteDocument(document)" class="text-error">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-delete</v-icon>
Delete
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- Upload Dialog -->
<v-dialog v-model="showUploadDialog" max-width="600">
<v-card>
<v-card-title>Upload Document</v-card-title>
<v-card-text>
<v-form ref="uploadForm">
<v-row>
<v-col cols="12">
<v-file-input
v-model="uploadForm.file"
label="Select Document"
accept=".pdf,.doc,.docx"
variant="outlined"
prepend-icon="mdi-file-document"
required
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="uploadForm.title"
label="Document Title"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="uploadForm.description"
label="Description"
variant="outlined"
rows="2"
/>
</v-col>
<v-col cols="12">
<v-select
v-model="uploadForm.category"
label="Category"
:items="categories"
item-title="title"
item-value="value"
variant="outlined"
required
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showUploadDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="uploadDocument">Upload</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'board',
middleware: 'board'
});
// State
const showUploadDialog = ref(false);
const selectedCategory = ref('all');
// Form data
const uploadForm = ref({
file: null,
title: '',
description: '',
category: ''
});
// Categories
const categories = [
{ title: 'All Documents', value: 'all', icon: 'mdi-file-multiple', count: 12 },
{ title: 'Bylaws', value: 'bylaws', icon: 'mdi-gavel', count: 2 },
{ title: 'Policies', value: 'policies', icon: 'mdi-shield-check', count: 4 },
{ title: 'Minutes', value: 'minutes', icon: 'mdi-clock-outline', count: 3 },
{ title: 'Reports', value: 'reports', icon: 'mdi-chart-box', count: 3 }
];
// Mock documents
const documents = ref([
{
id: 1,
title: 'Association Bylaws 2024',
description: 'Updated bylaws governing the association operations',
type: 'bylaws',
fileSize: '2.4 MB',
updatedAt: new Date('2024-01-01')
},
{
id: 2,
title: 'Code of Conduct Policy',
description: 'Member code of conduct and ethics guidelines',
type: 'policies',
fileSize: '548 KB',
updatedAt: new Date('2023-12-15')
},
{
id: 3,
title: 'Board Meeting Minutes - January 2024',
description: 'Minutes from the January board meeting',
type: 'minutes',
fileSize: '128 KB',
updatedAt: new Date('2024-01-10')
},
{
id: 4,
title: 'Annual Financial Report 2023',
description: 'Comprehensive financial report for fiscal year 2023',
type: 'reports',
fileSize: '4.2 MB',
updatedAt: new Date('2024-01-05')
},
{
id: 5,
title: 'Conflict of Interest Policy',
description: 'Policy for managing conflicts of interest',
type: 'policies',
fileSize: '315 KB',
updatedAt: new Date('2023-11-20')
},
{
id: 6,
title: 'Strategic Plan 2024-2026',
description: 'Three-year strategic planning document',
type: 'reports',
fileSize: '1.8 MB',
updatedAt: new Date('2023-12-01')
}
]);
// Computed
const filteredDocuments = computed(() => {
if (selectedCategory.value === 'all') {
return documents.value;
}
return documents.value.filter(d => d.type === selectedCategory.value);
});
// Methods
const getDocumentIcon = (type: string) => {
switch (type) {
case 'bylaws': return 'mdi-gavel';
case 'policies': return 'mdi-shield-check';
case 'minutes': return 'mdi-clock-outline';
case 'reports': return 'mdi-chart-box';
default: return 'mdi-file-document';
}
};
const getDocumentColor = (type: string) => {
switch (type) {
case 'bylaws': return 'error';
case 'policies': return 'warning';
case 'minutes': return 'info';
case 'reports': return 'success';
default: return 'primary';
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const viewDocument = (document: any) => {
console.log('View document:', document);
};
const downloadDocument = (document: any) => {
console.log('Download document:', document);
};
const shareDocument = (document: any) => {
console.log('Share document:', document);
};
const editDocument = (document: any) => {
console.log('Edit document:', document);
};
const archiveDocument = (document: any) => {
console.log('Archive document:', document);
};
const deleteDocument = (document: any) => {
console.log('Delete document:', document);
};
const uploadDocument = () => {
console.log('Upload document:', uploadForm.value);
showUploadDialog.value = false;
};
</script>

View File

@@ -0,0 +1,295 @@
<template>
<div class="board-dashboard">
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">Board Meetings</h1>
<p class="text-body-1 text-medium-emphasis">Schedule and manage board meetings</p>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-calendar-plus"
@click="showScheduleDialog = true"
>
Schedule Meeting
</v-btn>
</v-col>
</v-row>
<!-- Meeting Tabs -->
<v-tabs v-model="activeTab" color="primary" class="mb-6">
<v-tab value="upcoming">Upcoming</v-tab>
<v-tab value="past">Past Meetings</v-tab>
<v-tab value="calendar">Calendar View</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<!-- Upcoming Meetings -->
<v-window-item value="upcoming">
<v-row>
<v-col
v-for="meeting in upcomingMeetings"
:key="meeting.id"
cols="12"
>
<v-card elevation="2" class="mb-3">
<v-card-text>
<v-row align="center">
<v-col cols="auto">
<v-avatar color="primary" size="56">
<v-icon>mdi-calendar</v-icon>
</v-avatar>
</v-col>
<v-col>
<h3 class="text-h6 mb-1">{{ meeting.title }}</h3>
<div class="text-body-2 text-medium-emphasis mb-2">
<v-icon size="small" class="mr-1">mdi-calendar</v-icon>
{{ formatDate(meeting.date) }}
<v-icon size="small" class="ml-3 mr-1">mdi-clock</v-icon>
{{ meeting.time }}
<v-icon size="small" class="ml-3 mr-1">mdi-map-marker</v-icon>
{{ meeting.location }}
</div>
<div class="text-body-2">
<v-chip size="small" variant="tonal" class="mr-2">
<v-icon start size="small">mdi-account-group</v-icon>
{{ meeting.attendees }} Confirmed
</v-chip>
<v-chip size="small" variant="tonal" color="info">
{{ meeting.type }}
</v-chip>
</div>
</v-col>
<v-col cols="auto">
<v-btn variant="outlined" color="primary" class="mr-2" @click="viewMeeting(meeting)">
View Details
</v-btn>
<v-btn variant="flat" color="primary" @click="joinMeeting(meeting)">
Join Meeting
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-window-item>
<!-- Past Meetings -->
<v-window-item value="past">
<v-data-table
:headers="pastMeetingHeaders"
:items="pastMeetings"
class="elevation-2"
hover
>
<template v-slot:item.title="{ item }">
<div class="font-weight-medium">{{ item.title }}</div>
</template>
<template v-slot:item.date="{ item }">
{{ formatDate(item.date) }}
</template>
<template v-slot:item.attendees="{ item }">
<v-chip size="small" variant="tonal">
{{ item.attendees }}/{{ item.totalInvited }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn icon="mdi-file-document" size="small" variant="text" @click="viewMinutes(item)" />
<v-btn icon="mdi-download" size="small" variant="text" @click="downloadMaterials(item)" />
</template>
</v-data-table>
</v-window-item>
<!-- Calendar View -->
<v-window-item value="calendar">
<v-card elevation="2">
<v-card-text>
<div class="text-center py-12">
<v-icon size="64" color="primary" class="mb-4">mdi-calendar-month</v-icon>
<h3 class="text-h5 mb-2">Calendar View</h3>
<p class="text-body-1 text-medium-emphasis">
Interactive calendar view coming soon
</p>
</div>
</v-card-text>
</v-card>
</v-window-item>
</v-window>
<!-- Schedule Meeting Dialog -->
<v-dialog v-model="showScheduleDialog" max-width="600">
<v-card>
<v-card-title>Schedule Board Meeting</v-card-title>
<v-card-text>
<v-form ref="meetingForm">
<v-row>
<v-col cols="12">
<v-text-field
v-model="meetingForm.title"
label="Meeting Title"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-select
v-model="meetingForm.type"
label="Meeting Type"
:items="['Regular', 'Special', 'Emergency', 'Annual']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="meetingForm.date"
label="Date"
type="date"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="meetingForm.time"
label="Time"
type="time"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="meetingForm.location"
label="Location"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="meetingForm.agenda"
label="Agenda"
variant="outlined"
rows="3"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showScheduleDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="scheduleMeeting">Schedule</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'board',
middleware: 'board'
});
// State
const activeTab = ref('upcoming');
const showScheduleDialog = ref(false);
// Form data
const meetingForm = ref({
title: '',
type: '',
date: '',
time: '',
location: '',
agenda: ''
});
// Mock data
const upcomingMeetings = ref([
{
id: 1,
title: 'Monthly Board Meeting - February',
date: new Date('2024-02-15'),
time: '10:00 AM',
location: 'Board Room / Zoom',
type: 'Regular',
attendees: 8
},
{
id: 2,
title: 'Strategic Planning Session',
date: new Date('2024-02-28'),
time: '2:00 PM',
location: 'Conference Center',
type: 'Special',
attendees: 12
}
]);
const pastMeetings = ref([
{
id: 3,
title: 'Monthly Board Meeting - January',
date: new Date('2024-01-15'),
time: '10:00 AM',
attendees: 9,
totalInvited: 10
},
{
id: 4,
title: 'Annual General Meeting',
date: new Date('2024-01-05'),
time: '6:00 PM',
attendees: 45,
totalInvited: 50
}
]);
// Table headers
const pastMeetingHeaders = [
{ title: 'Meeting', key: 'title' },
{ title: 'Date', key: 'date' },
{ title: 'Time', key: 'time' },
{ title: 'Attendance', key: 'attendees' },
{ title: 'Actions', key: 'actions', align: 'end' }
];
// Methods
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
});
};
const viewMeeting = (meeting: any) => {
console.log('View meeting:', meeting);
};
const joinMeeting = (meeting: any) => {
console.log('Join meeting:', meeting);
};
const viewMinutes = (meeting: any) => {
console.log('View minutes:', meeting);
};
const downloadMaterials = (meeting: any) => {
console.log('Download materials:', meeting);
};
const scheduleMeeting = () => {
console.log('Schedule meeting:', meetingForm.value);
showScheduleDialog.value = false;
};
</script>

View File

@@ -0,0 +1,644 @@
<template>
<div class="board-dashboard">
<!-- Header -->
<div class="mb-6">
<div class="d-flex justify-space-between align-center">
<div>
<h1 class="text-h4 font-weight-bold mb-2">Member Management</h1>
<p class="text-body-1 text-medium-emphasis">Manage and oversee all MonacoUSA members</p>
</div>
<div class="d-flex gap-2">
<v-btn
variant="outlined"
color="error"
prepend-icon="mdi-download"
@click="exportMembers"
>
Export
</v-btn>
<v-btn
color="error"
variant="flat"
prepend-icon="mdi-account-plus"
@click="showAddMemberDialog = true"
>
Add Member
</v-btn>
</div>
</div>
</div>
<!-- Statistics Cards -->
<v-row class="mb-6">
<v-col cols="12" sm="6" md="3">
<v-card elevation="1">
<v-card-text>
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-caption text-medium-emphasis mb-1">Total Members</p>
<p class="text-h5 font-weight-bold">{{ stats.total }}</p>
</div>
<v-icon size="40" color="primary">mdi-account-group</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card elevation="1">
<v-card-text>
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-caption text-medium-emphasis mb-1">Active Members</p>
<p class="text-h5 font-weight-bold text-success">{{ stats.active }}</p>
</div>
<v-icon size="40" color="success">mdi-check-circle</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card elevation="1">
<v-card-text>
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-caption text-medium-emphasis mb-1">Pending Dues</p>
<p class="text-h5 font-weight-bold text-warning">{{ stats.pendingDues }}</p>
</div>
<v-icon size="40" color="warning">mdi-clock-alert</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card elevation="1">
<v-card-text>
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-caption text-medium-emphasis mb-1">New This Month</p>
<p class="text-h5 font-weight-bold text-info">{{ stats.newThisMonth }}</p>
</div>
<v-icon size="40" color="info">mdi-account-plus</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Filters and Search -->
<v-card class="mb-6" elevation="1">
<v-card-text>
<v-row align="center">
<v-col cols="12" md="4">
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
label="Search members"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="filterStatus"
:items="statusOptions"
label="Status"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="filterDues"
:items="duesOptions"
label="Dues Status"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="filterType"
:items="memberTypeOptions"
label="Member Type"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-btn
variant="outlined"
color="error"
block
@click="resetFilters"
>
Reset
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Members Table -->
<v-card elevation="1">
<v-data-table
:headers="headers"
:items="filteredMembers"
:search="searchQuery"
:items-per-page="10"
class="elevation-0"
>
<!-- Member Name with Avatar -->
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2">
<ProfileAvatar
:member-id="item.memberId"
:first-name="item.firstName"
:last-name="item.lastName"
size="small"
:show-badge="false"
class="mr-3"
/>
<div>
<div class="font-weight-medium">{{ item.firstName }} {{ item.lastName }}</div>
<div class="text-caption text-medium-emphasis">{{ item.memberId }}</div>
</div>
</div>
</template>
<!-- Email -->
<template v-slot:item.email="{ item }">
<div class="text-body-2">{{ item.email }}</div>
</template>
<!-- Nationality -->
<template v-slot:item.nationality="{ item }">
<MultipleCountryFlags
:nationality="item.nationality"
:show-name="false"
size="small"
fallback-text="-"
/>
</template>
<!-- Status -->
<template v-slot:item.status="{ item }">
<v-chip
:color="item.status === 'Active' ? 'success' : 'grey'"
size="small"
variant="tonal"
>
{{ item.status }}
</v-chip>
</template>
<!-- Dues Status -->
<template v-slot:item.duesStatus="{ item }">
<v-chip
:color="getDuesColor(item.duesStatus)"
size="small"
variant="outlined"
>
{{ item.duesStatus }}
</v-chip>
</template>
<!-- Member Type -->
<template v-slot:item.memberType="{ item }">
<v-chip
size="small"
variant="flat"
:color="getMemberTypeColor(item.memberType)"
>
{{ item.memberType }}
</v-chip>
</template>
<!-- Join Date -->
<template v-slot:item.joinDate="{ item }">
<span class="text-body-2">{{ formatDate(item.joinDate) }}</span>
</template>
<!-- Actions -->
<template v-slot:item.actions="{ item }">
<div class="d-flex gap-1">
<v-btn
icon
variant="text"
size="small"
@click="viewMember(item)"
>
<v-icon size="small">mdi-eye</v-icon>
<v-tooltip activator="parent" location="top">View Details</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
size="small"
@click="editMember(item)"
>
<v-icon size="small">mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">Edit Member</v-tooltip>
</v-btn>
<v-btn
icon
variant="text"
size="small"
@click="sendEmail(item)"
>
<v-icon size="small">mdi-email</v-icon>
<v-tooltip activator="parent" location="top">Send Email</v-tooltip>
</v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon
variant="text"
size="small"
v-bind="props"
>
<v-icon size="small">mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="sendDuesReminder(item)">
<v-list-item-title>Send Dues Reminder</v-list-item-title>
</v-list-item>
<v-list-item @click="viewPaymentHistory(item)">
<v-list-item-title>Payment History</v-list-item-title>
</v-list-item>
<v-list-item @click="toggleStatus(item)">
<v-list-item-title>
{{ item.status === 'Active' ? 'Deactivate' : 'Activate' }}
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item @click="deleteMember(item)" class="text-error">
<v-list-item-title>Delete Member</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
</v-data-table>
</v-card>
<!-- Add Member Dialog -->
<v-dialog v-model="showAddMemberDialog" max-width="600">
<v-card>
<v-card-title>Add New Member</v-card-title>
<v-card-text>
<v-form v-model="addMemberFormValid">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="newMember.firstName"
label="First Name"
variant="outlined"
:rules="[v => !!v || 'Required']"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="newMember.lastName"
label="Last Name"
variant="outlined"
:rules="[v => !!v || 'Required']"
required
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="newMember.email"
label="Email"
type="email"
variant="outlined"
:rules="[
v => !!v || 'Required',
v => /.+@.+/.test(v) || 'Invalid email'
]"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="newMember.phone"
label="Phone"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="newMember.nationality"
label="Nationality (can select multiple)"
:items="nationalityOptions"
variant="outlined"
multiple
chips
closable-chips
hint="Select all applicable nationalities"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="newMember.memberType"
label="Member Type"
:items="['Regular', 'Premium', 'Honorary']"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="newMember.joinDate"
label="Join Date"
type="date"
variant="outlined"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showAddMemberDialog = false">Cancel</v-btn>
<v-btn
color="error"
variant="flat"
:disabled="!addMemberFormValid"
@click="addMember"
>
Add Member
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'board',
middleware: 'board'
});
// State
const searchQuery = ref('');
const filterStatus = ref(null);
const filterDues = ref(null);
const filterType = ref(null);
const showAddMemberDialog = ref(false);
const addMemberFormValid = ref(true);
// Statistics
const stats = ref({
total: 156,
active: 142,
pendingDues: 23,
newThisMonth: 8
});
// Filter options
const statusOptions = ['Active', 'Inactive'];
const duesOptions = ['Paid', 'Pending', 'Overdue'];
const memberTypeOptions = ['Regular', 'Premium', 'Honorary', 'Board', 'Admin'];
// Country options with codes for multiple selection
const nationalityOptions = [
{ title: 'United States', value: 'US' },
{ title: 'Monaco', value: 'MC' },
{ title: 'France', value: 'FR' },
{ title: 'Italy', value: 'IT' },
{ title: 'United Kingdom', value: 'GB' },
{ title: 'Germany', value: 'DE' },
{ title: 'Spain', value: 'ES' },
{ title: 'Sweden', value: 'SE' },
{ title: 'Norway', value: 'NO' },
{ title: 'Denmark', value: 'DK' },
{ title: 'Canada', value: 'CA' },
{ title: 'Australia', value: 'AU' },
{ title: 'Japan', value: 'JP' },
{ title: 'China', value: 'CN' },
{ title: 'India', value: 'IN' },
{ title: 'Brazil', value: 'BR' },
{ title: 'Mexico', value: 'MX' },
{ title: 'Russia', value: 'RU' },
{ title: 'South Africa', value: 'ZA' },
{ title: 'Other', value: 'XX' }
];
// Table headers
const headers = [
{ title: 'Member', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: true },
{ title: 'Nationality', key: 'nationality', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Dues', key: 'duesStatus', sortable: true },
{ title: 'Type', key: 'memberType', sortable: true },
{ title: 'Joined', key: 'joinDate', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' }
];
// Real members data from API
const members = ref([]);
const loading = ref(false);
// New member form
const newMember = ref({
firstName: '',
lastName: '',
email: '',
phone: '',
nationality: [] as string[], // Array for multiple nationalities
memberType: 'Regular',
joinDate: new Date().toISOString().split('T')[0]
});
// Computed
const filteredMembers = computed(() => {
let filtered = members.value;
if (filterStatus.value) {
filtered = filtered.filter(m => m.status === filterStatus.value);
}
if (filterDues.value) {
filtered = filtered.filter(m => m.duesStatus === filterDues.value);
}
if (filterType.value) {
filtered = filtered.filter(m => m.memberType === filterType.value);
}
return filtered;
});
// Methods
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getDuesColor = (status: string) => {
const colors: Record<string, string> = {
'Paid': 'success',
'Pending': 'warning',
'Overdue': 'error'
};
return colors[status] || 'grey';
};
const getMemberTypeColor = (type: string) => {
const colors: Record<string, string> = {
'Regular': 'info',
'Premium': 'purple',
'Honorary': 'orange',
'Board': 'error',
'Admin': 'pink'
};
return colors[type] || 'grey';
};
const resetFilters = () => {
searchQuery.value = '';
filterStatus.value = null;
filterDues.value = null;
filterType.value = null;
};
const exportMembers = () => {
console.log('Exporting members');
};
const viewMember = (member: any) => {
console.log('Viewing member:', member);
};
const editMember = (member: any) => {
console.log('Editing member:', member);
};
const sendEmail = (member: any) => {
console.log('Sending email to:', member.email);
};
const sendDuesReminder = (member: any) => {
console.log('Sending dues reminder to:', member.email);
};
const viewPaymentHistory = (member: any) => {
console.log('Viewing payment history for:', member);
};
const toggleStatus = (member: any) => {
member.status = member.status === 'Active' ? 'Inactive' : 'Active';
};
const deleteMember = (member: any) => {
console.log('Deleting member:', member);
};
const addMember = () => {
// Convert nationality array to comma-separated string for storage
const memberData = {
...newMember.value,
nationality: Array.isArray(newMember.value.nationality)
? newMember.value.nationality.join(',')
: newMember.value.nationality
};
console.log('Adding new member:', memberData);
showAddMemberDialog.value = false;
// Reset form
newMember.value = {
firstName: '',
lastName: '',
email: '',
phone: '',
nationality: [],
memberType: 'Regular',
joinDate: new Date().toISOString().split('T')[0]
};
};
// Load real members data from API
const loadMembers = async () => {
loading.value = true;
try {
// Fetch members from API
const response = await $fetch('/api/members');
// Check for both possible response structures
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
if (membersList && membersList.length > 0) {
// Transform the data to match our interface
members.value = membersList.map((member: any) => ({
id: member.Id || member.id,
memberId: member.member_id || `MUSA-${String(member.Id).padStart(4, '0')}`,
firstName: member.first_name,
lastName: member.last_name,
// Add name field for sorting (last name, first name format for proper sorting)
name: `${member.last_name || ''}, ${member.first_name || ''}`.trim(),
email: member.email,
phone: member.phone_number || member.phone || '',
status: member.membership_status === 'Active' ? 'Active' : 'Inactive',
duesStatus: member.dues_status || 'Unknown',
memberType: member.membership_type || 'Regular',
joinDate: member.member_since || member.created_at,
nationality: member.nationality || member.country || ''
}));
// Sort by last name, then first name by default
members.value.sort((a, b) => {
const aLastName = (a.lastName || '').toLowerCase();
const bLastName = (b.lastName || '').toLowerCase();
const aFirstName = (a.firstName || '').toLowerCase();
const bFirstName = (b.firstName || '').toLowerCase();
// First compare by last name
const lastNameCompare = aLastName.localeCompare(bLastName);
if (lastNameCompare !== 0) return lastNameCompare;
// If last names are the same, compare by first name
return aFirstName.localeCompare(bFirstName);
});
console.log(`[board-members] Loaded ${members.value.length} members from API, sorted by last name`);
} else {
console.log('[board-members] No members found in response:', response);
members.value = [];
}
} catch (error) {
console.error('Error loading members:', error);
// Keep empty array if load fails
members.value = []
} finally {
loading.value = false;
}
};
// Load data on mount
onMounted(async () => {
await loadMembers();
});
</script>
<style scoped>
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
</style>

889
pages/dashboard/admin.vue Normal file
View File

@@ -0,0 +1,889 @@
<template>
<div>
<v-container fluid>
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-4">
<v-icon left>mdi-account</v-icon>
Welcome Back, {{ firstName }}
</h1>
<p class="text-body-1 mb-6">
Manage users and portal settings for the MonacoUSA Portal.
</p>
</v-col>
</v-row>
<!-- Portal Status -->
<v-row class="mb-6">
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-text>
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-caption text-medium-emphasis mb-1">Portal Status</p>
<p class="text-h5 font-weight-bold text-success">Online</p>
</div>
<v-icon color="success" size="40">mdi-check-circle</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-text>
<div class="d-flex justify-space-between align-center">
<div>
<p class="text-caption text-medium-emphasis mb-1">Total Users</p>
<p class="text-h5 font-weight-bold">{{ userCount }}</p>
</div>
<v-icon color="primary" size="40">mdi-account-multiple</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- User Management -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title>
<v-icon left>mdi-account-group</v-icon>
User Management
</v-card-title>
<v-card-text>
<p class="mb-4">Manage user accounts, roles, and permissions for the MonacoUSA Portal.</p>
<v-row>
<v-col cols="12" md="3">
<v-btn
color="primary"
block
size="large"
@click="navigateTo('/dashboard/member-list')"
>
<v-icon start>mdi-account-cog</v-icon>
Manage Users
</v-btn>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="success"
variant="outlined"
block
size="large"
@click="showCreateUserDialog = true"
>
<v-icon start>mdi-account-plus</v-icon>
Create User Account
</v-btn>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="secondary"
variant="outlined"
block
size="large"
@click="viewAuditLogs"
>
<v-icon start>mdi-file-document-outline</v-icon>
View Audit Logs
</v-btn>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="secondary"
variant="outlined"
block
size="large"
@click="showAdminConfig = true"
>
<v-icon start>mdi-cog</v-icon>
Portal Settings
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dues Management -->
<v-row class="mb-6">
<v-col cols="12">
<BoardDuesManagement
:refresh-trigger="duesRefreshTrigger"
@view-member="handleViewMember"
@view-all-members="navigateToMembers"
@member-updated="handleMemberUpdated"
/>
</v-col>
</v-row>
<!-- Portal Configuration -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title>
<v-icon left>mdi-cog</v-icon>
Portal Configuration
</v-card-title>
<v-card-text>
<p class="mb-4">Configure all portal settings including database, email, reCAPTCHA, and membership fees in one centralized location.</p>
<v-row>
<v-col cols="12" md="4">
<v-btn
color="primary"
block
size="large"
@click="showAdminConfig = true"
>
<v-icon start>mdi-cog</v-icon>
Portal Settings
</v-btn>
</v-col>
<v-col cols="12" md="8">
<v-row dense>
<v-col cols="6" sm="3">
<v-chip color="success" variant="tonal" size="small" block>
<v-icon start size="14">mdi-database</v-icon>
NocoDB
</v-chip>
</v-col>
<v-col cols="6" sm="3">
<v-chip color="info" variant="tonal" size="small" block>
<v-icon start size="14">mdi-email</v-icon>
Email
</v-chip>
</v-col>
<v-col cols="6" sm="3">
<v-chip color="warning" variant="tonal" size="small" block>
<v-icon start size="14">mdi-shield</v-icon>
reCAPTCHA
</v-chip>
</v-col>
<v-col cols="6" sm="3">
<v-chip color="primary" variant="tonal" size="small" block>
<v-icon start size="14">mdi-bank</v-icon>
Membership
</v-chip>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Data Management -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title>
<v-icon left>mdi-database-cog</v-icon>
Data Management
</v-card-title>
<v-card-text>
<p class="mb-4">Manage data integrity and perform maintenance operations on the portal database.</p>
<v-row>
<v-col cols="12" md="6">
<v-btn
@click="assignMemberIds"
color="warning"
variant="outlined"
prepend-icon="mdi-account-multiple-plus"
block
size="large"
:loading="assigningMemberIds"
>
Assign Member IDs
</v-btn>
<div class="text-caption mt-2 text-medium-emphasis">
Assign unique member IDs (MUSA-0001, MUSA-0002, etc.) to members who don't have them
</div>
</v-col>
<v-col cols="12" md="6">
<v-btn
@click="backfillEventIds"
color="primary"
variant="outlined"
prepend-icon="mdi-calendar-sync"
block
size="large"
:loading="backfillLoading"
>
Backfill Event IDs
</v-btn>
<div class="text-caption mt-2 text-medium-emphasis">
Assign business IDs to events that don't have them
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- NocoDB Settings Dialog -->
<NocoDBSettingsDialog
v-model="showNocoDBSettings"
@settings-saved="handleSettingsSaved"
/>
<!-- Admin Configuration Dialog -->
<AdminConfigurationDialog
v-model="showAdminConfig"
@settings-saved="handleAdminConfigSaved"
/>
<!-- reCAPTCHA Configuration Dialog -->
<v-dialog v-model="showRecaptchaConfig" max-width="600">
<v-card>
<v-card-title class="text-h5">
<v-icon left>mdi-shield-account</v-icon>
reCAPTCHA Configuration
</v-card-title>
<v-card-text>
<v-alert type="info" variant="tonal" class="mb-4">
<v-alert-title>Security Configuration</v-alert-title>
Configure Google reCAPTCHA settings for form protection on the registration page.
</v-alert>
<v-form ref="recaptchaForm" v-model="recaptchaValid">
<v-text-field
v-model="recaptchaConfig.siteKey"
label="Site Key"
placeholder="6Lc6BAAAAAAAAChqRbQZcn_yyyyyyyyyyyyyyyyy"
:rules="[v => !!v || 'Site key is required']"
variant="outlined"
required
/>
<v-text-field
v-model="recaptchaConfig.secretKey"
label="Secret Key"
placeholder="6Lc6BAAAAAAAAKN3DRm6VA_xxxxxxxxxxxxxxxxx"
:rules="[v => !!v || 'Secret key is required']"
variant="outlined"
type="password"
required
/>
<v-alert type="warning" variant="tonal" class="mt-4">
<v-alert-title>Important</v-alert-title>
Keep your secret key confidential. You can get these keys from the Google reCAPTCHA admin console.
</v-alert>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showRecaptchaConfig = false">Cancel</v-btn>
<v-btn
color="primary"
:loading="savingRecaptcha"
:disabled="!recaptchaValid"
@click="saveRecaptchaConfig"
>
Save Configuration
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Membership Configuration Dialog -->
<v-dialog v-model="showMembershipConfig" max-width="600">
<v-card>
<v-card-title class="text-h5">
<v-icon left>mdi-bank</v-icon>
Membership Configuration
</v-card-title>
<v-card-text>
<v-alert type="info" variant="tonal" class="mb-4">
<v-alert-title>Payment Configuration</v-alert-title>
Configure membership fees and payment details displayed on the registration page.
</v-alert>
<v-form ref="membershipForm" v-model="membershipValid">
<v-text-field
v-model="membershipConfig.membershipFee"
label="Annual Membership Fee (€)"
type="number"
:rules="[
v => !!v || 'Membership fee is required',
v => v > 0 || 'Fee must be greater than 0'
]"
variant="outlined"
required
/>
<v-text-field
v-model="membershipConfig.iban"
label="IBAN"
placeholder="DE89 3704 0044 0532 0130 00"
:rules="[v => !!v || 'IBAN is required']"
variant="outlined"
required
/>
<v-text-field
v-model="membershipConfig.accountHolder"
label="Account Holder Name"
placeholder="MonacoUSA Association"
:rules="[v => !!v || 'Account holder is required']"
variant="outlined"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showMembershipConfig = false">Cancel</v-btn>
<v-btn
color="primary"
:loading="savingMembership"
:disabled="!membershipValid"
@click="saveMembershipConfig"
>
Save Configuration
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
<!-- Create User Dialog -->
<v-dialog v-model="showCreateUserDialog" max-width="600">
<v-card>
<v-card-title class="text-h5">
<v-icon left>mdi-account-plus</v-icon>
Create User Account
</v-card-title>
<v-card-text>
<v-alert type="info" variant="tonal" class="mb-4">
<v-alert-title>Create Portal Account</v-alert-title>
This will create a new user account in the MonacoUSA Portal with email verification.
</v-alert>
<v-form ref="createUserForm" v-model="createUserValid">
<v-row>
<v-col cols="6">
<v-text-field
v-model="newUser.firstName"
label="First Name"
:rules="[v => !!v || 'First name is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="newUser.lastName"
label="Last Name"
:rules="[v => !!v || 'Last name is required']"
variant="outlined"
required
/>
</v-col>
</v-row>
<v-text-field
v-model="newUser.email"
label="Email Address"
type="email"
:rules="[
v => !!v || 'Email is required',
v => /.+@.+\..+/.test(v) || 'Email must be valid'
]"
variant="outlined"
required
/>
<v-select
v-model="newUser.role"
label="User Role"
:items="roleOptions"
item-title="title"
item-value="value"
variant="outlined"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showCreateUserDialog = false">Cancel</v-btn>
<v-btn
color="primary"
:loading="creatingUser"
:disabled="!createUserValid"
@click="createUserAccount"
>
Create Account
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
middleware: 'auth-admin'
});
const { firstName } = useAuth();
// Reactive data
const userCount = ref(0);
const loading = ref(false);
const showCreateUserDialog = ref(false);
const showAdminConfig = ref(false);
const showRecaptchaConfig = ref(false);
const showMembershipConfig = ref(false);
const showEmailConfig = ref(false);
// Dues management
const overdueCount = ref(0);
const overdueRefreshTrigger = ref(0);
const duesRefreshTrigger = ref(0);
// Data management
const assigningMemberIds = ref(false);
const backfillLoading = ref(false);
// Member dialog state
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const selectedMember = ref(null);
// Create user dialog data
const createUserValid = ref(false);
const creatingUser = ref(false);
const newUser = ref({
firstName: '',
lastName: '',
email: '',
role: 'user'
});
const roleOptions = [
{ title: 'User', value: 'user' },
{ title: 'Board Member', value: 'board' },
{ title: 'Administrator', value: 'admin' }
];
// reCAPTCHA configuration data
const recaptchaValid = ref(false);
const savingRecaptcha = ref(false);
const recaptchaConfig = ref({
siteKey: '',
secretKey: ''
});
// Membership configuration data
const membershipValid = ref(false);
const savingMembership = ref(false);
const membershipConfig = ref({
membershipFee: 50,
iban: '',
accountHolder: ''
});
const recentActivity = ref([
{
id: 1,
title: 'User Account Created',
description: 'New user account created for john.doe@monacousa.org',
time: '2 hours ago',
icon: 'mdi-account-plus',
color: 'success'
},
{
id: 2,
title: 'Role Updated',
description: 'User role updated from User to Board Member',
time: '4 hours ago',
icon: 'mdi-shield-account',
color: 'warning'
},
{
id: 3,
title: 'System Backup',
description: 'Automated system backup completed successfully',
time: '1 day ago',
icon: 'mdi-backup-restore',
color: 'info'
},
{
id: 4,
title: 'Password Reset',
description: 'Password reset requested for jane.smith@monacousa.org',
time: '2 days ago',
icon: 'mdi-key-change',
color: 'primary'
}
]);
// Load simplified admin stats (without system metrics)
const loadStats = async () => {
try {
loading.value = true;
// Simple user count without complex system metrics
const response = await $fetch<{ userCount: number }>('/api/admin/stats');
userCount.value = response.userCount || 0;
console.log('✅ Admin stats loaded:', { userCount: userCount.value });
} catch (error) {
console.error('❌ Failed to load admin stats:', error);
// Use fallback data
userCount.value = 25;
} finally {
loading.value = false;
}
};
// Action methods (placeholders for now)
const manageUsers = () => {
window.open('https://auth.monacousa.org', '_blank');
};
const viewAuditLogs = () => {
console.log('Navigate to audit logs');
// TODO: Implement audit logs navigation
};
const showNocoDBSettings = ref(false);
const portalSettings = () => {
showNocoDBSettings.value = true;
};
const handleSettingsSaved = () => {
console.log('NocoDB settings saved successfully');
};
const handleAdminConfigSaved = () => {
console.log('Admin configuration saved successfully');
showAdminConfig.value = false;
};
// Handle opening email configuration directly
const openEmailConfig = () => {
// Set the activeTab to email when opening the admin config dialog
showEmailConfig.value = true;
showAdminConfig.value = true;
};
// Watch for showEmailConfig to set the initial tab
watch(showEmailConfig, (newValue) => {
if (newValue) {
// This will be handled by the AdminConfigurationDialog to set initial tab
showEmailConfig.value = false; // Reset the flag
}
});
const saveRecaptchaConfig = async () => {
if (!recaptchaValid.value) return;
savingRecaptcha.value = true;
try {
const response = await $fetch('/api/admin/recaptcha-config', {
method: 'POST',
body: {
siteKey: recaptchaConfig.value.siteKey,
secretKey: recaptchaConfig.value.secretKey
}
}) as any;
if (response?.success) {
showRecaptchaConfig.value = false;
console.log('reCAPTCHA configuration saved successfully');
// TODO: Show success notification
}
} catch (error) {
console.error('Failed to save reCAPTCHA configuration:', error);
// TODO: Show error notification
} finally {
savingRecaptcha.value = false;
}
};
const saveMembershipConfig = async () => {
if (!membershipValid.value) return;
savingMembership.value = true;
try {
const response = await $fetch('/api/admin/registration-config', {
method: 'POST',
body: {
membershipFee: membershipConfig.value.membershipFee,
iban: membershipConfig.value.iban,
accountHolder: membershipConfig.value.accountHolder
}
}) as any;
if (response?.success) {
showMembershipConfig.value = false;
console.log('Membership configuration saved successfully');
// TODO: Show success notification
}
} catch (error) {
console.error('Failed to save membership configuration:', error);
// TODO: Show error notification
} finally {
savingMembership.value = false;
}
};
const createUserAccount = async () => {
if (!createUserValid.value) return;
creatingUser.value = true;
try {
console.log('Creating user account:', newUser.value);
// TODO: Implement actual user creation using enhanced Keycloak API
// For now, just show success
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
// Reset form
newUser.value = {
firstName: '',
lastName: '',
email: '',
role: 'user'
};
showCreateUserDialog.value = false;
console.log('User account created successfully');
// TODO: Show success notification
// TODO: Refresh user list
} catch (error) {
console.error('Failed to create user account:', error);
// TODO: Show error notification
} finally {
creatingUser.value = false;
}
};
const createUser = () => {
console.log('Create new user');
// TODO: Implement create user dialog/form
};
const generateReport = () => {
console.log('Generate user report');
// TODO: Implement report generation
};
const manageRoles = () => {
console.log('Manage user roles');
// TODO: Implement role management
};
const systemMaintenance = () => {
console.log('System maintenance');
// TODO: Implement maintenance mode
};
// Dues management handlers
const loadOverdueCount = async () => {
try {
const response = await $fetch<{ success: boolean; data: { count: number } }>('/api/members/overdue-count');
if (response.success) {
overdueCount.value = response.data.count;
}
} catch (error: any) {
console.error('Error loading overdue count:', error);
}
};
const viewOverdueMembers = () => {
// Navigate to member list with overdue filter applied
navigateTo('/dashboard/member-list');
};
const sendDuesReminders = () => {
// Placeholder for dues reminder functionality
console.log('Send dues reminders - feature to be implemented');
};
const handleStatusesUpdated = async (updatedCount: number) => {
console.log(`Successfully updated ${updatedCount} member${updatedCount !== 1 ? 's' : ''} to inactive status`);
// Refresh overdue count
await loadOverdueCount();
// Trigger banner refresh
overdueRefreshTrigger.value += 1;
};
const handleViewMember = (member: any) => {
// Open the view dialog instead of navigating away
selectedMember.value = member;
showViewDialog.value = true;
};
const handleEditMember = (member: any) => {
// Close the view dialog and open the edit dialog
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const navigateToMembers = () => {
// Navigate to member list page
navigateTo('/dashboard/member-list');
};
const handleMemberUpdated = (member: any) => {
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
// Close edit dialog
showEditDialog.value = false;
// Trigger dues refresh
duesRefreshTrigger.value += 1;
};
// Data management functions
const assignMemberIds = async () => {
assigningMemberIds.value = true;
try {
console.log('Starting member ID assignment...');
const response = await $fetch<{
success: boolean;
message: string;
data: {
totalMembers: number;
membersUpdated: number;
updatedMembers: Array<{
id: string;
name: string;
email: string;
memberId: string;
}>;
startingId: string | null;
endingId: string | null;
};
}>('/api/admin/assign-member-ids', {
method: 'POST'
});
if (response.success) {
console.log('✅ Member ID assignment completed:', {
totalMembers: response.data.totalMembers,
membersUpdated: response.data.membersUpdated,
startingId: response.data.startingId,
endingId: response.data.endingId
});
// Show success message
alert(`Success! Assigned member IDs to ${response.data.membersUpdated} members.\nRange: ${response.data.startingId} to ${response.data.endingId}`);
// Refresh dues management data
duesRefreshTrigger.value += 1;
}
} catch (error: any) {
console.error('❌ Failed to assign member IDs:', error);
alert(`Error: ${error.statusMessage || error.message || 'Failed to assign member IDs'}`);
} finally {
assigningMemberIds.value = false;
}
};
const backfillEventIds = async () => {
backfillLoading.value = true;
try {
console.log('Starting event ID backfill...');
const response = await $fetch<{
success: boolean;
message: string;
data: {
totalEvents: number;
eventsUpdated: number;
};
}>('/api/admin/backfill-event-ids', {
method: 'POST'
});
if (response.success) {
console.log('✅ Event ID backfill completed:', {
totalEvents: response.data.totalEvents,
eventsUpdated: response.data.eventsUpdated
});
// Show success message
alert(`Success! Assigned event IDs to ${response.data.eventsUpdated} events.`);
}
} catch (error: any) {
console.error('❌ Failed to backfill event IDs:', error);
alert(`Error: ${error.statusMessage || error.message || 'Failed to backfill event IDs'}`);
} finally {
backfillLoading.value = false;
}
};
// Load stats and overdue count on component mount
onMounted(async () => {
await loadStats();
await loadOverdueCount();
});
</script>
<style scoped>
.v-card {
border-radius: 12px !important;
}
.v-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
transition: all 0.3s ease;
}
.v-btn {
text-transform: none !important;
font-weight: 600;
}
.v-list-item {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.v-list-item:last-child {
border-bottom: none;
}
</style>

350
pages/dashboard/board.vue Normal file
View File

@@ -0,0 +1,350 @@
<template>
<v-container>
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<!-- Welcome Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold" style="color: #a31515;">
Welcome Back, {{ firstName }}!
</h1>
<p class="text-h6 text-medium-emphasis">
MonacoUSA Board Portal
</p>
<div class="text-center">
<v-chip color="primary" variant="elevated" class="mt-2">
<v-icon start>mdi-shield-account</v-icon>
Board Member
</v-chip>
</div>
</v-col>
</v-row>
<!-- Board Tools -->
<v-row class="mb-6">
<v-col cols="12" md="6">
<v-card class="pa-4 text-center" elevation="2" hover>
<v-icon size="48" color="primary" class="mb-2">mdi-calendar</v-icon>
<h3 class="mb-2">Events</h3>
<p class="text-body-2 mb-4">View and manage association events</p>
<v-btn
color="primary"
variant="outlined"
style="border-color: #a31515; color: #a31515;"
@click="navigateToEvents"
>
View Events
</v-btn>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 text-center" elevation="2" hover>
<v-icon size="48" color="primary" class="mb-2">mdi-account-group</v-icon>
<h3 class="mb-2">Members</h3>
<p class="text-body-2 mb-4">View and manage association members</p>
<v-btn
color="primary"
variant="outlined"
style="border-color: #a31515; color: #a31515;"
@click="navigateToMembers"
>
View Members
</v-btn>
</v-card>
</v-col>
</v-row>
<!-- Board Statistics -->
<v-row class="mb-6">
<v-col cols="12" md="8">
<v-card elevation="2">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-chart-box-outline</v-icon>
Board Overview
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<v-col cols="6" md="3" class="text-center">
<div class="text-h4 font-weight-bold" style="color: #a31515;">{{ stats.totalMembers }}</div>
<div class="text-body-2">Total Members</div>
</v-col>
<v-col cols="6" md="3" class="text-center">
<div class="text-h4 font-weight-bold" style="color: #a31515;">{{ stats.activeMembers }}</div>
<div class="text-body-2">Active Members</div>
</v-col>
<v-col cols="6" md="6" class="text-center">
<div class="text-h4 font-weight-bold" style="color: #a31515;">{{ stats.upcomingEvents }}</div>
<div class="text-body-2">Upcoming Events</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card elevation="2">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-calendar-today</v-icon>
Next Event
</v-card-title>
<v-card-text class="pa-4">
<div class="text-h6 mb-2">{{ nextEvent.title }}</div>
<div class="text-body-2 mb-2">
<v-icon size="small" class="mr-1">mdi-calendar</v-icon>
{{ nextEvent.date }}
</div>
<div class="text-body-2 mb-4">
<v-icon size="small" class="mr-1">mdi-clock</v-icon>
{{ nextEvent.time }}
</div>
<v-btn
color="primary"
variant="outlined"
size="small"
style="border-color: #a31515; color: #a31515;"
@click="viewEventDetails"
>
View Details
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dues Management Section -->
<v-row class="mb-6">
<v-col cols="12">
<BoardDuesManagement
:refresh-trigger="duesRefreshTrigger"
@view-member="handleViewMember"
@view-all-members="navigateToMembers"
@member-updated="handleMemberUpdated"
/>
</v-col>
</v-row>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
</v-container>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
});
const { firstName, isBoard, isAdmin } = useAuth();
// Check board access on mount
onMounted(() => {
if (!isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Board membership required.'
});
}
});
// Dues management state
const duesRefreshTrigger = ref(0);
// Member dialog state
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const selectedMember = ref<Member | null>(null);
// Real data for board dashboard
const stats = ref({
totalMembers: 0,
activeMembers: 0,
upcomingEvents: 0
});
const nextEvent = ref({
id: null,
title: 'Next Event',
date: 'Loading...',
time: 'Loading...',
location: 'TBD',
description: 'Upcoming association event'
});
const isLoading = ref(true);
// Load real data on component mount
onMounted(async () => {
await loadBoardData();
});
const loadBoardData = async () => {
try {
isLoading.value = true;
// Load board statistics
const [statsResponse, meetingResponse] = await Promise.allSettled([
$fetch('/api/board/stats'),
$fetch('/api/board/next-meeting')
]);
// Handle stats response
if (statsResponse.status === 'fulfilled') {
const statsData = statsResponse.value as any;
if (statsData?.success) {
stats.value = {
totalMembers: statsData.data.totalMembers || 0,
activeMembers: statsData.data.activeMembers || 0,
upcomingEvents: statsData.data.upcomingEvents || 0
};
}
}
// Handle next meeting response
if (meetingResponse.status === 'fulfilled') {
const meetingData = meetingResponse.value as any;
if (meetingData?.success) {
nextEvent.value = {
id: meetingData.data.id,
title: meetingData.data.title || 'Next Event',
date: meetingData.data.date || 'TBD',
time: meetingData.data.time || 'TBD',
location: meetingData.data.location || 'TBD',
description: meetingData.data.description || 'Upcoming association event'
};
}
}
} catch (error) {
console.error('Error loading board data:', error);
// Keep fallback values
} finally {
isLoading.value = false;
}
};
const recentActivity = ref([
{
id: 1,
title: 'Monthly Board Meeting',
description: 'Meeting minutes approved and distributed',
type: 'success',
status: 'Completed'
},
{
id: 2,
title: 'Budget Review',
description: 'Q4 financial report under review',
type: 'warning',
status: 'In Progress'
},
{
id: 3,
title: 'Member Application',
description: 'New member application pending approval',
type: 'info',
status: 'Pending'
}
]);
// Dues management handlers
const handleViewMember = (member: Member) => {
// Open the view dialog instead of navigating away
selectedMember.value = member;
showViewDialog.value = true;
};
const handleEditMember = (member: Member) => {
// Close the view dialog and open the edit dialog
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const handleMemberUpdated = (member: Member) => {
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
// Close edit dialog
showEditDialog.value = false;
// Trigger dues refresh to update the lists
duesRefreshTrigger.value += 1;
// You could also update stats here if needed
// stats.value = await fetchUpdatedStats();
};
// Navigation methods
const navigateToEvents = () => {
// Navigate to events page
navigateTo('/dashboard/events');
};
const navigateToMembers = () => {
// Navigate to member list page
navigateTo('/dashboard/member-list');
};
const viewEventDetails = () => {
console.log('View event details');
};
const scheduleNewMeeting = () => {
console.log('Schedule new meeting');
};
const createAnnouncement = () => {
console.log('Create announcement');
};
const generateReport = () => {
console.log('Generate report');
};
</script>
<style scoped>
.v-card {
border-radius: 12px !important;
}
.v-card:hover {
transform: translateY(-2px);
transition: transform 0.2s ease-in-out;
}
.v-btn {
text-transform: none !important;
}
.v-icon {
color: #a31515 !important;
}
h3 {
color: #333;
font-weight: 600;
}
.text-body-2 {
color: #666;
}
.v-chip {
font-weight: 600;
}
</style>

572
pages/dashboard/events.vue Normal file
View File

@@ -0,0 +1,572 @@
<template>
<v-container fluid>
<!-- Upcoming Event Banner -->
<UpcomingEventBanner
v-if="upcomingEvent"
:event="upcomingEvent"
class="mb-4"
@event-click="handleEventClick"
/>
<!-- Page Header -->
<v-row class="mb-4">
<v-col cols="12" md="8">
<h1 class="text-h4 font-weight-bold text-primary">
<v-icon class="me-2">mdi-calendar</v-icon>
Events Calendar
</h1>
<p class="text-body-1 text-medium-emphasis">
View and manage events for the MonacoUSA community
</p>
</v-col>
<v-col cols="12" md="4" class="d-flex justify-end align-start ga-2">
<v-btn
v-if="isBoard || isAdmin"
@click="showCreateDialog = true"
color="primary"
prepend-icon="mdi-plus"
size="large"
>
Create Event
</v-btn>
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="outlined"
prepend-icon="mdi-download"
size="large"
>
Subscribe
</v-btn>
</template>
<v-list>
<v-list-item @click="exportCalendar">
<v-list-item-title>
<v-icon start>mdi-calendar-export</v-icon>
Export Calendar
</v-list-item-title>
</v-list-item>
<v-list-item @click="subscribeCalendar">
<v-list-item-title>
<v-icon start>mdi-calendar-sync</v-icon>
Subscribe (iOS/Android)
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
<!-- Filters Row -->
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-select
v-model="filters.event_type"
:items="eventTypeOptions"
label="Event Type"
variant="outlined"
density="comfortable"
clearable
@update:model-value="applyFilters"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="filters.visibility"
:items="visibilityOptions"
label="Visibility"
variant="outlined"
density="comfortable"
clearable
@update:model-value="applyFilters"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="filters.search"
label="Search events..."
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-magnify"
clearable
@update:model-value="debounceSearch"
/>
</v-col>
<v-col cols="12" md="3" class="d-flex align-center">
<v-btn
@click="clearFilters"
variant="outlined"
prepend-icon="mdi-filter-off"
:disabled="!hasActiveFilters"
>
Clear Filters
</v-btn>
</v-col>
</v-row>
<!-- Main Calendar -->
<EventCalendar
ref="calendarRef"
:events="events"
:loading="loading"
:show-create-button="false"
@event-click="handleEventClick"
@date-click="handleDateClick"
@view-change="handleViewChange"
@date-range-change="handleDateRangeChange"
/>
<!-- Stats Row (if admin/board) -->
<v-row v-if="isBoard || isAdmin" class="mt-6">
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-primary font-weight-bold">{{ totalEvents }}</div>
<div class="text-body-2">Total Events</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-success font-weight-bold">{{ totalRSVPs }}</div>
<div class="text-body-2">Total RSVPs</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-warning font-weight-bold">{{ upcomingEventsCount }}</div>
<div class="text-body-2">Upcoming Events</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-info font-weight-bold">{{ thisMonthEventsCount }}</div>
<div class="text-body-2">This Month</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dialogs -->
<CreateEventDialog
v-model="showCreateDialog"
:prefilled-date="prefilledDate"
:prefilled-end-date="prefilledEndDate"
@event-created="handleEventCreated"
/>
<EventDetailsDialog
v-model="showDetailsDialog"
:event="selectedEvent"
@rsvp-updated="handleRSVPUpdated"
/>
<!-- Error Snackbar -->
<v-snackbar
v-model="showErrorSnackbar"
color="error"
:timeout="5000"
>
{{ errorMessage }}
<template #actions>
<v-btn
variant="text"
@click="showErrorSnackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
<!-- Success Snackbar -->
<v-snackbar
v-model="showSuccessSnackbar"
color="success"
:timeout="3000"
>
{{ successMessage }}
<template #actions>
<v-btn
variant="text"
@click="showSuccessSnackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import type { Event, EventFilters } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
import { useEvents } from '~/composables/useEvents';
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
});
const { isBoard, isAdmin, user } = useAuth();
const {
events,
loading,
error,
upcomingEvent,
fetchEvents,
getUpcomingEvents,
clearCache
} = useEvents();
// Component refs
const calendarRef = ref();
// Reactive state
const showCreateDialog = ref(false);
const showDetailsDialog = ref(false);
const selectedEvent = ref<Event | null>(null);
const prefilledDate = ref<string>('');
const prefilledEndDate = ref<string>('');
// Filter state
const filters = reactive<EventFilters>({
event_type: undefined,
visibility: undefined,
search: undefined,
status: 'active'
});
// Notification state
const showErrorSnackbar = ref(false);
const showSuccessSnackbar = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
// Search debouncing
let searchTimeout: NodeJS.Timeout | null = null;
// Computed properties
const eventTypeOptions = [
{ title: 'All Types', value: undefined },
{ title: 'Meeting', value: 'meeting' },
{ title: 'Social Event', value: 'social' },
{ title: 'Fundraiser', value: 'fundraiser' },
{ title: 'Workshop', value: 'workshop' },
{ title: 'Board Only', value: 'board-only' }
];
const visibilityOptions = computed(() => {
const options = [
{ title: 'All Events', value: undefined },
{ title: 'Public', value: 'public' },
{ title: 'Board Only', value: 'board-only' }
];
if (isAdmin.value) {
options.push({ title: 'Admin Only', value: 'admin-only' });
}
return options;
});
const hasActiveFilters = computed(() => {
return filters.event_type || filters.visibility || filters.search;
});
const totalEvents = computed(() => events.value.length);
const totalRSVPs = computed(() => {
return events.value.reduce((count, event) => {
const attendees = typeof event.current_attendees === 'string'
? parseInt(event.current_attendees) || 0
: event.current_attendees || 0;
return count + attendees;
}, 0);
});
const upcomingEventsCount = computed(() => {
const now = new Date();
return events.value.filter(event => new Date(event.start_datetime) >= now).length;
});
const thisMonthEventsCount = computed(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return events.value.filter(event => {
const eventDate = new Date(event.start_datetime);
return eventDate >= startOfMonth && eventDate <= endOfMonth;
}).length;
});
// Methods
const applyFilters = async () => {
try {
await fetchEvents(filters);
} catch (err: any) {
showErrorMessage('Failed to apply filters');
}
};
const debounceSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
applyFilters();
}, 500);
};
const clearFilters = async () => {
filters.event_type = undefined;
filters.visibility = undefined;
filters.search = undefined;
await applyFilters();
};
const handleEventClick = (eventInfo: any) => {
console.log('[Events] EVENT CLICK HANDLER CALLED');
console.log('[Events] Raw eventInfo received:', eventInfo);
// Extract the original event data from FullCalendar's extendedProps
const calendarEvent = eventInfo.event || eventInfo;
const originalEvent = calendarEvent.extendedProps?.originalEvent;
console.log('[Events] Calendar event:', calendarEvent);
console.log('[Events] Original event from extendedProps:', originalEvent);
// Use original event if available, otherwise reconstruct from calendar event
if (originalEvent) {
selectedEvent.value = originalEvent as Event;
console.log('[Events] Using original event from extendedProps');
} else {
console.log('[Events] Reconstructing event from calendar data');
// Fallback: reconstruct event from FullCalendar event data
selectedEvent.value = {
id: calendarEvent.id,
title: calendarEvent.title,
description: calendarEvent.extendedProps?.description || '',
event_type: calendarEvent.extendedProps?.event_type || 'meeting',
start_datetime: calendarEvent.start?.toISOString() || calendarEvent.startStr,
end_datetime: calendarEvent.end?.toISOString() || calendarEvent.endStr,
location: calendarEvent.extendedProps?.location || '',
visibility: calendarEvent.extendedProps?.visibility || 'public',
is_paid: calendarEvent.extendedProps?.is_paid ? 'true' : 'false',
cost_members: calendarEvent.extendedProps?.cost_members || '',
cost_non_members: calendarEvent.extendedProps?.cost_non_members || '',
max_attendees: calendarEvent.extendedProps?.max_attendees?.toString() || '',
current_attendees: calendarEvent.extendedProps?.current_attendees?.toString() || '0',
user_rsvp: calendarEvent.extendedProps?.user_rsvp || null,
creator: calendarEvent.extendedProps?.creator || '',
status: 'active'
} as Event;
}
console.log('[Events] Final selected event for dialog:', {
id: selectedEvent.value.id,
title: selectedEvent.value.title,
event_type: selectedEvent.value.event_type,
full_event: selectedEvent.value
});
console.log('[Events] About to show dialog...');
console.log('[Events] showDetailsDialog current value:', showDetailsDialog.value);
showDetailsDialog.value = true;
console.log('[Events] showDetailsDialog after setting to true:', showDetailsDialog.value);
// Force Vue to update
nextTick(() => {
console.log('[Events] After nextTick - showDetailsDialog:', showDetailsDialog.value);
console.log('[Events] After nextTick - selectedEvent:', selectedEvent.value?.title);
});
};
const handleDateClick = (dateInfo: any) => {
if (isBoard.value || isAdmin.value) {
// Debug: Log the date format being passed
console.log('[Events] Date clicked:', dateInfo);
// Create proper ISO datetime strings (full format)
let formattedDate = '';
let formattedEndDate = '';
if (dateInfo.date) {
// Convert to proper Date object and set default time
const clickedDate = new Date(dateInfo.date);
// Set default time to 6 PM in the user's local timezone
clickedDate.setHours(18, 0, 0, 0); // Default to 6 PM
formattedDate = clickedDate.toISOString(); // Full ISO string
// Set end date 2 hours later if not provided
if (dateInfo.endDate) {
const endDate = new Date(dateInfo.endDate);
endDate.setHours(20, 0, 0, 0); // Default to 8 PM for end date
formattedEndDate = endDate.toISOString();
} else {
const endDate = new Date(clickedDate);
endDate.setHours(20, 0, 0, 0); // Default to 8 PM (2 hours later)
formattedEndDate = endDate.toISOString();
}
}
// Clear previous values first to trigger watchers properly
prefilledDate.value = '';
prefilledEndDate.value = '';
// Set new values
nextTick(() => {
prefilledDate.value = formattedDate;
prefilledEndDate.value = formattedEndDate;
console.log('[Events] Prefilled dates set:', {
date: formattedDate,
endDate: formattedEndDate
});
showCreateDialog.value = true;
});
}
};
const handleViewChange = (viewInfo: any) => {
// Handle calendar view changes if needed
console.log('View changed:', viewInfo);
};
const handleDateRangeChange = async (start: string, end: string) => {
// Fetch events for the new date range
const rangeFilters = {
...filters,
start_date: start,
end_date: end
};
try {
await fetchEvents(rangeFilters);
} catch (err: any) {
showErrorMessage('Failed to load events for date range');
}
};
const handleEventCreated = async (event: Event) => {
showSuccessMessage('Event created successfully!');
await refreshCalendar();
};
const handleRSVPUpdated = async (event: Event) => {
showSuccessMessage('RSVP updated successfully!');
await refreshCalendar();
};
const refreshCalendar = async () => {
try {
// Clear cache and force refresh events data
clearCache();
await fetchEvents({ force: true });
// Also refresh the calendar component
calendarRef.value?.refetchEvents?.();
console.log('Calendar refreshed successfully');
} catch (error) {
console.error('Error refreshing calendar:', error);
showErrorMessage('Failed to refresh calendar');
}
};
const exportCalendar = () => {
// Create download link for iCal export
const feedUrl = `/api/events/calendar-feed?user_id=${user.value?.id}&format=ical`;
const link = document.createElement('a');
link.href = feedUrl;
link.download = 'monacousa-events.ics';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showSuccessMessage('Calendar export started!');
};
const subscribeCalendar = async () => {
try {
const feedUrl = `${window.location.origin}/api/events/calendar-feed?user_id=${user.value?.id}&format=ical`;
await navigator.clipboard.writeText(feedUrl);
showSuccessMessage('Calendar subscription URL copied to clipboard!');
} catch (error) {
showErrorMessage('Failed to copy subscription URL');
}
};
const showErrorMessage = (message: string) => {
errorMessage.value = message;
showErrorSnackbar.value = true;
};
const showSuccessMessage = (message: string) => {
successMessage.value = message;
showSuccessSnackbar.value = true;
};
// Lifecycle
onMounted(async () => {
try {
await fetchEvents({ status: 'active' });
} catch (err: any) {
showErrorMessage('Failed to load events');
}
});
// Watch for errors from composable
watchEffect(() => {
if (error.value) {
showErrorMessage(error.value);
}
});
// Cleanup
onUnmounted(() => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
});
</script>
<style scoped>
.text-medium-emphasis {
opacity: 0.7;
}
.v-container {
max-width: 1400px;
}
/* Ensure calendar takes full width */
:deep(.event-calendar) {
width: 100%;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.v-container {
padding: 12px;
}
.text-h4 {
font-size: 1.5rem !important;
}
}
</style>

64
pages/dashboard/index.vue Normal file
View File

@@ -0,0 +1,64 @@
<template>
<div class="dashboard-router">
<v-container v-if="loading" class="fill-height">
<v-row justify="center" align="center">
<v-col cols="auto" class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
width="6"
/>
<p class="mt-4 text-h6">Loading your dashboard...</p>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
layout: 'dashboard'
});
const { user, userTier, isAdmin, isBoard } = useAuth();
const loading = ref(true);
// Route to tier-specific dashboard - auth middleware ensures user is authenticated
onMounted(() => {
console.log('🔄 Dashboard mounted, routing to role-specific section...');
// Auth middleware has already verified authentication - route based on highest privilege
if (user.value && userTier.value) {
// Use new role-based structure
let targetRoute = '';
if (isAdmin.value) {
targetRoute = '/admin/dashboard';
} else if (isBoard.value) {
targetRoute = '/board/dashboard';
} else {
targetRoute = '/member/dashboard';
}
console.log('🔄 Routing to role-specific dashboard:', targetRoute);
navigateTo(targetRoute, { replace: true });
} else {
console.warn('❌ No user or tier found - this should not happen after auth middleware');
// Fallback - middleware should have caught this
navigateTo('/login');
}
loading.value = false;
});
</script>
<style scoped>
.dashboard-router {
min-height: 100vh;
}
.v-progress-circular {
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,680 @@
<template>
<v-container fluid class="pa-4">
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<!-- Header -->
<v-row class="mb-4">
<v-col>
<h1 class="text-h4 font-weight-bold mb-4">
<v-icon left>mdi-account-multiple</v-icon>
Welcome Back, {{ firstName }}
</h1>
<p class="text-body-1 mb-4">
Manage MonacoUSA association members and their information.
</p>
</v-col>
</v-row>
<!-- Search and Filter Controls -->
<v-row class="mb-4">
<v-col cols="12" md="2">
<v-text-field
v-model="searchTerm"
label="Search members..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
clearable
@input="debouncedSearch"
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="activeFilter"
:items="activeFilterOptions"
label="Member Status"
variant="outlined"
clearable
prepend-inner-icon="mdi-account-check"
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="duesFilter"
:items="duesFilterOptions"
label="Dues Status"
variant="outlined"
clearable
prepend-inner-icon="mdi-cash"
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="sortOption"
:items="sortOptions"
label="Sort By"
variant="outlined"
prepend-inner-icon="mdi-sort"
/>
</v-col>
<v-col cols="12" md="2">
<v-btn
color="primary"
block
size="large"
@click="showAddDialog = true"
:disabled="!canCreateMembers"
>
<v-icon start>mdi-plus</v-icon>
Add Member
</v-btn>
</v-col>
</v-row>
<!-- Member Statistics -->
<v-row class="mb-6">
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 text-primary font-weight-bold">{{ totalMembers }}</div>
<div class="text-body-2 text-medium-emphasis">Total Members</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 text-success font-weight-bold">{{ activeMembers }}</div>
<div class="text-body-2 text-medium-emphasis">Active Members</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 text-success font-weight-bold">{{ paidDuesMembers }}</div>
<div class="text-body-2 text-medium-emphasis">Paid Dues</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 font-weight-bold">{{ uniqueNationalities }}</div>
<div class="text-body-2 text-medium-emphasis">Countries</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Loading State -->
<v-row v-if="loading" justify="center" class="my-12">
<v-col cols="auto" class="text-center">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-h6">Loading members...</p>
</v-col>
</v-row>
<!-- Error State -->
<v-alert
v-else-if="error"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="error = ''"
>
<template #title>Failed to load members</template>
{{ error }}
<template #append>
<v-btn color="error" variant="text" @click="loadMembers">
Try Again
</v-btn>
</template>
</v-alert>
<!-- Members Grid -->
<v-row v-else>
<v-col
v-for="member in filteredMembers"
:key="member.Id"
cols="12"
sm="6"
md="6"
lg="4"
xl="3"
>
<MemberCard
:member="member"
@edit="editMember"
@delete="confirmDeleteMember"
@view="viewMember"
@create-portal-account="createPortalAccount"
:can-edit="canEditMembers"
:can-delete="canDeleteMembers"
:can-create-portal-account="canCreatePortalAccounts"
:creating-portal-account="creatingPortalAccountIds.includes(member.Id)"
/>
</v-col>
<!-- No Results State -->
<v-col v-if="filteredMembers.length === 0 && !loading && !error" cols="12" class="text-center">
<v-card elevation="0" class="pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-account-search</v-icon>
<h3 class="text-h5 mb-2">No members found</h3>
<p class="text-body-1 mb-4">
{{ searchTerm
? 'Try adjusting your filters to find members.'
: 'No members have been added yet.' }}
</p>
<v-btn
v-if="canCreateMembers && !searchTerm"
color="primary"
@click="showAddDialog = true"
>
<v-icon start>mdi-plus</v-icon>
Add First Member
</v-btn>
</v-card>
</v-col>
</v-row>
<!-- Add Member Dialog -->
<AddMemberDialog
v-model="showAddDialog"
@member-created="handleMemberCreated"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="editMember"
/>
<!-- Create Portal Account Dialog -->
<CreatePortalAccountDialog
v-model="showCreatePortalAccountDialog"
:member="selectedMemberForPortalAccount"
@account-created="handlePortalAccountCreated"
/>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title class="text-h6">
<v-icon color="error" class="mr-2">mdi-delete-alert</v-icon>
Confirm Delete
</v-card-title>
<v-card-text>
Are you sure you want to delete <strong>{{ selectedMember?.FullName }}</strong>?
<br><br>
<v-alert type="warning" variant="tonal" class="mt-2">
This action cannot be undone.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false" variant="text">
Cancel
</v-btn>
<v-btn
color="error"
@click="deleteMember"
:loading="deleteLoading"
>
<v-icon start>mdi-delete</v-icon>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Success Snackbar -->
<v-snackbar
v-model="showSuccess"
:timeout="4000"
color="success"
location="top"
>
{{ successMessage }}
<template #actions>
<v-btn variant="text" @click="showSuccess = false">
Close
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import type { Member, MembershipStatus } from '~/utils/types';
import { getAllCountries, searchCountries } from '~/utils/countries';
definePageMeta({
layout: 'dashboard',
middleware: 'auth-board'
});
// Auth and permissions
const { firstName, isBoard, isAdmin } = useAuth();
const canCreateMembers = computed(() => isBoard.value || isAdmin.value);
const canEditMembers = computed(() => isBoard.value || isAdmin.value);
const canDeleteMembers = computed(() => isAdmin.value);
const canCreatePortalAccounts = computed(() => isAdmin.value); // Only admins can create portal accounts
// Reactive data
const members = ref<Member[]>([]);
const loading = ref(true);
const error = ref('');
// Search and filtering
const searchTerm = ref('');
const activeFilter = ref('');
const duesFilter = ref('');
const sortOption = ref('lastname-asc');
// Dialogs
const showAddDialog = ref(false);
const showEditDialog = ref(false);
const showViewDialog = ref(false);
const showDeleteDialog = ref(false);
const showCreatePortalAccountDialog = ref(false);
const selectedMember = ref<Member | null>(null);
const selectedMemberForPortalAccount = ref<Member | null>(null);
const deleteLoading = ref(false);
// Success handling
const showSuccess = ref(false);
const successMessage = ref('');
// Portal account creation
const creatingPortalAccountIds = ref<string[]>([]);
// Overdue dues management
const overdueCount = ref(0);
const overdueRefreshTrigger = ref(0);
// Filter options
const activeFilterOptions = [
{ title: 'Active Members', value: 'active' },
{ title: 'Inactive Members', value: 'inactive' }
];
const membershipLevelOptions = [
{ title: 'Regular Member', value: 'regular' },
{ title: 'Board Member', value: 'board' },
{ title: 'Honorary Member', value: 'honorary' },
{ title: 'New Member', value: 'new' },
{ title: 'Delinquent Member', value: 'delinquent' }
];
const duesFilterOptions = [
{ title: 'Dues Paid', value: 'paid' },
{ title: 'Dues Outstanding', value: 'unpaid' }
];
// Sort options
const sortOptions = [
{ title: 'Last Name (A-Z)', value: 'lastname-asc' },
{ title: 'Last Name (Z-A)', value: 'lastname-desc' },
{ title: 'First Name (A-Z)', value: 'firstname-asc' },
{ title: 'First Name (Z-A)', value: 'firstname-desc' },
{ title: 'Nationality (A-Z)', value: 'nationality-asc' },
{ title: 'Nationality (Z-A)', value: 'nationality-desc' }
];
// Computed properties
const filteredMembers = computed(() => {
let filtered = [...members.value];
// Search filter
if (searchTerm.value) {
const search = searchTerm.value.toLowerCase();
filtered = filtered.filter(member =>
member.FullName?.toLowerCase().includes(search) ||
member.email?.toLowerCase().includes(search) ||
member.phone?.includes(search) ||
member.member_id?.toLowerCase().includes(search) ||
`MUSA-${member.Id}`.toLowerCase().includes(search) // Search by generated member ID format
);
}
// Active/Inactive filter
if (activeFilter.value) {
if (activeFilter.value === 'active') {
filtered = filtered.filter(member => member.membership_status === 'Active');
} else if (activeFilter.value === 'inactive') {
filtered = filtered.filter(member => member.membership_status !== 'Active');
}
}
// Dues filter
if (duesFilter.value) {
if (duesFilter.value === 'paid') {
filtered = filtered.filter(member => member.current_year_dues_paid === 'true');
} else if (duesFilter.value === 'unpaid') {
filtered = filtered.filter(member => member.current_year_dues_paid !== 'true');
}
}
// Sorting
if (sortOption.value) {
filtered.sort((a, b) => {
// Extract first and last names properly
const getLastName = (member: any) => member.last_name || '';
const getFirstName = (member: any) => member.first_name || '';
switch (sortOption.value) {
case 'lastname-asc': {
const lastNameCompare = getLastName(a).localeCompare(getLastName(b));
return lastNameCompare !== 0 ? lastNameCompare : getFirstName(a).localeCompare(getFirstName(b));
}
case 'lastname-desc': {
const lastNameCompare = getLastName(b).localeCompare(getLastName(a));
return lastNameCompare !== 0 ? lastNameCompare : getFirstName(b).localeCompare(getFirstName(a));
}
case 'firstname-asc':
return getFirstName(a).localeCompare(getFirstName(b));
case 'firstname-desc':
return getFirstName(b).localeCompare(getFirstName(a));
case 'nationality-asc':
return (a.nationality || '').localeCompare(b.nationality || '');
case 'nationality-desc':
return (b.nationality || '').localeCompare(a.nationality || '');
default:
return 0;
}
});
}
return filtered;
});
const totalMembers = computed(() => members.value.length);
const activeMembers = computed(() => {
// Temporary debug logging
console.log('Members data for active count:');
members.value.forEach((m, i) => {
if (i < 5) { // Only log first 5 to avoid spam
console.log(`${m.FullName}: status="${m.membership_status}", type=${typeof m.membership_status}`);
}
});
const activeCount = members.value.filter(m => m.membership_status === 'Active').length;
console.log(`Active members count: ${activeCount} out of ${members.value.length} total`);
return activeCount;
});
const paidDuesMembers = computed(() =>
members.value.filter(m => m.current_year_dues_paid === 'true').length
);
const uniqueNationalities = computed(() => {
const nationalities = new Set(members.value.map(m => m.nationality).filter(Boolean));
return nationalities.size;
});
// Methods
const loadMembers = async () => {
loading.value = true;
error.value = '';
try {
const response = await $fetch<{ success: boolean; data: { list: Member[] } }>('/api/members');
if (response.success) {
members.value = response.data.list || [];
// DIAGNOSTIC: Log what we received from API
console.log('[member-list] Received response from API:', response);
console.log('[member-list] Members count:', members.value.length);
if (members.value.length > 0) {
const sampleMember = members.value[0];
console.log('[member-list] DIAGNOSTIC - Sample member from API:', JSON.stringify(sampleMember, null, 2));
console.log('[member-list] DIAGNOSTIC - Sample member fields:', Object.keys(sampleMember));
console.log('[member-list] DIAGNOSTIC - Sample FullName:', `"${sampleMember.FullName}"`);
console.log('[member-list] DIAGNOSTIC - Sample first_name:', `"${sampleMember.first_name}"`);
console.log('[member-list] DIAGNOSTIC - Sample last_name:', `"${sampleMember.last_name}"`);
}
} else {
throw new Error('Failed to load members');
}
} catch (err: any) {
console.error('Error loading members:', err);
error.value = err.message || 'Failed to load members. Please try again.';
} finally {
loading.value = false;
}
};
// Simple debounce function
const debouncedSearch = (() => {
let timeout: NodeJS.Timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
// Search happens automatically via computed
}, 300);
};
})();
const filterMembers = () => {
// Filtering happens automatically via computed
};
const viewMember = (member: Member) => {
selectedMember.value = member;
showViewDialog.value = true;
};
const editMember = (member: Member) => {
selectedMember.value = member;
showEditDialog.value = true;
};
const confirmDeleteMember = (member: Member) => {
selectedMember.value = member;
showDeleteDialog.value = true;
};
const deleteMember = async () => {
if (!selectedMember.value) return;
deleteLoading.value = true;
try {
const response = await $fetch<{ success: boolean; message?: string }>(`/api/members/${selectedMember.value.Id}`, {
method: 'DELETE'
});
if (response.success) {
// Remove from local array
const index = members.value.findIndex(m => m.Id === selectedMember.value?.Id);
if (index !== -1) {
members.value.splice(index, 1);
}
showSuccess.value = true;
successMessage.value = `${selectedMember.value.FullName} has been deleted successfully.`;
showDeleteDialog.value = false;
selectedMember.value = null;
}
} catch (err: any) {
console.error('Error deleting member:', err);
error.value = err.message || 'Failed to delete member. Please try again.';
} finally {
deleteLoading.value = false;
}
};
const handleMemberCreated = (newMember: Member) => {
console.log('[member-list] =====================================');
console.log('[member-list] handleMemberCreated called with:', JSON.stringify(newMember, null, 2));
console.log('[member-list] newMember fields:', Object.keys(newMember));
console.log('[member-list] FullName value:', `"${newMember.FullName}"`);
console.log('[member-list] first_name value:', `"${newMember.first_name}"`);
console.log('[member-list] last_name value:', `"${newMember.last_name}"`);
console.log('[member-list] nationality value:', `"${newMember.nationality}"`);
console.log('[member-list] email value:', `"${newMember.email}"`);
console.log('[member-list] member_id value:', `"${newMember.member_id}"`);
console.log('[member-list] membership_status value:', `"${newMember.membership_status}"`);
// ADVANCED DEBUGGING: Check if data is actually missing
const hasFirstName = !!(newMember.first_name && newMember.first_name.trim());
const hasLastName = !!(newMember.last_name && newMember.last_name.trim());
const hasFullName = !!(newMember.FullName && newMember.FullName.trim());
console.log('[member-list] Data validation:');
console.log(' - hasFirstName:', hasFirstName);
console.log(' - hasLastName:', hasLastName);
console.log(' - hasFullName:', hasFullName);
// If the API response is missing data, refresh the entire member list instead
if (!hasFirstName || !hasLastName || !hasFullName) {
console.error('[member-list] ❌ API response missing critical member data, refreshing member list...');
loadMembers();
showSuccess.value = true;
successMessage.value = 'Member created successfully. Refreshing member list...';
return;
}
// Calculate FullName with robust fallback
const fullName = newMember.FullName ||
`${newMember.first_name || ''} ${newMember.last_name || ''}`.trim() ||
'New Member';
console.log('[member-list] Calculated FullName:', `"${fullName}"`);
// Ensure the member has complete data for display
const memberWithCompleteData = {
...newMember,
FullName: fullName,
// Ensure all required fields are present
first_name: newMember.first_name || '',
last_name: newMember.last_name || '',
nationality: newMember.nationality || '',
email: newMember.email || '',
membership_status: newMember.membership_status || 'Active'
};
console.log('[member-list] Final member data:', JSON.stringify(memberWithCompleteData, null, 2));
console.log('[member-list] Adding member to beginning of list...');
members.value.unshift(memberWithCompleteData);
showSuccess.value = true;
successMessage.value = `${fullName} has been added successfully.`;
console.log('[member-list] ✅ Member added to local list, total count:', members.value.length);
console.log('[member-list] =====================================');
};
const handleMemberUpdated = (updatedMember: Member) => {
const index = members.value.findIndex(m => m.Id === updatedMember.Id);
if (index !== -1) {
members.value[index] = updatedMember;
}
showSuccess.value = true;
successMessage.value = `${updatedMember.FullName} has been updated successfully.`;
};
const createPortalAccount = (member: Member) => {
selectedMemberForPortalAccount.value = member;
showCreatePortalAccountDialog.value = true;
};
const handlePortalAccountCreated = (updatedMember: Member) => {
// Update the member in the local array to reflect the new keycloak_id
const index = members.value.findIndex(m => m.Id === updatedMember.Id);
if (index !== -1) {
members.value[index] = updatedMember;
}
showSuccess.value = true;
successMessage.value = `Portal account created successfully for ${updatedMember.FullName}.`;
};
// Overdue dues handlers
const loadOverdueCount = async () => {
try {
const response = await $fetch<{
success: boolean;
data: {
count: number;
overdueMembers: any[];
}
}>('/api/members/overdue-count');
if (response.success) {
overdueCount.value = response.data.count;
}
} catch (error: any) {
console.error('Error loading overdue count:', error);
}
};
const viewOverdueMembers = () => {
// Filter to show only inactive members (who were marked inactive due to overdue dues)
activeFilter.value = 'inactive';
duesFilter.value = 'unpaid';
showSuccess.value = true;
successMessage.value = 'Showing members with overdue dues (marked as inactive)';
};
const sendDuesReminders = () => {
// Placeholder for dues reminder functionality
console.log('Send dues reminders - feature to be implemented');
showSuccess.value = true;
successMessage.value = 'Dues reminder feature coming soon!';
};
const handleStatusesUpdated = async (updatedCount: number) => {
showSuccess.value = true;
successMessage.value = `Successfully updated ${updatedCount} member${updatedCount !== 1 ? 's' : ''} to inactive status`;
// Refresh members list and overdue count
await loadMembers();
await loadOverdueCount();
// Trigger banner refresh
overdueRefreshTrigger.value += 1;
};
// Load members and overdue count on mount
onMounted(async () => {
await loadMembers();
await loadOverdueCount();
});
</script>
<style scoped>
.v-card {
border-radius: 12px !important;
transition: all 0.3s ease;
}
.v-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
}
.member-grid {
min-height: 400px;
}
.text-primary {
color: #a31515 !important;
}
</style>

613
pages/dashboard/mockup.vue Normal file
View File

@@ -0,0 +1,613 @@
<template>
<div class="dashboard-mockup">
<!-- Header -->
<header class="dashboard-header">
<div class="dashboard-header__content">
<h1
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{ opacity: 1, x: 0 }"
class="dashboard-header__title"
>
Welcome back, {{ userName }}
</h1>
<p
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{ opacity: 1, x: 0, transition: { delay: 100 } }"
class="dashboard-header__subtitle"
>
Here's what's happening with MonacoUSA today
</p>
</div>
<div class="dashboard-header__actions">
<MonacoButton variant="glass" icon="bell">
Notifications
</MonacoButton>
<MonacoButton variant="primary" icon="plus">
New Event
</MonacoButton>
</div>
</header>
<!-- Stats Grid -->
<section class="dashboard-stats">
<StatsCard
v-for="(stat, index) in stats"
:key="stat.label"
:label="stat.label"
:value="stat.value"
:icon="stat.icon"
:prefix="stat.prefix"
:suffix="stat.suffix"
:trend="stat.trend"
:progress="stat.progress"
:sparkline="stat.sparkline"
:delay="index"
variant="glass"
/>
</section>
<!-- Main Content Grid -->
<div class="dashboard-grid">
<!-- Recent Activity -->
<GlassCard
title="Recent Activity"
variant="glass"
:delay="400"
class="dashboard-activity"
>
<div class="activity-list">
<div
v-for="(activity, index) in recentActivities"
:key="index"
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{
opacity: 1,
x: 0,
transition: { delay: 500 + (index * 50) }
}"
class="activity-item"
>
<div class="activity-item__icon">
<span :class="`activity-icon activity-icon--${activity.type}`">
{{ activity.icon }}
</span>
</div>
<div class="activity-item__content">
<p class="activity-item__text">{{ activity.text }}</p>
<span class="activity-item__time">{{ activity.time }}</span>
</div>
</div>
</div>
</GlassCard>
<!-- Upcoming Events -->
<GlassCard
title="Upcoming Events"
variant="glass"
:delay="450"
class="dashboard-events"
>
<div class="events-list">
<div
v-for="(event, index) in upcomingEvents"
:key="index"
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: { delay: 550 + (index * 50) }
}"
class="event-card"
>
<div class="event-card__date">
<span class="event-card__day">{{ event.day }}</span>
<span class="event-card__month">{{ event.month }}</span>
</div>
<div class="event-card__content">
<h4 class="event-card__title">{{ event.title }}</h4>
<p class="event-card__location">{{ event.location }}</p>
<div class="event-card__attendees">
<span class="event-card__count">{{ event.attendees }} attending</span>
<MonacoButton variant="ghost" size="sm">
View Details
</MonacoButton>
</div>
</div>
</div>
</div>
</GlassCard>
<!-- Member Status -->
<GlassCard
title="Member Status"
variant="gradient"
:delay="500"
class="dashboard-member-status"
>
<div class="member-status">
<div class="member-status__badge">
<span class="badge badge--active">Active Member</span>
</div>
<div class="member-status__info">
<div class="status-item">
<span class="status-item__label">Dues Status</span>
<span class="status-item__value status-item__value--success">Paid</span>
</div>
<div class="status-item">
<span class="status-item__label">Next Payment</span>
<span class="status-item__value">January 2025</span>
</div>
<div class="status-item">
<span class="status-item__label">Member Since</span>
<span class="status-item__value">March 2023</span>
</div>
</div>
<MonacoButton variant="primary" block>
Manage Membership
</MonacoButton>
</div>
</GlassCard>
<!-- Quick Actions -->
<GlassCard
title="Quick Actions"
variant="glass"
:delay="550"
class="dashboard-actions"
>
<div class="quick-actions">
<button
v-for="(action, index) in quickActions"
:key="action.label"
v-motion
:initial="{ opacity: 0, scale: 0.8 }"
:enter="{
opacity: 1,
scale: 1,
transition: { delay: 600 + (index * 50) }
}"
class="action-button"
>
<span class="action-button__icon">{{ action.icon }}</span>
<span class="action-button__label">{{ action.label }}</span>
</button>
</div>
</GlassCard>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import GlassCard from '~/components/ui/GlassCard.vue'
import MonacoButton from '~/components/ui/MonacoButton.vue'
import StatsCard from '~/components/ui/StatsCard.vue'
const userName = ref('John')
const stats = ref([
{
label: 'Total Members',
value: 1234,
icon: 'users',
trend: { type: 'up', value: 12 },
sparkline: [30, 40, 35, 50, 49, 60, 70, 91, 95]
},
{
label: 'Events This Month',
value: 8,
icon: 'calendar',
suffix: ' events',
trend: { type: 'up', value: 33 }
},
{
label: 'Dues Collected',
value: 45670,
icon: 'dollar',
prefix: '$',
trend: { type: 'up', value: 5 },
progress: 78
},
{
label: 'Active Projects',
value: 12,
icon: 'briefcase',
trend: { type: 'neutral', value: 0 }
}
])
const recentActivities = ref([
{
icon: '👤',
type: 'member',
text: 'New member John Doe joined',
time: '2 hours ago'
},
{
icon: '📅',
type: 'event',
text: 'Summer Gala event created',
time: '5 hours ago'
},
{
icon: '💳',
type: 'payment',
text: 'Sarah Smith paid dues',
time: '1 day ago'
},
{
icon: '📝',
type: 'update',
text: 'Board meeting minutes posted',
time: '2 days ago'
}
])
const upcomingEvents = ref([
{
day: '15',
month: 'DEC',
title: 'Monaco Winter Gala',
location: 'Grand Ballroom',
attendees: 120
},
{
day: '22',
month: 'DEC',
title: 'Board Meeting',
location: 'Conference Room A',
attendees: 15
},
{
day: '31',
month: 'DEC',
title: 'New Year Celebration',
location: 'Monaco Club',
attendees: 200
}
])
const quickActions = ref([
{ icon: '📝', label: 'Register for Event' },
{ icon: '💳', label: 'Pay Dues' },
{ icon: '📊', label: 'View Reports' },
{ icon: '👥', label: 'Member Directory' },
{ icon: '📧', label: 'Send Newsletter' },
{ icon: '⚙️', label: 'Settings' }
])
</script>
<style scoped lang="scss">
.dashboard-mockup {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%);
min-height: 100vh;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
&__content {
flex: 1;
}
&__title {
font-size: 2rem;
font-weight: 700;
color: #27272a;
margin: 0 0 0.5rem;
}
&__subtitle {
font-size: 1rem;
color: #6b7280;
margin: 0;
}
&__actions {
display: flex;
gap: 1rem;
}
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.5rem;
.dashboard-activity {
grid-column: span 8;
}
.dashboard-events {
grid-column: span 4;
}
.dashboard-member-status {
grid-column: span 4;
}
.dashboard-actions {
grid-column: span 8;
}
}
.activity-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: rgba(220, 38, 38, 0.05);
}
&__icon {
flex-shrink: 0;
}
&__content {
flex: 1;
}
&__text {
margin: 0 0 0.25rem;
color: #27272a;
font-size: 0.875rem;
}
&__time {
font-size: 0.75rem;
color: #6b7280;
}
}
.activity-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 8px;
font-size: 1rem;
&--member {
background: rgba(59, 130, 246, 0.1);
}
&--event {
background: rgba(168, 85, 247, 0.1);
}
&--payment {
background: rgba(16, 185, 129, 0.1);
}
&--update {
background: rgba(251, 146, 60, 0.1);
}
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-card {
display: flex;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 12px;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.7);
transform: translateX(4px);
}
&__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
border-radius: 10px;
flex-shrink: 0;
}
&__day {
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
}
&__month {
font-size: 0.75rem;
text-transform: uppercase;
}
&__content {
flex: 1;
}
&__title {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
color: #27272a;
}
&__location {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
&__attendees {
display: flex;
align-items: center;
justify-content: space-between;
}
&__count {
font-size: 0.75rem;
color: #dc2626;
font-weight: 500;
}
}
.member-status {
display: flex;
flex-direction: column;
gap: 1.5rem;
&__badge {
text-align: center;
}
&__info {
display: flex;
flex-direction: column;
gap: 1rem;
}
}
.badge {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
&--active {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
&__label {
font-size: 0.875rem;
color: #6b7280;
}
&__value {
font-size: 0.875rem;
font-weight: 600;
color: #27272a;
&--success {
color: #10b981;
}
}
}
.quick-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.action-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
background: rgba(255, 255, 255, 0.5);
border: 2px solid transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(220, 38, 38, 0.2);
transform: translateY(-2px);
}
&__icon {
font-size: 1.5rem;
}
&__label {
font-size: 0.875rem;
font-weight: 500;
color: #27272a;
}
}
// Responsive
@media (max-width: 1024px) {
.dashboard-grid {
.dashboard-activity,
.dashboard-events,
.dashboard-member-status,
.dashboard-actions {
grid-column: span 12;
}
}
.quick-actions {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
&__actions {
width: 100%;
button {
flex: 1;
}
}
}
.dashboard-stats {
grid-template-columns: 1fr;
}
}
</style>

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