Compare commits
262 Commits
858b252a7e
...
visual-aud
| Author | SHA1 | Date | |
|---|---|---|---|
| e54ef9e596 | |||
| 5e5bcdfb4f | |||
| 793d973f35 | |||
| 12469a7952 | |||
| 183bba0c9e | |||
| 1162d0c9ca | |||
| 64dbc78569 | |||
| 9800245b7e | |||
| d13b9837b3 | |||
| 95c253a552 | |||
| b67100df2a | |||
| d34d16fda1 | |||
| 67bb9e32ac | |||
| f57f6b6bb2 | |||
| 3e7d04c521 | |||
| bdbb5694ae | |||
| a41a483de5 | |||
| f84adeff21 | |||
| 3b455a3989 | |||
| 8c2847cbd9 | |||
| 4ba24f8626 | |||
| e949df311b | |||
| 4d24315103 | |||
| e75de8b9f4 | |||
| 0199e5ba8c | |||
| 299a49c258 | |||
| f68e3afacd | |||
| 557f9b6260 | |||
| c37e0d0b1b | |||
| 9e4f037917 | |||
| 52c10a6c1b | |||
| f20c1ef96a | |||
| 8954621813 | |||
| 185ac24067 | |||
| c99599f7a2 | |||
| abd71445ab | |||
| cbf97254a2 | |||
| c9e181e8a8 | |||
| ce7d5af450 | |||
| 70e79d2618 | |||
| 1aef356d78 | |||
| 9d93f0ca84 | |||
| bf75c51b32 | |||
| a85be39ffa | |||
| 363d3367fc | |||
| 1758dec6ac | |||
| 1c4cf8ac19 | |||
| ae18ce1786 | |||
| 7685cd130f | |||
| e74f12eaa0 | |||
| 36b8f9d2d4 | |||
| 6e0e640f1d | |||
| 5af0f2e4c0 | |||
| ab706298a6 | |||
| eb6efe7c90 | |||
| 696b321373 | |||
| 41eeb8650c | |||
| 4d0591ce7d | |||
| aba6c2ecac | |||
| 9fa9db9b8a | |||
| 7c49b9db66 | |||
| 8b1dd4d083 | |||
| 1471f7d7b3 | |||
| d9d8627e97 | |||
| 27e38d98e5 | |||
| c1f986bc07 | |||
| d4ecd15914 | |||
| c39936984b | |||
| de75d2d764 | |||
| 968fa6febb | |||
| 368293e0e2 | |||
| 6f2c843cfd | |||
| cdacb4a114 | |||
| 136eb5229e | |||
| 031ed4fd9e | |||
| 4b78080f53 | |||
| c6edd6d25d | |||
| cde48de9ff | |||
| 17b78b3514 | |||
| 2cbef70d82 | |||
| df4b89a45a | |||
| dc0a3c6a2f | |||
| 1f50c2adb7 | |||
| e02d6c3e33 | |||
| df6d549573 | |||
| a8022d8fb3 | |||
| 888059a612 | |||
| 7784fab23f | |||
| 4adbb0465a | |||
| b059d81c21 | |||
| 5d3518d256 | |||
| 3da5a64dbb | |||
| a0e9643880 | |||
| 503d68cd2d | |||
| 676bbc04f6 | |||
| 615112b7e8 | |||
| 983361114c | |||
| 503d10f0ab | |||
| 2ff0c31bbd | |||
| 0952d6c381 | |||
| 400f9cdd52 | |||
| 1ab45cf503 | |||
| 198fbf3187 | |||
| 1875fac7d4 | |||
| 3d565e8185 | |||
| fc1d691950 | |||
| 44aee8f2f9 | |||
| 9ee0b2f14e | |||
| b4e72ddf9a | |||
| 34fdf820fe | |||
| b49148cf95 | |||
| 9b183b48cc | |||
| e097fb746f | |||
| b833826a1e | |||
| 4b1a77de90 | |||
| 5371ad4fa2 | |||
| db19eb2708 | |||
| 62fb84d25e | |||
| 234c939dcd | |||
| 7205de22c9 | |||
| 3620bd8b53 | |||
| d215dfedc7 | |||
| a0153a76a4 | |||
| d5647c3667 | |||
| 5473555977 | |||
| 1553a39fa8 | |||
| ef01d2f22e | |||
| 072acf95eb | |||
| 70b77fbe9f | |||
| 7d55468a21 | |||
| 287af29f6c | |||
| 1d5ecfddcd | |||
| c4789ec9df | |||
| 9c029eb510 | |||
| d01758b947 | |||
| 2c3c64e7e3 | |||
| fd8767e56d | |||
| c0c5ae6c44 | |||
| 54a4f05c2a | |||
| 0688c23093 | |||
| 122d6fdd26 | |||
| 85e8a20f40 | |||
| e75579e3e4 | |||
| 22dbbae150 | |||
| 490cb57b66 | |||
| 67d4d5236b | |||
| e06f639454 | |||
| f096897129 | |||
| a555584b2c | |||
| 6e68e42f28 | |||
| 13cf728ab2 | |||
| ea6a722364 | |||
| c12b88072f | |||
| f4044c4477 | |||
| f1a462094a | |||
| abf6ade8cd | |||
| 7a8c88c341 | |||
| d9ef5bbdeb | |||
| ff85d1c722 | |||
| d3c3a865ba | |||
| 91dea9910d | |||
| ecae3795ee | |||
| e33f32e15a | |||
| 8b05fdd3d7 | |||
| 86977ca92a | |||
| 0774e16fb2 | |||
| 62be77ec34 | |||
| c4379f0813 | |||
| 0d6a82f03c | |||
| 9572291299 | |||
| 30136117ce | |||
| 4e53e7ea10 | |||
| 524ecc6941 | |||
| 21bc4909b1 | |||
| 2eaf9cda95 | |||
| fd08c38ade | |||
| 86a315f24c | |||
| 006d4cf1ff | |||
| 623ad9c3fd | |||
| 2b2cd5891f | |||
| d14008efd4 | |||
| 30b7e23319 | |||
| 09773f9571 | |||
| d55f253222 | |||
| 44cdc988ee | |||
| 358e9c0ad1 | |||
| 794b6a09f0 | |||
| a4e8231a3b | |||
| c13ef30f69 | |||
| df1ff15975 | |||
| bff89bd89d | |||
| 97653b7307 | |||
| c4a0230f42 | |||
| 8d872f9a04 | |||
| dcb7840825 | |||
| 97a0b5eea6 | |||
| 4ec05e29dc | |||
| 7b72d7a565 | |||
| 7d9f895ca6 | |||
| 15dd090d44 | |||
| aed8dc68fc | |||
| 3951ce1d4e | |||
| cb73b239a8 | |||
| 3894295569 | |||
| 42d1d012c1 | |||
| 72492fb754 | |||
| 4365cc53ff | |||
| 5535b7905d | |||
| b308b8272c | |||
| 28fa779dae | |||
| 0545f7e9c4 | |||
| 59bb4ca714 | |||
| 497e0134d8 | |||
| d2057cc878 | |||
| 65bda25c8f | |||
| 0193269749 | |||
| e6371d02b9 | |||
| 9f9cb7db53 | |||
| 3f81d0dd86 | |||
| dcce2050ee | |||
| 863ad9abe7 | |||
| f6bc81cb01 | |||
| 9202509c9c | |||
| 024eca02ac | |||
| 13fa95a9a2 | |||
| d36209818a | |||
| f096a22824 | |||
| b043648db6 | |||
| 5fe015af51 | |||
| 22a74c6b33 | |||
| d0d7a34ae7 | |||
| ce0cbdc980 | |||
| 676420c3fa | |||
| 6f2037e01c | |||
| af99ea48e2 | |||
| c84442433f | |||
| c6a57c7922 | |||
| 423d8c3aa1 | |||
| 616490dfef | |||
| 2843bcf4f5 | |||
| 146b3c9400 | |||
| ec6958375c | |||
| d0c9c02bf9 | |||
| 91cbffe189 | |||
| 99772ab62c | |||
| 5c8bf15956 | |||
| aa541fcc5c | |||
| 789ecd7eab | |||
| af4fae6378 | |||
| fe5aed075f | |||
| eef81d7409 | |||
| 98ef466022 | |||
| 1b2ce79919 | |||
| 05b8d97e22 | |||
| 989c56acbf | |||
| cbaedeb0a8 | |||
| 2c545dcaaa | |||
| 57428f437c | |||
| c2c9f2fb8e | |||
| cd29123e23 | |||
| 2c2c0f5c33 | |||
| 308c58e924 |
66
.claude/claude_project.json
Normal file
66
.claude/claude_project.json
Normal 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
101
.claude/settings.local.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
14
.env.example
14
.env.example
@@ -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
8
.gitignore
vendored
@@ -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
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal 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"
|
||||
@@ -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
102
ENVIRONMENT_VARIABLES.md
Normal 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
474
assets/scss/components/_dashboards.scss
Normal file
474
assets/scss/components/_dashboards.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
652
assets/scss/design-system-v2.scss
Normal file
652
assets/scss/design-system-v2.scss
Normal 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
1046
assets/scss/main.scss
Normal file
File diff suppressed because it is too large
Load Diff
559
components/AddMemberDialog.vue
Normal file
559
components/AddMemberDialog.vue
Normal 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>
|
||||
1211
components/AdminConfigurationDialog.vue
Normal file
1211
components/AdminConfigurationDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
238
components/BoardDuesManagement.vue
Normal file
238
components/BoardDuesManagement.vue
Normal 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
116
components/CountryFlag.vue
Normal 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>
|
||||
778
components/CreateEventDialog.vue
Normal file
778
components/CreateEventDialog.vue
Normal 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>
|
||||
313
components/CreatePortalAccountDialog.vue
Normal file
313
components/CreatePortalAccountDialog.vue
Normal 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>
|
||||
593
components/DuesActionCard.vue
Normal file
593
components/DuesActionCard.vue
Normal 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>
|
||||
276
components/DuesOverdueBanner.vue
Normal file
276
components/DuesOverdueBanner.vue
Normal 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>
|
||||
622
components/DuesPaymentBanner.vue
Normal file
622
components/DuesPaymentBanner.vue
Normal 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>
|
||||
732
components/EditMemberDialog.vue
Normal file
732
components/EditMemberDialog.vue
Normal 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>
|
||||
507
components/EventCalendar.vue
Normal file
507
components/EventCalendar.vue
Normal 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>
|
||||
809
components/EventDetailsDialog.vue
Normal file
809
components/EventDetailsDialog.vue
Normal 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>
|
||||
201
components/ForgotPasswordDialog.vue
Normal file
201
components/ForgotPasswordDialog.vue
Normal 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
633
components/MemberCard.vue
Normal 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>
|
||||
159
components/MonacoUSALogo.vue
Normal file
159
components/MonacoUSALogo.vue
Normal 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>
|
||||
175
components/MultipleCountryFlags.vue
Normal file
175
components/MultipleCountryFlags.vue
Normal 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>
|
||||
742
components/MultipleNationalityInput.vue
Normal file
742
components/MultipleNationalityInput.vue
Normal 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>
|
||||
474
components/NocoDBSettingsDialog.vue
Normal file
474
components/NocoDBSettingsDialog.vue
Normal 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>
|
||||
240
components/PWAInstallBanner.vue
Normal file
240
components/PWAInstallBanner.vue
Normal 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>
|
||||
626
components/PhoneInputWrapper.vue
Normal file
626
components/PhoneInputWrapper.vue
Normal 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>
|
||||
310
components/ProfileAvatar.vue
Normal file
310
components/ProfileAvatar.vue
Normal 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>
|
||||
335
components/RegistrationSuccessDialog.vue
Normal file
335
components/RegistrationSuccessDialog.vue
Normal 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>
|
||||
424
components/UpcomingEventBanner.vue
Normal file
424
components/UpcomingEventBanner.vue
Normal 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>
|
||||
725
components/ViewMemberDialog.vue
Normal file
725
components/ViewMemberDialog.vue
Normal 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>
|
||||
285
components/dashboard/ActivityTimeline.vue
Normal file
285
components/dashboard/ActivityTimeline.vue
Normal 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>
|
||||
164
components/dashboard/BentoGrid.vue
Normal file
164
components/dashboard/BentoGrid.vue
Normal 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>
|
||||
303
components/dashboard/EventsCard.vue
Normal file
303
components/dashboard/EventsCard.vue
Normal 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>
|
||||
348
components/dashboard/PaymentCard.vue
Normal file
348
components/dashboard/PaymentCard.vue
Normal 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>
|
||||
443
components/dashboard/ProfileCard.vue
Normal file
443
components/dashboard/ProfileCard.vue
Normal 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>
|
||||
163
components/dashboard/QuickActionCard.vue
Normal file
163
components/dashboard/QuickActionCard.vue
Normal 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>
|
||||
296
components/dashboard/SimpleProfileCard.vue
Normal file
296
components/dashboard/SimpleProfileCard.vue
Normal 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>
|
||||
332
components/dashboard/StatsCard.vue
Normal file
332
components/dashboard/StatsCard.vue
Normal 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>
|
||||
64
components/ui/AnimatedNumber.vue
Normal file
64
components/ui/AnimatedNumber.vue
Normal 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>
|
||||
417
components/ui/FloatingInput.vue
Normal file
417
components/ui/FloatingInput.vue
Normal 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
276
components/ui/GlassCard.vue
Normal 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
197
components/ui/Icon.vue
Normal 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>
|
||||
408
components/ui/MemberCard.vue
Normal file
408
components/ui/MemberCard.vue
Normal 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>
|
||||
329
components/ui/MonacoButton.vue
Normal file
329
components/ui/MonacoButton.vue
Normal 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
369
components/ui/StatsCard.vue
Normal 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>
|
||||
@@ -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
441
composables/useEvents.ts
Normal 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
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
1975
docs/minio_example_guide.md
Normal file
File diff suppressed because it is too large
Load Diff
214
error.vue
Normal file
214
error.vue
Normal 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
18768
keycloak-rest-api.json
Normal file
File diff suppressed because it is too large
Load Diff
667
layouts/admin.vue
Normal file
667
layouts/admin.vue
Normal 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
743
layouts/board.vue
Normal 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
280
layouts/dashboard.vue
Normal 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
640
layouts/member.vue
Normal 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
17
middleware/admin.ts
Normal 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
14
middleware/auth-admin.ts
Normal 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
14
middleware/auth-board.ts
Normal 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
7
middleware/auth-user.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo('/login');
|
||||
}
|
||||
});
|
||||
@@ -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
15
middleware/board.ts
Normal 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
8
middleware/guest.ts
Normal 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
15
middleware/member.ts
Normal 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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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/ {
|
||||
|
||||
329
nuxt.config.ts
329
nuxt.config.ts
@@ -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
1924
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
721
pages/admin/dashboard-v2.vue
Normal file
721
pages/admin/dashboard-v2.vue
Normal 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>
|
||||
1063
pages/admin/dashboard/index.vue
Normal file
1063
pages/admin/dashboard/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
552
pages/admin/events/index.vue
Normal file
552
pages/admin/events/index.vue
Normal 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>
|
||||
996
pages/admin/members-refined.vue
Normal file
996
pages/admin/members-refined.vue
Normal 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>
|
||||
800
pages/admin/members/index.vue
Normal file
800
pages/admin/members/index.vue
Normal 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>
|
||||
557
pages/admin/payments/index.vue
Normal file
557
pages/admin/payments/index.vue
Normal 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>
|
||||
818
pages/admin/settings/index.vue
Normal file
818
pages/admin/settings/index.vue
Normal 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
415
pages/admin/users/index.vue
Normal 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>
|
||||
406
pages/auth/forgot-password-mockup.vue
Normal file
406
pages/auth/forgot-password-mockup.vue
Normal 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
466
pages/auth/login-mockup.vue
Normal 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>
|
||||
489
pages/auth/setup-password.vue
Normal file
489
pages/auth/setup-password.vue
Normal 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>
|
||||
747
pages/auth/signup-mockup.vue
Normal file
747
pages/auth/signup-mockup.vue
Normal 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>
|
||||
279
pages/auth/verify-expired.vue
Normal file
279
pages/auth/verify-expired.vue
Normal 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>
|
||||
265
pages/auth/verify-success.vue
Normal file
265
pages/auth/verify-success.vue
Normal 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
510
pages/auth/verify.vue
Normal 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>
|
||||
886
pages/board/dashboard-v2.vue
Normal file
886
pages/board/dashboard-v2.vue
Normal 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>
|
||||
498
pages/board/dashboard/index.vue
Normal file
498
pages/board/dashboard/index.vue
Normal 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>
|
||||
352
pages/board/governance/index.vue
Normal file
352
pages/board/governance/index.vue
Normal 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>
|
||||
295
pages/board/meetings/index.vue
Normal file
295
pages/board/meetings/index.vue
Normal 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>
|
||||
644
pages/board/members/index.vue
Normal file
644
pages/board/members/index.vue
Normal 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
889
pages/dashboard/admin.vue
Normal 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
350
pages/dashboard/board.vue
Normal 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
572
pages/dashboard/events.vue
Normal 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
64
pages/dashboard/index.vue
Normal 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>
|
||||
680
pages/dashboard/member-list.vue
Normal file
680
pages/dashboard/member-list.vue
Normal 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
613
pages/dashboard/mockup.vue
Normal 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
Reference in New Issue
Block a user