Merge branch 'auth'
This commit is contained in:
commit
eb5c994f2d
76 changed files with 22438 additions and 9522 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,6 +3,7 @@ dist/
|
|||
|
||||
# generated types
|
||||
.astro/
|
||||
.cursor
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
|
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.background": "#221489",
|
||||
"titleBar.activeBackground": "#301DC0",
|
||||
"titleBar.activeForeground": "#F9F9FE"
|
||||
}
|
||||
}
|
273
bun.lock
273
bun.lock
|
@ -5,21 +5,40 @@
|
|||
"dependencies": {
|
||||
"@astrojs/mdx": "4.0.3",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "4.1.2",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/tailwind": "5.1.4",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@iconify-json/heroicons": "^1.2.2",
|
||||
"@iconify/react": "^5.2.0",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"astro": "5.1.1",
|
||||
"astro-expressive-code": "^0.38.3",
|
||||
"astro-icon": "^1.1.4",
|
||||
"astro-expressive-code": "^0.40.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"chart.js": "^4.4.7",
|
||||
"dexie": "^4.0.11",
|
||||
"framer-motion": "^12.4.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"pocketbase": "^0.25.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-icons": "^5.4.0",
|
||||
"rehype-expressive-code": "^0.40.2",
|
||||
"tailwindcss": "^3.4.16",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"daisyui": "^4.12.23",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"tailwindcss-animated": "^1.1.2",
|
||||
|
@ -48,7 +67,7 @@
|
|||
|
||||
"@astrojs/prism": ["@astrojs/prism@3.2.0", "", { "dependencies": { "prismjs": "^1.29.0" } }, "sha512-GilTHKGCW6HMq7y3BUv9Ac7GMe/MO9gi9GW62GzKtth0SwukCu/qp2wLiGpEujhY+VVhaG9v7kv/5vFzvf4NYw=="],
|
||||
|
||||
"@astrojs/react": ["@astrojs/react@4.1.2", "", { "dependencies": { "@vitejs/plugin-react": "^4.3.4", "ultrahtml": "^1.5.3", "vite": "^6.0.5" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-Slw8Bho50w1+rYnSnDl5PDAUikSOEItx5DEJU5OgmarTirBr1audIb2DgC8faAGcGkq5WhuUVsSiq/TmSORlwA=="],
|
||||
"@astrojs/react": ["@astrojs/react@4.2.0", "", { "dependencies": { "@vitejs/plugin-react": "^4.3.4", "ultrahtml": "^1.5.3", "vite": "^6.0.9" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-2OccnYFK+mLuy9GpJqPM3BQGvvemnXNeww+nBVYFuiH04L7YIdfg4Gq0LT7v/BraiuADV5uTl9VhTDL/ZQPAhw=="],
|
||||
|
||||
"@astrojs/tailwind": ["@astrojs/tailwind@5.1.4", "", { "dependencies": { "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "postcss-load-config": "^4.0.2" }, "peerDependencies": { "astro": "^3.0.0 || ^4.0.0 || ^5.0.0", "tailwindcss": "^3.0.24" } }, "sha512-EJ3uoTZZr0RYwTrVS2HgYN0+VbXvg7h87AtwpD5OzqS3GyMwRmzfOwHfORTxoWGQRrY9k/Fi+Awk60kwpvRL5Q=="],
|
||||
|
||||
|
@ -128,9 +147,11 @@
|
|||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
|
@ -142,13 +163,17 @@
|
|||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@expressive-code/core": ["@expressive-code/core@0.38.3", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-s0/OtdRpBONwcn23O8nVwDNQqpBGKscysejkeBkwlIeHRLZWgiTVrusT5Idrdz1d8cW5wRk9iGsAIQmwDPXgJg=="],
|
||||
"@expressive-code/core": ["@expressive-code/core@0.40.2", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-gXY3v7jbgz6nWKvRpoDxK4AHUPkZRuJsM79vHX/5uhV9/qX6Qnctp/U/dMHog/LCVXcuOps+5nRmf1uxQVPb3w=="],
|
||||
|
||||
"@expressive-code/plugin-frames": ["@expressive-code/plugin-frames@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3" } }, "sha512-qL2oC6FplmHNQfZ8ZkTR64/wKo9x0c8uP2WDftR/ydwN/yhe1ed7ZWYb8r3dezxsls+tDokCnN4zYR594jbpvg=="],
|
||||
"@expressive-code/plugin-frames": ["@expressive-code/plugin-frames@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2" } }, "sha512-aLw5IlDlZWb10Jo/TTDCVsmJhKfZ7FJI83Zo9VDrV0OBlmHAg7klZqw68VDz7FlftIBVAmMby53/MNXPnMjTSQ=="],
|
||||
|
||||
"@expressive-code/plugin-shiki": ["@expressive-code/plugin-shiki@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3", "shiki": "^1.22.2" } }, "sha512-kqHnglZeesqG3UKrb6e9Fq5W36AZ05Y9tCREmSN2lw8LVTqENIeCIkLDdWtQ5VoHlKqwUEQFTVlRehdwoY7Gmw=="],
|
||||
"@expressive-code/plugin-shiki": ["@expressive-code/plugin-shiki@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2", "shiki": "^1.26.1" } }, "sha512-t2HMR5BO6GdDW1c1ISBTk66xO503e/Z8ecZdNcr6E4NpUfvY+MRje+LtrcvbBqMwWBBO8RpVKcam/Uy+1GxwKQ=="],
|
||||
|
||||
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3" } }, "sha512-dPK3+BVGTbTmGQGU3Fkj3jZ3OltWUAlxetMHI6limUGCWBCucZiwoZeFM/WmqQa71GyKRzhBT+iEov6kkz2xVA=="],
|
||||
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2" } }, "sha512-/XoLjD67K9nfM4TgDlXAExzMJp6ewFKxNpfUw4F7q5Ecy+IU3/9zQQG/O70Zy+RxYTwKGw2MA9kd7yelsxnSmw=="],
|
||||
|
||||
"@iconify-json/heroicons": ["@iconify-json/heroicons@1.2.2", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-qoW4pXr5kTTL6juEjgTs83OJIwpePu7q1tdtKVEdj+i0zyyVHgg/dd9grsXJQnpTpBt6/VwNjrXBvFjRsKPENg=="],
|
||||
|
||||
"@iconify/react": ["@iconify/react@5.2.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-7Sdjrqq3fkkQNks9SY3adGC37NQTHsBJL2PRKlQd455PoDi9s+Es9AUTY+vGLFOYs5yO9w9yCE42pmxCwG26WA=="],
|
||||
|
||||
"@iconify/tools": ["@iconify/tools@4.0.7", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.32", "@types/tar": "^6.1.13", "axios": "^1.7.7", "cheerio": "1.0.0", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^0.5.0", "pathe": "^1.1.2", "svgo": "^3.3.2", "tar": "^6.2.1" } }, "sha512-zOJxKIfZn96ZRGGvIWzDRLD9vb2CsxjcLuM+QIdvwWbv6SWhm49gECzUnd4d2P0sq9sfodT7yCNobWK8nvavxQ=="],
|
||||
|
||||
|
@ -206,6 +231,8 @@
|
|||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="],
|
||||
|
||||
"@next/env": ["@next/env@15.1.3", "", {}, "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="],
|
||||
|
@ -310,6 +337,10 @@
|
|||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.24.0", "", { "dependencies": { "@shikijs/types": "1.24.0", "@shikijs/vscode-textmate": "^9.3.0" } }, "sha512-Eua0qNOL73Y82lGA4GF5P+G2+VXX9XnuUxkiUuwcxQPH4wom+tE39kZpBFXfUuwNYxHSkrSxpB1p4kyRW0moSg=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@1.24.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^9.3.0", "@types/hast": "^3.0.4" } }, "sha512-aptbEuq1Pk88DMlCe+FzXNnBZ17LCiLIGWAeCWhoFDzia5Q5Krx3DgnULLiouSdd6+LUM39XwXGppqYE0Ghtug=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@9.3.0", "", {}, "sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA=="],
|
||||
|
@ -340,6 +371,12 @@
|
|||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/highlight.js": ["@types/highlight.js@10.1.0", "", { "dependencies": { "highlight.js": "*" } }, "sha512-77hF2dGBsOgnvZll1vymYiNUtqJ8cJfXPD6GG/2M0aLRc29PkvB7Au6sIDjIEFcSICBhCh2+Pyq6WSRS7LUm6A=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.15", "", {}, "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
||||
|
@ -350,11 +387,11 @@
|
|||
|
||||
"@types/node": ["@types/node@22.10.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.13", "", {}, "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="],
|
||||
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.14", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg=="],
|
||||
"@types/react": ["@types/react@19.0.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.2", "", { "dependencies": { "@types/react": "^18" } }, "sha512-Fqp+rcvem9wEnGr3RY8dYNvSQ8PoLqjZ9HLgaPUOjJJD120uDyOxOjc/39M4Kddp9JQCxpGQbnhVQF0C0ncYVg=="],
|
||||
"@types/react-dom": ["@types/react-dom@19.0.3", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA=="],
|
||||
|
||||
"@types/tar": ["@types/tar@6.1.13", "", { "dependencies": { "@types/node": "*", "minipass": "^4.0.0" } }, "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw=="],
|
||||
|
||||
|
@ -392,9 +429,9 @@
|
|||
|
||||
"astro": ["astro@5.1.1", "", { "dependencies": { "@astrojs/compiler": "^2.10.3", "@astrojs/internal-helpers": "0.4.2", "@astrojs/markdown-remark": "6.0.1", "@astrojs/telemetry": "3.2.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.3", "@types/cookie": "^0.6.0", "acorn": "^8.14.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.1.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^0.7.2", "cssesc": "^3.0.0", "debug": "^4.3.7", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.5.4", "esbuild": "^0.21.5", "estree-walker": "^3.0.3", "fast-glob": "^3.3.2", "flattie": "^1.1.1", "github-slugger": "^2.0.0", "html-escaper": "^3.0.3", "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.14", "magicast": "^0.3.5", "micromatch": "^4.0.8", "mrmime": "^2.0.0", "neotraverse": "^0.6.18", "p-limit": "^6.1.0", "p-queue": "^8.0.1", "preferred-pm": "^4.0.0", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.6.3", "shiki": "^1.23.1", "tinyexec": "^0.3.1", "tsconfck": "^3.1.4", "ultrahtml": "^1.5.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.14.0", "vfile": "^6.0.3", "vite": "^6.0.5", "vitefu": "^1.0.4", "which-pm": "^3.0.0", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.1.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-prpWC2PRs4P3FKQg6gZaU+VNMqbZi5pDvORGB2nrjfRjkrvF6/l4BqhvkJ6YQ0Ohm5rIMVz8ljgaRI77mLHbwg=="],
|
||||
|
||||
"astro-expressive-code": ["astro-expressive-code@0.38.3", "", { "dependencies": { "rehype-expressive-code": "^0.38.3" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-Tvdc7RV0G92BbtyEOsfJtXU35w41CkM94fOAzxbQP67Wj5jArfserJ321FO4XA7WG9QMV0GIBmQq77NBIRDzpQ=="],
|
||||
"astro-expressive-code": ["astro-expressive-code@0.40.2", "", { "dependencies": { "rehype-expressive-code": "^0.40.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-yJMQId0yXSAbW9I6yqvJ3FcjKzJ8zRL7elbJbllkv1ZJPlsI0NI83Pxn1YL1IapEM347EvOOkSW2GL+2+NO61w=="],
|
||||
|
||||
"astro-icon": ["astro-icon@1.1.4", "", { "dependencies": { "@iconify/tools": "^4.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.30" } }, "sha512-sMLkQaevIQLv38WBzb/RDbsmxhg5+X+KcNmpTi9cE6MLurNWU1MPnlO87d9Vwg4HxTenKpDlYp81A3syXFW/gw=="],
|
||||
"astro-icon": ["astro-icon@1.1.5", "", { "dependencies": { "@iconify/tools": "^4.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.30" } }, "sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
|
@ -446,6 +483,8 @@
|
|||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chart.js": ["chart.js@4.4.7", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw=="],
|
||||
|
||||
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
|
||||
|
||||
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||
|
@ -494,6 +533,8 @@
|
|||
|
||||
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"crossws": ["crossws@0.3.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw=="],
|
||||
|
@ -502,6 +543,8 @@
|
|||
|
||||
"css-selector-parser": ["css-selector-parser@3.0.5", "", {}, "sha512-3itoDFbKUNx1eKmVpYMFyqKX04Ww9osZ+dLgrk6GEv6KMVeXUhUnp4I5X+evw+u3ZxVU6RFXSSRxlTeMh8bA+g=="],
|
||||
|
||||
"css-selector-tokenizer": ["css-selector-tokenizer@0.8.0", "", { "dependencies": { "cssesc": "^3.0.0", "fastparse": "^1.1.2" } }, "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg=="],
|
||||
|
||||
"css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
|
||||
|
||||
"css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="],
|
||||
|
@ -512,6 +555,10 @@
|
|||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"culori": ["culori@3.3.0", "", {}, "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ=="],
|
||||
|
||||
"daisyui": ["daisyui@4.12.23", "", { "dependencies": { "css-selector-tokenizer": "^0.8", "culori": "^3", "picocolors": "^1", "postcss-js": "^4" } }, "sha512-EM38duvxutJ5PD65lO/AFMpcw+9qEy6XAZrTpzp7WyaPeO/l+F/Qiq0ECHHmFNcFXh5aVoALY4MGrrxtCiaQCQ=="],
|
||||
|
||||
"debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="],
|
||||
|
@ -536,6 +583,8 @@
|
|||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dexie": ["dexie@4.0.11", "", {}, "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],
|
||||
|
@ -608,7 +657,7 @@
|
|||
|
||||
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
|
||||
"expressive-code": ["expressive-code@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3", "@expressive-code/plugin-frames": "^0.38.3", "@expressive-code/plugin-shiki": "^0.38.3", "@expressive-code/plugin-text-markers": "^0.38.3" } }, "sha512-COM04AiUotHCKJgWdn7NtW2lqu8OW8owAidMpkXt1qxrZ9Q2iC7+tok/1qIn2ocGnczvr9paIySgGnEwFeEQ8Q=="],
|
||||
"expressive-code": ["expressive-code@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2", "@expressive-code/plugin-frames": "^0.40.2", "@expressive-code/plugin-shiki": "^0.40.2", "@expressive-code/plugin-text-markers": "^0.40.2" } }, "sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
|
@ -616,6 +665,8 @@
|
|||
|
||||
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
|
||||
|
||||
"fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="],
|
||||
|
||||
"fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
@ -638,7 +689,7 @@
|
|||
|
||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||
|
||||
"framer-motion": ["framer-motion@11.15.0", "", { "dependencies": { "motion-dom": "^11.14.3", "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w=="],
|
||||
"framer-motion": ["framer-motion@12.4.4", "", { "dependencies": { "motion-dom": "^12.4.4", "motion-utils": "^12.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JWkVwbJBgVkeZHNcnk8ififgwTF+5de9wbJnTLI+g9YqaGo75Xd5uRVDm9FR8chqRDOKcXv/71f40CGescYVmg=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
|
@ -664,6 +715,8 @@
|
|||
|
||||
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
|
||||
|
||||
"goober": ["goober@2.1.16", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@1.13.0", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": ">=0.2.0 <0.4.0", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "ohash": "^1.1.4", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3", "unenv": "^1.10.0" } }, "sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg=="],
|
||||
|
@ -700,6 +753,8 @@
|
|||
|
||||
"hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
@ -716,6 +771,8 @@
|
|||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
@ -758,6 +815,8 @@
|
|||
|
||||
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
@ -772,10 +831,14 @@
|
|||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
@ -788,9 +851,9 @@
|
|||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
|
@ -934,9 +997,9 @@
|
|||
|
||||
"motion": ["motion@11.15.0", "", { "dependencies": { "framer-motion": "^11.15.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw=="],
|
||||
|
||||
"motion-dom": ["motion-dom@11.14.3", "", {}, "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="],
|
||||
"motion-dom": ["motion-dom@12.4.4", "", { "dependencies": { "motion-utils": "^12.0.0" } }, "sha512-D8Kjp8oqUNqxoAVmIlOH+YCMov/4koBAmG4OJs0VWfh18xkQEIsx9+S7yrXyx0XaMBEPtre6e9LiSW2Zs7vIhA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@11.14.3", "", {}, "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="],
|
||||
"motion-utils": ["motion-utils@12.0.0", "", {}, "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.0", "", {}, "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="],
|
||||
|
||||
|
@ -1000,6 +1063,8 @@
|
|||
|
||||
"package-manager-detector": ["package-manager-detector@0.2.6", "", {}, "sha512-9vPH3qooBlYRJdmdYP00nvjZOulm40r5dhtal8st18ctf+6S1k7pi5yIHLvI4w5D70x0Y+xdVD9qITH0QO/A8A=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.1", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w=="],
|
||||
|
||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||
|
@ -1034,6 +1099,8 @@
|
|||
|
||||
"pkg-types": ["pkg-types@1.2.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.2", "pathe": "^1.1.2" } }, "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw=="],
|
||||
|
||||
"pocketbase": ["pocketbase@0.25.1", "", {}, "sha512-2IH0KLI/qMNR/E17C7BGWX2FxW7Tead+igLHOWZ45P56v/NyVT18Jnmddeft+3qWWGL1Hog2F8bc4orWV/+Fcg=="],
|
||||
|
||||
"postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
@ -1056,6 +1123,8 @@
|
|||
|
||||
"prismjs": ["prismjs@1.29.0", "", {}, "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
||||
|
@ -1070,9 +1139,13 @@
|
|||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
"react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="],
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
"react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="],
|
||||
|
||||
"react-hot-toast": ["react-hot-toast@2.5.2", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw=="],
|
||||
|
||||
"react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="],
|
||||
|
||||
|
@ -1080,6 +1153,8 @@
|
|||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
|
||||
|
@ -1098,7 +1173,7 @@
|
|||
|
||||
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
|
||||
|
||||
"rehype-expressive-code": ["rehype-expressive-code@0.38.3", "", { "dependencies": { "expressive-code": "^0.38.3" } }, "sha512-RYSSDkMBikoTbycZPkcWp6ELneANT4eTpND1DSRJ6nI2eVFUwTBDCvE2vO6jOOTaavwnPiydi4i/87NRyjpdOA=="],
|
||||
"rehype-expressive-code": ["rehype-expressive-code@0.40.2", "", { "dependencies": { "expressive-code": "^0.40.2" } }, "sha512-+kn+AMGCrGzvtH8Q5lC6Y5lnmTV/r33fdmi5QU/IH1KPHKobKr5UnLwJuqHv5jBTSN/0v2wLDS7RTM73FVzqmQ=="],
|
||||
|
||||
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
|
||||
|
||||
|
@ -1138,11 +1213,13 @@
|
|||
|
||||
"s.color": ["s.color@0.0.15", "", {}, "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
"scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
|
||||
|
||||
"semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
|
||||
|
@ -1150,6 +1227,8 @@
|
|||
|
||||
"server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
@ -1184,6 +1263,8 @@
|
|||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
@ -1290,7 +1371,7 @@
|
|||
|
||||
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
|
||||
|
||||
"vite": ["vite@6.0.5", "", { "dependencies": { "esbuild": "0.24.0", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g=="],
|
||||
"vite": ["vite@6.0.11", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.0.4", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow=="],
|
||||
|
||||
|
@ -1344,6 +1425,8 @@
|
|||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
@ -1352,10 +1435,16 @@
|
|||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"astro/vite": ["vite@6.0.5", "", { "dependencies": { "esbuild": "0.24.0", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
|
||||
|
@ -1378,6 +1467,8 @@
|
|||
|
||||
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@11.15.0", "", { "dependencies": { "motion-dom": "^11.14.3", "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
@ -1404,7 +1495,7 @@
|
|||
|
||||
"tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.24.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.0", "@esbuild/android-arm": "0.24.0", "@esbuild/android-arm64": "0.24.0", "@esbuild/android-x64": "0.24.0", "@esbuild/darwin-arm64": "0.24.0", "@esbuild/darwin-x64": "0.24.0", "@esbuild/freebsd-arm64": "0.24.0", "@esbuild/freebsd-x64": "0.24.0", "@esbuild/linux-arm": "0.24.0", "@esbuild/linux-arm64": "0.24.0", "@esbuild/linux-ia32": "0.24.0", "@esbuild/linux-loong64": "0.24.0", "@esbuild/linux-mips64el": "0.24.0", "@esbuild/linux-ppc64": "0.24.0", "@esbuild/linux-riscv64": "0.24.0", "@esbuild/linux-s390x": "0.24.0", "@esbuild/linux-x64": "0.24.0", "@esbuild/netbsd-x64": "0.24.0", "@esbuild/openbsd-arm64": "0.24.0", "@esbuild/openbsd-x64": "0.24.0", "@esbuild/sunos-x64": "0.24.0", "@esbuild/win32-arm64": "0.24.0", "@esbuild/win32-ia32": "0.24.0", "@esbuild/win32-x64": "0.24.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ=="],
|
||||
"vite/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
|
@ -1414,70 +1505,146 @@
|
|||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@shikijs/langs/@shikijs/types/@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="],
|
||||
|
||||
"@shikijs/themes/@shikijs/types/@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"astro/vite/esbuild": ["esbuild@0.24.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.0", "@esbuild/android-arm": "0.24.0", "@esbuild/android-arm64": "0.24.0", "@esbuild/android-x64": "0.24.0", "@esbuild/darwin-arm64": "0.24.0", "@esbuild/darwin-x64": "0.24.0", "@esbuild/freebsd-arm64": "0.24.0", "@esbuild/freebsd-x64": "0.24.0", "@esbuild/linux-arm": "0.24.0", "@esbuild/linux-arm64": "0.24.0", "@esbuild/linux-ia32": "0.24.0", "@esbuild/linux-loong64": "0.24.0", "@esbuild/linux-mips64el": "0.24.0", "@esbuild/linux-ppc64": "0.24.0", "@esbuild/linux-riscv64": "0.24.0", "@esbuild/linux-s390x": "0.24.0", "@esbuild/linux-x64": "0.24.0", "@esbuild/netbsd-x64": "0.24.0", "@esbuild/openbsd-arm64": "0.24.0", "@esbuild/openbsd-x64": "0.24.0", "@esbuild/sunos-x64": "0.24.0", "@esbuild/win32-arm64": "0.24.0", "@esbuild/win32-ia32": "0.24.0", "@esbuild/win32-x64": "0.24.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
||||
"hast-util-to-estree/style-to-object/inline-style-parser": ["inline-style-parser@0.1.1", "", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="],
|
||||
|
||||
"load-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@11.14.3", "", {}, "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@11.14.3", "", {}, "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw=="],
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew=="],
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w=="],
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.0", "", { "os": "android", "cpu": "x64" }, "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ=="],
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw=="],
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA=="],
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA=="],
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ=="],
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw=="],
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g=="],
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA=="],
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g=="],
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA=="],
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ=="],
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw=="],
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g=="],
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA=="],
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.0", "", { "os": "none", "cpu": "x64" }, "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg=="],
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q=="],
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA=="],
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA=="],
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw=="],
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA=="],
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/core/hast-util-to-html": ["hast-util-to-html@9.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.0", "", { "os": "android", "cpu": "x64" }, "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.0", "", { "os": "none", "cpu": "x64" }, "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw=="],
|
||||
|
||||
"astro/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
|
||||
}
|
||||
}
|
||||
|
|
207
notes.md
Normal file
207
notes.md
Normal file
|
@ -0,0 +1,207 @@
|
|||
# Event Request Form:
|
||||
|
||||
Prior Notes:
|
||||
Whether you are or aren't requesting AS Funding or physical flyers, you MUST submit this request form at least 6 weeks before your event. We can create both digital and physical flyers for your event, advertise your event on social media (Facebook, Instagram, Discord), advertise your event on newsletters (IEEE, ECE, IDEA), take pictures at your event and edit them (we highly recommend this!), and livestream your event on Facebook. After submitting this form, please @-pr and/or @-coordinators in #-events on Slack.
|
||||
|
||||
Please note that if you submit your request late, we may deny your request.
|
||||
|
||||
Also note that if you're requesting AS Funding, please don't forget to check the Funding Guide or the Google Calendar for the funding request deadlines.
|
||||
|
||||
### Do you need graphics from our design team?
|
||||
|
||||
Possible Answers:
|
||||
|
||||
- Yes (Go to Section 2)
|
||||
- No (Go to Section 3)
|
||||
|
||||
## Section 2: PR
|
||||
|
||||
If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form.
|
||||
|
||||
### Type of material needed?
|
||||
|
||||
Feel free to add what else you need as well as where else you want your event advertised (if needed) in the other option.
|
||||
|
||||
- Digital flyer (with social media advertising: Facebook, Instagram, Discord)
|
||||
- Digital flyer (with NO social media advertising)
|
||||
- Physical flyer (with advertising)
|
||||
- Physical flyer (with NO advertising)
|
||||
- Newsletter (IEEE, ECE, IDEA)
|
||||
- Other
|
||||
|
||||
### If you chose to have your flyer advertised, when do you need us to start advertising?
|
||||
|
||||
- DATETIME
|
||||
|
||||
### Logos Required?
|
||||
|
||||
[ ] IEEE
|
||||
[ ] AS (required if funded by AS)
|
||||
[ ] HKN
|
||||
[ ] TESC
|
||||
[ ] PIB
|
||||
[ ] TNT
|
||||
[ ] SWE
|
||||
[ ] OTHER (please upload transparent logo files to the next question)
|
||||
|
||||
### Please share your logo files here:
|
||||
|
||||
FILEUPLOAD
|
||||
|
||||
### What format do you need it to be in?
|
||||
|
||||
- PNG
|
||||
- PDF
|
||||
- JPG
|
||||
- DOES NOT MATTER
|
||||
|
||||
### Any other specifications and requests (color scheme, overall design, etc)? Feel free to link us to any examples you want us to consider in designing your promotional material. (i.e. past FB covers from events, etc)
|
||||
|
||||
TEXTBOX
|
||||
|
||||
### Photography Needed?
|
||||
|
||||
- Yes
|
||||
- No
|
||||
|
||||
## Section 3: Event Details
|
||||
|
||||
Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you.
|
||||
|
||||
### Event Name:
|
||||
|
||||
TEXTBOX
|
||||
|
||||
### Event Description:
|
||||
|
||||
TEXTBOX
|
||||
|
||||
### Event Start Date:
|
||||
|
||||
DATETIME
|
||||
|
||||
### Event End Date:
|
||||
|
||||
DATETIME
|
||||
|
||||
### Event Location:
|
||||
|
||||
TEXTBOX
|
||||
|
||||
### Do you/will you have a room booking for this event?
|
||||
|
||||
- Yes
|
||||
- No
|
||||
|
||||
## Section 4: TAP Form
|
||||
|
||||
Please ensure you have ALL sections completed, if something is not available, let the coordinators know and be advised on how to proceed.
|
||||
|
||||
### Expected attendance? Include a number NOT a range please.
|
||||
|
||||
(
|
||||
|
||||
PROGRAMMING FUNDS
|
||||
EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.
|
||||
ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.
|
||||
EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT
|
||||
|
||||
)
|
||||
|
||||
NUMBER
|
||||
|
||||
### Upload your room booking here. Ensure your file size fits within this size. Please use the following naming format: EventName_LocationOfEvent_DateOfEvent
|
||||
|
||||
i.e. ArduinoWorkshop_Qualcomm_01/06/2025
|
||||
FILEUPLOAD
|
||||
|
||||
### Do you need AS Funding? (food/flyers)
|
||||
|
||||
- Yes
|
||||
- No
|
||||
|
||||
### Will you be serving food/drinks at your event?
|
||||
|
||||
- Yes
|
||||
- No
|
||||
|
||||
## Section 5: AS Funding
|
||||
|
||||
Please make sure the restaurant is a valid AS Funding food vendor! An invoice can be an unofficial receipt. Just make sure that the restaurant name and location, desired pickup or delivery date and time, all the items ordered plus their prices, discount/fees/tax/tip, and total are on the invoice! We don't recommend paying out of pocket because reimbursements can be a hassle when you're not a Principal Member.
|
||||
|
||||
### Please put your invoice information in the following format: quantity + item name + unit cost + discounts/fees/tax/tip + total + vendor.
|
||||
|
||||
(e.g. 3-Chicken Cutlet with Gravy Regular, white rice, and mac salad x14.95 each | 3-Garlic Shrimp Regular with white rice and mac salad x15.45 each | 10-Spam Musubi x2.95 each | Tax = 9.35 | Tip = 18.10 | Total = 148.15 from L&L Hawaiian Barbeque)
|
||||
TEXTBOX
|
||||
|
||||
### Be sure to share a screenshot of your order/your official food invoice here. Official food invoices will be required 2 weeks before the start of your event. Please use the following naming format: EventName_OrderLocation_DateOfEvent
|
||||
|
||||
i.e. QPWorkathon#1_PapaJohns_01/06/2025
|
||||
FILEUPLOAD
|
||||
|
||||
Pocketbase Collection Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"collectionId": "pbc_1475615553",
|
||||
"collectionName": "event_request",
|
||||
"id": "test",
|
||||
"requested_user": "RELATION_RECORD_ID",
|
||||
"name": "test",
|
||||
"location": "test",
|
||||
"start_date_time": "2022-01-01 10:00:00.123Z",
|
||||
"end_date_time": "2022-01-01 10:00:00.123Z",
|
||||
"flyers_needed": true,
|
||||
"flyer_type": [
|
||||
"digital_with_social",
|
||||
"digital_no_social",
|
||||
"physical_with_advertising",
|
||||
"physical_no_advertising",
|
||||
"newsletter",
|
||||
"other"
|
||||
],
|
||||
"other_flyer_type": "test",
|
||||
"flyer_advertising_start_date": "test",
|
||||
"flyer_additional_requests": "test",
|
||||
"photography_needed": true,
|
||||
"required_logos": ["IEEE", "AS", "HKN", "TESC", "PIB", "TNT", "SWE", "OTHER"],
|
||||
"other_logos": ["filename.jpg"],
|
||||
"advertising_format": "pdf",
|
||||
"will_or_have_room_booking": true,
|
||||
"expected_attendance": 123,
|
||||
"room_booking": "filename.jpg",
|
||||
"as_funding_required": true,
|
||||
"food_drinks_being_served": true,
|
||||
"itemized_invoice": "JSON",
|
||||
"invoice": "filename.jpg",
|
||||
"created": "2022-01-01 10:00:00.123Z",
|
||||
"updated": "2022-01-01 10:00:00.123Z"
|
||||
}
|
||||
```
|
||||
|
||||
Possible Flyer Types:
|
||||
|
||||
- digital_with_social
|
||||
- digital_no_social
|
||||
- physical_with_advertising
|
||||
- physical_no_advertising
|
||||
- newsletter
|
||||
- other
|
||||
|
||||
Possible Logos:
|
||||
|
||||
[ ] IEEE
|
||||
[ ] AS
|
||||
[ ] HKN
|
||||
[ ] TESC
|
||||
[ ] PIB
|
||||
[ ] TNT
|
||||
[ ] SWE
|
||||
[ ] OTHER
|
||||
|
||||
Possible Advertising Formats:
|
||||
|
||||
- pdf
|
||||
- jpeg
|
||||
- png
|
||||
- does_not_matter
|
9385
package-lock.json
generated
9385
package-lock.json
generated
File diff suppressed because it is too large
Load diff
33
package.json
33
package.json
|
@ -12,21 +12,40 @@
|
|||
"dependencies": {
|
||||
"@astrojs/mdx": "4.0.3",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "4.1.2",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/tailwind": "5.1.4",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@iconify-json/heroicons": "^1.2.2",
|
||||
"@iconify/react": "^5.2.0",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"astro": "5.1.1",
|
||||
"astro-expressive-code": "^0.38.3",
|
||||
"astro-icon": "^1.1.4",
|
||||
"astro-expressive-code": "^0.40.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"chart.js": "^4.4.7",
|
||||
"dexie": "^4.0.11",
|
||||
"framer-motion": "^12.4.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"pocketbase": "^0.25.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-icons": "^5.4.0",
|
||||
"rehype-expressive-code": "^0.40.2",
|
||||
"tailwindcss": "^3.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"daisyui": "^4.12.23",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"tailwindcss-animated": "^1.1.2",
|
||||
|
|
3
src/components/dashboard/AdminDashboard.astro
Normal file
3
src/components/dashboard/AdminDashboard.astro
Normal file
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
|
||||
---
|
547
src/components/dashboard/EventsSection.astro
Normal file
547
src/components/dashboard/EventsSection.astro
Normal file
|
@ -0,0 +1,547 @@
|
|||
---
|
||||
import FilePreview from "./universal/FilePreview";
|
||||
import EventCheckIn from "./EventsSection/EventCheckIn";
|
||||
import EventLoad from "./EventsSection/EventLoad";
|
||||
---
|
||||
|
||||
<div id="" class="">
|
||||
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
|
||||
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
|
||||
<p class="opacity-70 text-sm sm:text-base">
|
||||
View and manage your IEEE UCSD events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6 px-4 sm:px-6"
|
||||
>
|
||||
<!-- Event Check-in Card -->
|
||||
<div class="w-full">
|
||||
<EventCheckIn client:load />
|
||||
</div>
|
||||
|
||||
<!-- Event Registration Card -->
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 opacity-50 cursor-not-allowed relative group h-full"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-base-100 opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10"
|
||||
>
|
||||
<span
|
||||
class="text-base-content font-medium text-sm sm:text-base"
|
||||
>Coming Soon</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
|
||||
Event Registration
|
||||
</h3>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm sm:text-base"
|
||||
>Select an event to register</span
|
||||
>
|
||||
</label>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
class="select select-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
|
||||
disabled
|
||||
>
|
||||
<option disabled selected>Pick an event</option>
|
||||
<option
|
||||
>Technical Workshop - Web Development</option
|
||||
>
|
||||
<option
|
||||
>Professional Development Workshop</option
|
||||
>
|
||||
<option>Social Event - Game Night</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-primary text-sm sm:text-base h-10 min-h-[2.5rem] w-full sm:w-[100px]"
|
||||
disabled>Register</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventLoad client:load />
|
||||
</div>
|
||||
|
||||
<!-- Event Details Modal -->
|
||||
<dialog id="eventDetailsModal" class="modal">
|
||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
|
||||
Event Files
|
||||
</h3>
|
||||
<button
|
||||
id="downloadAllBtn"
|
||||
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
|
||||
onclick="window.downloadAllFiles()"
|
||||
>
|
||||
<iconify-icon
|
||||
icon="heroicons:arrow-down-tray-20-solid"
|
||||
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
|
||||
Download All
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
|
||||
onclick="window.closeEventDetailsModal()"
|
||||
>
|
||||
<iconify-icon
|
||||
icon="heroicons:x-mark"
|
||||
className="h-4 w-4 sm:h-6 sm:w-6"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="filesContent" class="space-y-3 sm:space-y-4">
|
||||
<!-- Files list will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.closeEventDetailsModal()">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Universal File Preview Modal -->
|
||||
<dialog id="filePreviewModal" class="modal">
|
||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-xs sm:text-sm"
|
||||
onclick="window.closeFilePreviewEvents()">Close</button
|
||||
>
|
||||
<h3
|
||||
class="font-bold text-base sm:text-lg truncate"
|
||||
id="previewFileName"
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" id="previewContainer">
|
||||
<div
|
||||
id="previewLoadingSpinner"
|
||||
class="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden"
|
||||
>
|
||||
<span class="loading loading-spinner loading-md sm:loading-lg"
|
||||
></span>
|
||||
</div>
|
||||
<div id="previewContent" class="w-full">
|
||||
<FilePreview client:load isModal={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.closeFilePreviewEvents()">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
import { toast } from "react-hot-toast";
|
||||
import JSZip from "jszip";
|
||||
|
||||
// Add styles to the document
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
/* Custom styles for the event details modal */
|
||||
.event-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.event-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove custom toast styles since we're using react-hot-toast */
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Add helper functions for file preview
|
||||
function getFileType(filename: string): string {
|
||||
const extension = filename.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
pdf: "application/pdf",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
mp4: "video/mp4",
|
||||
mp3: "audio/mpeg",
|
||||
txt: "text/plain",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
json: "application/json",
|
||||
};
|
||||
|
||||
return mimeTypes[extension || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
// Universal file preview function for events section
|
||||
window.previewFileEvents = function (url: string, filename: string) {
|
||||
console.log("previewFileEvents called with:", { url, filename });
|
||||
console.log("URL type:", typeof url, "URL length:", url?.length || 0);
|
||||
console.log(
|
||||
"Filename type:",
|
||||
typeof filename,
|
||||
"Filename length:",
|
||||
filename?.length || 0
|
||||
);
|
||||
|
||||
// Validate inputs
|
||||
if (!url || typeof url !== "string") {
|
||||
console.error("Invalid URL provided to previewFileEvents:", url);
|
||||
toast.error("Cannot preview file: Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filename || typeof filename !== "string") {
|
||||
console.error(
|
||||
"Invalid filename provided to previewFileEvents:",
|
||||
filename
|
||||
);
|
||||
toast.error("Cannot preview file: Invalid filename");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL is properly formatted
|
||||
if (!url.startsWith("http")) {
|
||||
console.warn(
|
||||
"URL doesn't start with http, attempting to fix:",
|
||||
url
|
||||
);
|
||||
if (url.startsWith("/")) {
|
||||
url = `https://pocketbase.ieeeucsd.org${url}`;
|
||||
} else {
|
||||
url = `https://pocketbase.ieeeucsd.org/${url}`;
|
||||
}
|
||||
console.log("Fixed URL:", url);
|
||||
}
|
||||
|
||||
const modal = document.getElementById(
|
||||
"filePreviewModal"
|
||||
) as HTMLDialogElement;
|
||||
const previewFileName = document.getElementById("previewFileName");
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||
|
||||
if (modal && previewFileName && previewContent) {
|
||||
console.log("Found all required elements");
|
||||
|
||||
// Show loading spinner
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Update the filename display
|
||||
previewFileName.textContent = filename;
|
||||
|
||||
// Show the modal
|
||||
modal.showModal();
|
||||
|
||||
// Test the URL with a fetch before dispatching the event
|
||||
fetch(url, { method: "HEAD" })
|
||||
.then((response) => {
|
||||
console.log(
|
||||
"URL test response:",
|
||||
response.status,
|
||||
response.ok
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.warn("URL might not be accessible:", url);
|
||||
toast(
|
||||
"File might not be accessible. Attempting to preview anyway.",
|
||||
{
|
||||
icon: "⚠️",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#FFC107",
|
||||
color: "#000",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error testing URL:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Dispatch state change event to update the FilePreview component
|
||||
console.log(
|
||||
"Dispatching filePreviewStateChange event with:",
|
||||
{ url, filename }
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("filePreviewStateChange", {
|
||||
detail: { url, filename },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Hide loading spinner after a short delay
|
||||
setTimeout(() => {
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.add("hidden");
|
||||
}
|
||||
}, 1000); // Increased delay to allow for URL testing
|
||||
} else {
|
||||
console.error("Missing required elements for file preview");
|
||||
toast.error("Could not initialize file preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Close file preview for events section
|
||||
window.closeFilePreviewEvents = function () {
|
||||
console.log("closeFilePreviewEvents called");
|
||||
const modal = document.getElementById(
|
||||
"filePreviewModal"
|
||||
) as HTMLDialogElement;
|
||||
const previewFileName = document.getElementById("previewFileName");
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (modal && previewFileName && previewContent) {
|
||||
console.log("Resetting preview and closing modal");
|
||||
|
||||
// First reset the preview state by dispatching an event with empty values
|
||||
// This ensures the FilePreview component clears its internal state
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("filePreviewStateChange", {
|
||||
detail: { url: "", filename: "" },
|
||||
})
|
||||
);
|
||||
|
||||
// Reset the UI
|
||||
previewFileName.textContent = "";
|
||||
|
||||
// Close the modal
|
||||
modal.close();
|
||||
|
||||
console.log("File preview modal closed and state reset");
|
||||
} else {
|
||||
console.error("Could not find elements to close file preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Update the showFilePreview function for events section
|
||||
window.showFilePreviewEvents = function (file: {
|
||||
url: string;
|
||||
name: string;
|
||||
}) {
|
||||
console.log("showFilePreviewEvents called with:", file);
|
||||
if (!file || !file.url || !file.name) {
|
||||
console.error("Invalid file data:", file);
|
||||
toast.error("Could not preview file: missing file information");
|
||||
return;
|
||||
}
|
||||
window.previewFileEvents(file.url, file.name);
|
||||
};
|
||||
|
||||
// Update the openDetailsModal function to use the events-specific preview
|
||||
window.openDetailsModal = function (event: any) {
|
||||
const modal = document.getElementById(
|
||||
"eventDetailsModal"
|
||||
) as HTMLDialogElement;
|
||||
const filesContent = document.getElementById(
|
||||
"filesContent"
|
||||
) as HTMLDivElement;
|
||||
|
||||
// Check if event has ended
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
|
||||
if (eventEndDate > now) {
|
||||
toast("Files are only available after the event has ended.", {
|
||||
icon: "⚠️",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#FFC107",
|
||||
color: "#000",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
window.currentEventId = event.id;
|
||||
if (filesContent) filesContent.classList.remove("hidden");
|
||||
|
||||
// Populate files content
|
||||
if (
|
||||
event.files &&
|
||||
Array.isArray(event.files) &&
|
||||
event.files.length > 0
|
||||
) {
|
||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||
const collectionId = "events";
|
||||
const recordId = event.id;
|
||||
|
||||
filesContent.innerHTML = `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${event.files
|
||||
.map((file: string) => {
|
||||
// Ensure the file URL is properly formatted
|
||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
|
||||
const fileType = getFileType(file);
|
||||
// Properly escape the data for the onclick handler
|
||||
const fileData = {
|
||||
url: fileUrl,
|
||||
name: file,
|
||||
};
|
||||
return `
|
||||
<tr>
|
||||
<td>${file}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-ghost btn-sm" onclick="window.showFilePreviewEvents({'url': '${fileUrl}', 'name': '${file}'})">
|
||||
<iconify-icon icon="heroicons:document" className="h-4 w-4" />
|
||||
</button>
|
||||
<a href="${fileUrl}" download="${file}" class="btn btn-ghost btn-sm">
|
||||
<iconify-icon icon="heroicons:arrow-down-tray-20-solid" className="h-4 w-4" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
filesContent.innerHTML = `
|
||||
<div class="text-center py-8 text-base-content/70">
|
||||
<iconify-icon icon="heroicons:document-duplicate" className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No files attached to this event</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.showModal();
|
||||
};
|
||||
|
||||
// Add downloadAllFiles function
|
||||
window.downloadAllFiles = async function () {
|
||||
const downloadBtn = document.getElementById(
|
||||
"downloadAllBtn"
|
||||
) as HTMLButtonElement;
|
||||
if (!downloadBtn) return;
|
||||
const originalBtnContent = downloadBtn.innerHTML;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
downloadBtn.innerHTML =
|
||||
'<span class="loading loading-spinner loading-xs"></span> Preparing...';
|
||||
downloadBtn.disabled = true;
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Get current event files
|
||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||
const collectionId = "events";
|
||||
const recordId = window.currentEventId;
|
||||
|
||||
// Get the current event from the window object
|
||||
const eventDataId = `event_${window.currentEventId}`;
|
||||
const event = window[eventDataId];
|
||||
|
||||
if (!event || !event.files || event.files.length === 0) {
|
||||
throw new Error("No files available to download");
|
||||
}
|
||||
|
||||
// Download each file and add to zip
|
||||
const filePromises = event.files.map(async (filename: string) => {
|
||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filename}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
zip.file(filename, blob);
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
|
||||
// Generate and download zip
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = `${event.event_name}_files.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
// Show success message
|
||||
toast.success("Files downloaded successfully!");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download files:", error);
|
||||
toast.error(
|
||||
error?.message || "Failed to download files. Please try again."
|
||||
);
|
||||
} finally {
|
||||
// Reset button state
|
||||
downloadBtn.innerHTML = originalBtnContent;
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Close event details modal
|
||||
window.closeEventDetailsModal = function () {
|
||||
const modal = document.getElementById(
|
||||
"eventDetailsModal"
|
||||
) as HTMLDialogElement;
|
||||
const filesContent = document.getElementById("filesContent");
|
||||
|
||||
if (modal) {
|
||||
// Reset the files content
|
||||
if (filesContent) {
|
||||
filesContent.innerHTML = "";
|
||||
}
|
||||
|
||||
// Reset any other state if needed
|
||||
window.currentEventId = "";
|
||||
|
||||
// Close the modal
|
||||
modal.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Make helper functions available globally
|
||||
window.showFilePreview = window.showFilePreviewEvents;
|
||||
window.handlePreviewError = function () {
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
if (previewContent) {
|
||||
previewContent.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<Icon icon="heroicons:x-circle" className="h-6 w-6" />
|
||||
<span>Failed to load file preview</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
</script>
|
525
src/components/dashboard/EventsSection/EventCheckIn.tsx
Normal file
525
src/components/dashboard/EventsSection/EventCheckIn.tsx
Normal file
|
@ -0,0 +1,525 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { Update } from "../../../scripts/pocketbase/Update";
|
||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import { Icon } from "@iconify/react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Event, EventAttendee } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended Event interface with additional properties needed for this component
|
||||
interface ExtendedEvent extends Event {
|
||||
description?: string; // This component uses 'description' but schema has 'event_description'
|
||||
}
|
||||
|
||||
// Note: Date conversion is now handled automatically by the Get and Update classes.
|
||||
// When fetching events, UTC dates are converted to local time.
|
||||
// When saving events, local dates are converted back to UTC.
|
||||
|
||||
const EventCheckIn = () => {
|
||||
const [currentCheckInEvent, setCurrentCheckInEvent] = useState<Event | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [foodInput, setFoodInput] = useState("");
|
||||
|
||||
// SECURITY FIX: Purge event codes when component mounts
|
||||
useEffect(() => {
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
dataSync.purgeEventCodes().catch(err => {
|
||||
console.error("Error purging event codes:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleEventCheckIn(eventCode: string): Promise<void> {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
"Check-in failed: User not logged in"
|
||||
);
|
||||
toast.error("You must be logged in to check in to events");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the check-in attempt
|
||||
await logger.send(
|
||||
"info",
|
||||
"event_check_in",
|
||||
`Attempting to check in with code: ${eventCode}`
|
||||
);
|
||||
|
||||
// Validate event code
|
||||
if (!eventCode || eventCode.trim() === "") {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
"Check-in failed: Empty event code"
|
||||
);
|
||||
toast.error("Please enter an event code");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get event by code
|
||||
const events = await get.getList<Event>(
|
||||
Collections.EVENTS,
|
||||
1,
|
||||
1,
|
||||
`event_code="${eventCode}"`
|
||||
);
|
||||
|
||||
if (events.totalItems === 0) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Invalid event code: ${eventCode}`
|
||||
);
|
||||
toast.error("Invalid event code. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const event = events.items[0];
|
||||
|
||||
// Check if event is published
|
||||
if (!event.published) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Event not published: ${event.event_name}`
|
||||
);
|
||||
toast.error("This event is not currently available for check-in");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the event is active (has started and hasn't ended yet)
|
||||
const currentTime = new Date();
|
||||
const eventStartDate = new Date(event.start_date);
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
|
||||
if (currentTime < eventStartDate) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Event has not started yet: ${event.event_name}`
|
||||
);
|
||||
toast.error(`This event hasn't started yet. It begins on ${eventStartDate.toLocaleDateString()} at ${eventStartDate.toLocaleTimeString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTime > eventEndDate) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Event has already ended: ${event.event_name}`
|
||||
);
|
||||
toast.error("This event has already ended");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is already checked in - IMPROVED VALIDATION
|
||||
const attendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
50, // Increased limit to ensure we catch all possible duplicates
|
||||
`user="${currentUser.id}" && event="${event.id}"`
|
||||
);
|
||||
|
||||
if (attendees.totalItems > 0) {
|
||||
const lastCheckIn = new Date(attendees.items[0].time_checked_in);
|
||||
const timeSinceLastCheckIn = Date.now() - lastCheckIn.getTime();
|
||||
const hoursAgo = Math.round(timeSinceLastCheckIn / (1000 * 60 * 60));
|
||||
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Already checked in to event: ${event.event_name} (${hoursAgo} hours ago)`
|
||||
);
|
||||
toast.error(`You have already checked in to this event (${hoursAgo} hours ago)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set current event for check-in
|
||||
setCurrentCheckInEvent(event);
|
||||
|
||||
// Log successful event lookup
|
||||
await logger.send(
|
||||
"info",
|
||||
"event_check_in",
|
||||
`Found event for check-in: ${event.event_name}`
|
||||
);
|
||||
|
||||
// Store event code in local storage for offline check-in
|
||||
await dataSync.storeEventCode(eventCode);
|
||||
|
||||
// Show event details toast only for non-food events
|
||||
// For food events, we'll show the toast after food selection
|
||||
if (!event.has_food) {
|
||||
toast.success(
|
||||
<div>
|
||||
<strong>Event found!</strong>
|
||||
<p className="text-sm mt-1">{event.event_name}</p>
|
||||
<p className="text-xs mt-1">
|
||||
{event.points_to_reward > 0 ? `${event.points_to_reward} points` : "No points"}
|
||||
</p>
|
||||
</div>,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
// If event has food, show food selection modal
|
||||
if (event.has_food) {
|
||||
// Show food-specific toast
|
||||
toast.success(
|
||||
<div>
|
||||
<strong>Event with food found!</strong>
|
||||
<p className="text-sm mt-1">{event.event_name}</p>
|
||||
<p className="text-xs mt-1">Please select your food preference</p>
|
||||
</div>,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
if (modal) modal.showModal();
|
||||
} else {
|
||||
// If no food, show confirmation modal
|
||||
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
if (modal) modal.showModal();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error checking in:", error);
|
||||
toast.error(error.message || "An error occurred during check-in");
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCheckIn(event: Event, foodSelection: string | null): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const get = Get.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
const userId = currentUser.id;
|
||||
const eventId = event.id;
|
||||
|
||||
// Double-check for existing check-ins with improved validation
|
||||
const existingAttendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
50, // Increased limit to ensure we catch all possible duplicates
|
||||
`user="${userId}" && event="${eventId}"`
|
||||
);
|
||||
|
||||
if (existingAttendees.totalItems > 0) {
|
||||
const lastCheckIn = new Date(existingAttendees.items[0].time_checked_in);
|
||||
const timeSinceLastCheckIn = Date.now() - lastCheckIn.getTime();
|
||||
const hoursAgo = Math.round(timeSinceLastCheckIn / (1000 * 60 * 60));
|
||||
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Already checked in to event: ${event.event_name} (${hoursAgo} hours ago)`
|
||||
);
|
||||
throw new Error(`You have already checked in to this event (${hoursAgo} hours ago)`);
|
||||
}
|
||||
|
||||
// Create new attendee record with transaction to prevent race conditions
|
||||
const attendeeData = {
|
||||
user: userId,
|
||||
event: eventId,
|
||||
food_ate: foodSelection || "",
|
||||
time_checked_in: new Date().toISOString(),
|
||||
points_earned: event.points_to_reward || 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Create the attendee record in PocketBase
|
||||
const newAttendee = await update.create(Collections.EVENT_ATTENDEES, attendeeData);
|
||||
|
||||
console.log("Successfully created attendance record");
|
||||
|
||||
// Update user's total points
|
||||
// First, get all the user's attendance records to calculate total points
|
||||
const userAttendance = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
1000,
|
||||
`user="${userId}"`
|
||||
);
|
||||
|
||||
// Calculate total points
|
||||
let totalPoints = 0;
|
||||
userAttendance.items.forEach(attendee => {
|
||||
totalPoints += attendee.points_earned || 0;
|
||||
});
|
||||
|
||||
// Log the points update
|
||||
console.log(`Updating user points to: ${totalPoints}`);
|
||||
|
||||
// Update the user record with the new total points
|
||||
await update.updateFields(Collections.USERS, userId, {
|
||||
points: totalPoints
|
||||
});
|
||||
|
||||
// Ensure local data is in sync with backend
|
||||
// First sync the new attendance record
|
||||
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
||||
|
||||
// Then sync the updated user data to ensure points are correctly reflected locally
|
||||
await dataSync.syncCollection(Collections.USERS);
|
||||
|
||||
// Clear event code from local storage
|
||||
await dataSync.clearEventCode();
|
||||
|
||||
// Log successful check-in
|
||||
await logger.send(
|
||||
"info",
|
||||
"event_check_in",
|
||||
`Successfully checked in to event: ${event.event_name}`
|
||||
);
|
||||
|
||||
// Show success message with event name and points
|
||||
const pointsMessage = event.points_to_reward > 0
|
||||
? ` (+${event.points_to_reward} points!)`
|
||||
: "";
|
||||
toast.success(`Successfully checked in to ${event.event_name}${pointsMessage}`);
|
||||
|
||||
// Close any open modals
|
||||
const foodModal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
if (foodModal) foodModal.close();
|
||||
|
||||
const confirmModal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
if (confirmModal) confirmModal.close();
|
||||
|
||||
setCurrentCheckInEvent(null);
|
||||
setFoodInput("");
|
||||
} catch (createError: any) {
|
||||
console.error("Error creating attendance record:", createError);
|
||||
|
||||
// Check if this is a duplicate record error (race condition handling)
|
||||
if (createError.status === 400 && createError.data?.data?.user?.code === "validation_not_unique") {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
throw createError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error completing check-in:", error);
|
||||
toast.error(error.message || "An error occurred during check-in");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!currentCheckInEvent) return;
|
||||
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
const get = Get.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Additional check to prevent duplicate check-ins right before submission
|
||||
const existingAttendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${currentCheckInEvent.id}"`
|
||||
);
|
||||
|
||||
// Check if user is already checked in
|
||||
if (existingAttendees.totalItems > 0) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Complete check-in with food selection
|
||||
await completeCheckIn(currentCheckInEvent, foodInput);
|
||||
} catch (error: any) {
|
||||
console.error("Error submitting check-in:", error);
|
||||
toast.error(error.message || "An error occurred during check-in");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 h-full">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text text-sm sm:text-base">Enter event code to check in</span>
|
||||
</label>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.querySelector('input') as HTMLInputElement;
|
||||
if (input.value.trim()) {
|
||||
setIsLoading(true);
|
||||
handleEventCheckIn(input.value.trim()).finally(() => {
|
||||
setIsLoading(false);
|
||||
input.value = "";
|
||||
});
|
||||
} else {
|
||||
toast("Please enter an event code", {
|
||||
icon: '⚠️',
|
||||
style: {
|
||||
borderRadius: '10px',
|
||||
background: '#FFC107',
|
||||
color: '#000',
|
||||
},
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter code"
|
||||
className="input input-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary h-10 min-h-[2.5rem] text-sm sm:text-base w-full sm:w-auto"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon="line-md:loading-twotone-loop"
|
||||
className="w-5 h-5"
|
||||
inline={true}
|
||||
/>
|
||||
) : (
|
||||
"Check In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Food Selection Modal */}
|
||||
<dialog id="foodSelectionModal" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
|
||||
<div className="text-sm mb-4 opacity-75">
|
||||
{currentCheckInEvent?.event_description}
|
||||
</div>
|
||||
<div className="badge badge-primary mb-4">
|
||||
{currentCheckInEvent?.points_to_reward} points
|
||||
</div>
|
||||
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-control">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter your food preference"
|
||||
className="input input-bordered w-full"
|
||||
value={foodInput}
|
||||
onChange={(e) => setFoodInput(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn" onClick={() => {
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
modal.close();
|
||||
setCurrentCheckInEvent(null);
|
||||
}}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon="line-md:loading-twotone-loop"
|
||||
className="w-5 h-5"
|
||||
inline={true}
|
||||
/>
|
||||
) : (
|
||||
"Check In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{/* Confirmation Modal (for events without food) */}
|
||||
<dialog id="confirmCheckInModal" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
|
||||
<div className="text-sm mb-4 opacity-75">
|
||||
{currentCheckInEvent?.event_description}
|
||||
</div>
|
||||
<div className="badge badge-primary mb-4">
|
||||
{currentCheckInEvent?.points_to_reward} points
|
||||
</div>
|
||||
<p className="mb-4">Are you sure you want to check in to this event?</p>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
modal.close();
|
||||
setCurrentCheckInEvent(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (currentCheckInEvent) {
|
||||
completeCheckIn(currentCheckInEvent, null);
|
||||
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
modal.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon="line-md:loading-twotone-loop"
|
||||
className="w-5 h-5"
|
||||
inline={true}
|
||||
/>
|
||||
) : (
|
||||
"Confirm Check In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCheckIn;
|
595
src/components/dashboard/EventsSection/EventLoad.tsx
Normal file
595
src/components/dashboard/EventsSection/EventLoad.tsx
Normal file
|
@ -0,0 +1,595 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { DexieService } from "../../../scripts/database/DexieService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended Event interface with additional properties needed for this component
|
||||
interface ExtendedEvent extends Event {
|
||||
description?: string; // This component uses 'description' but schema has 'event_description'
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
openDetailsModal: (event: ExtendedEvent) => void;
|
||||
downloadAllFiles: () => Promise<void>;
|
||||
currentEventId: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
}
|
||||
// Helper function to validate event data integrity
|
||||
const isValidEvent = (event: any): boolean => {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
|
||||
// Check required fields
|
||||
if (!event.id || !event.event_name) return false;
|
||||
|
||||
// Validate date fields
|
||||
try {
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
|
||||
// Check if dates are valid
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
console.warn(`Event ${event.id} has invalid date format`, {
|
||||
start: event.start_date,
|
||||
end: event.end_date
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`Error validating event ${event?.id || 'unknown'}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const EventLoad = () => {
|
||||
const [events, setEvents] = useState<{
|
||||
upcoming: Event[];
|
||||
ongoing: Event[];
|
||||
past: Event[];
|
||||
}>({
|
||||
upcoming: [],
|
||||
ongoing: [],
|
||||
past: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Function to clear the events cache and force a fresh sync
|
||||
const refreshEvents = async () => {
|
||||
try {
|
||||
setRefreshing(true);
|
||||
|
||||
// Get DexieService instance
|
||||
const dexieService = DexieService.getInstance();
|
||||
const db = dexieService.getDB();
|
||||
|
||||
// Clear events table
|
||||
if (db && db.events) {
|
||||
console.log("Clearing events cache...");
|
||||
await db.events.clear();
|
||||
console.log("Events cache cleared successfully");
|
||||
}
|
||||
|
||||
// Reset sync timestamp for events by updating it to 0
|
||||
// First get the current record
|
||||
const currentInfo = await dexieService.getLastSync(Collections.EVENTS);
|
||||
// Then update it with a timestamp of 0 (forcing a fresh sync)
|
||||
await dexieService.updateLastSync(Collections.EVENTS);
|
||||
console.log("Events sync timestamp reset");
|
||||
|
||||
// Reload events
|
||||
setLoading(true);
|
||||
await loadEvents();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error refreshing events:", error);
|
||||
setErrorMessage("Failed to refresh events. Please try again.");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, []);
|
||||
|
||||
const createSkeletonCard = () => (
|
||||
<div className="card bg-base-200 shadow-lg animate-pulse">
|
||||
<div className="card-body p-5">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="skeleton h-6 w-3/4 mb-2"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="skeleton h-5 w-16"></div>
|
||||
<div className="skeleton h-5 w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="skeleton h-5 w-24 mb-1"></div>
|
||||
<div className="skeleton h-4 w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skeleton h-4 w-full mb-3"></div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="skeleton h-4 w-4"></div>
|
||||
<div className="skeleton h-4 w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEventCard = (event: Event) => {
|
||||
try {
|
||||
// Get authentication instance
|
||||
const auth = Authentication.getInstance();
|
||||
const currentUser = auth.getCurrentUser();
|
||||
|
||||
// Check if user has attended this event by querying the event_attendees collection
|
||||
let hasAttended = false;
|
||||
if (currentUser) {
|
||||
// We'll check attendance status when displaying the card
|
||||
// This will be done asynchronously after rendering
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const attendees = await get.getList<EventAttendee>(
|
||||
"event_attendees",
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${event.id}"`
|
||||
);
|
||||
|
||||
const hasAttendedEvent = attendees.totalItems > 0;
|
||||
|
||||
// Update the card UI based on attendance status
|
||||
const cardElement = document.getElementById(`event-card-${event.id}`);
|
||||
if (cardElement && hasAttendedEvent) {
|
||||
const attendedBadge = cardElement.querySelector('.attended-badge');
|
||||
if (attendedBadge) {
|
||||
(attendedBadge as HTMLElement).style.display = 'flex';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking attendance status:", error);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Store event data in window object with unique ID
|
||||
const eventDataId = `event_${event.id}`;
|
||||
window[eventDataId] = event;
|
||||
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
const isPastEvent = endDate < now;
|
||||
|
||||
return (
|
||||
<div key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
|
||||
<div className="card-body p-3 sm:p-4">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2">{event.event_name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-base-content/70">
|
||||
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
|
||||
<div className="text-xs sm:text-sm opacity-75">
|
||||
{startDate.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{" • "}
|
||||
{startDate.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2">
|
||||
{event.event_description || "No description available"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
|
||||
{event.files && event.files.length > 0 && (
|
||||
<button
|
||||
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
|
||||
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2"
|
||||
>
|
||||
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
Files ({event.files.length})
|
||||
</button>
|
||||
)}
|
||||
{isPastEvent && (
|
||||
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}>
|
||||
<Icon
|
||||
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{hasAttended ? 'Attended' : 'Not Attended'}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs sm:text-sm opacity-75 ml-auto">
|
||||
{event.location}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error rendering event card:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
console.log("Starting to load events...");
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!auth.isAuthenticated()) {
|
||||
console.error("User not authenticated, cannot load events");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force sync to ensure we have the latest data
|
||||
console.log("Syncing events collection...");
|
||||
let syncSuccess = false;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
while (!syncSuccess && retryCount < maxRetries) {
|
||||
try {
|
||||
if (retryCount > 0) {
|
||||
console.log(`Retry attempt ${retryCount} of ${maxRetries}...`);
|
||||
// Add a small delay between retries
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
||||
}
|
||||
|
||||
await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date");
|
||||
console.log("Events collection synced successfully");
|
||||
syncSuccess = true;
|
||||
} catch (syncError) {
|
||||
retryCount++;
|
||||
console.error(`Error syncing events collection (attempt ${retryCount}/${maxRetries}):`, syncError);
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.warn("Max retry attempts reached, continuing with local data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get events from IndexedDB
|
||||
console.log("Fetching events from IndexedDB...");
|
||||
const allEvents = await dataSync.getData<Event>(
|
||||
Collections.EVENTS,
|
||||
false, // Don't force sync again
|
||||
"published = true",
|
||||
"-start_date"
|
||||
);
|
||||
|
||||
console.log(`Retrieved ${allEvents.length} events from IndexedDB`);
|
||||
|
||||
// Filter out invalid events
|
||||
const validEvents = allEvents.filter(event => isValidEvent(event));
|
||||
console.log(`Filtered out ${allEvents.length - validEvents.length} invalid events`);
|
||||
|
||||
// If no valid events found in IndexedDB, try fetching directly from PocketBase as fallback
|
||||
let eventsToProcess = validEvents;
|
||||
if (allEvents.length === 0) {
|
||||
console.log("No events found in IndexedDB, trying direct PocketBase fetch...");
|
||||
try {
|
||||
const pbEvents = await get.getAll<Event>(
|
||||
Collections.EVENTS,
|
||||
"published = true",
|
||||
"-start_date"
|
||||
);
|
||||
console.log(`Retrieved ${pbEvents.length} events directly from PocketBase`);
|
||||
|
||||
// Filter out invalid events from PocketBase results
|
||||
const validPbEvents = pbEvents.filter(event => isValidEvent(event));
|
||||
console.log(`Filtered out ${pbEvents.length - validPbEvents.length} invalid events from PocketBase`);
|
||||
|
||||
eventsToProcess = validPbEvents;
|
||||
|
||||
// Store these events in IndexedDB for future use
|
||||
if (validPbEvents.length > 0) {
|
||||
const dexieService = DexieService.getInstance();
|
||||
const db = dexieService.getDB();
|
||||
if (db && db.events) {
|
||||
console.log(`Storing ${validPbEvents.length} valid PocketBase events in IndexedDB...`);
|
||||
await db.events.bulkPut(validPbEvents);
|
||||
}
|
||||
}
|
||||
} catch (pbError) {
|
||||
console.error("Error fetching events from PocketBase:", pbError);
|
||||
}
|
||||
}
|
||||
|
||||
// Split events into upcoming, ongoing, and past based on start and end dates
|
||||
console.log("Categorizing events...");
|
||||
const now = new Date();
|
||||
const { upcoming, ongoing, past } = eventsToProcess.reduce(
|
||||
(acc, event) => {
|
||||
try {
|
||||
// Convert UTC dates to local time
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
|
||||
// Set both dates and now to midnight for date-only comparison
|
||||
const startLocal = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes()
|
||||
);
|
||||
const endLocal = new Date(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate(),
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes()
|
||||
);
|
||||
const nowLocal = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
|
||||
if (startLocal > nowLocal) {
|
||||
acc.upcoming.push(event);
|
||||
} else if (endLocal < nowLocal) {
|
||||
acc.past.push(event);
|
||||
} else {
|
||||
acc.ongoing.push(event);
|
||||
}
|
||||
} catch (dateError) {
|
||||
console.error("Error processing event dates:", dateError, event);
|
||||
// If we can't process dates properly, put in past events as fallback
|
||||
acc.past.push(event);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
upcoming: [] as Event[],
|
||||
ongoing: [] as Event[],
|
||||
past: [] as Event[],
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Categorized events: ${upcoming.length} upcoming, ${ongoing.length} ongoing, ${past.length} past`);
|
||||
|
||||
// Sort events
|
||||
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
|
||||
ongoing.sort((a, b) => new Date(a.end_date).getTime() - new Date(b.end_date).getTime());
|
||||
past.sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime());
|
||||
|
||||
setEvents({
|
||||
upcoming: upcoming.slice(0, 50), // Limit to 50 events per section
|
||||
ongoing: ongoing.slice(0, 50),
|
||||
past: past.slice(0, 50)
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load events:", error);
|
||||
|
||||
// Attempt to diagnose the error
|
||||
if (error instanceof Error) {
|
||||
console.error(`Error type: ${error.name}, Message: ${error.message}`);
|
||||
console.error(`Stack trace: ${error.stack}`);
|
||||
|
||||
// Check for network-related errors
|
||||
if (error.message.includes('network') || error.message.includes('fetch') || error.message.includes('connection')) {
|
||||
console.error("Network-related error detected");
|
||||
|
||||
// Try to load from IndexedDB only as a last resort
|
||||
try {
|
||||
console.log("Attempting to load events from IndexedDB only...");
|
||||
const dexieService = DexieService.getInstance();
|
||||
const db = dexieService.getDB();
|
||||
if (db && db.events) {
|
||||
const allCachedEvents = await db.events.filter(event => event.published === true).toArray();
|
||||
console.log(`Found ${allCachedEvents.length} cached events in IndexedDB`);
|
||||
|
||||
// Filter out invalid events
|
||||
const cachedEvents = allCachedEvents.filter(event => isValidEvent(event));
|
||||
console.log(`Filtered out ${allCachedEvents.length - cachedEvents.length} invalid cached events`);
|
||||
|
||||
if (cachedEvents.length > 0) {
|
||||
// Process these events
|
||||
const now = new Date();
|
||||
const { upcoming, ongoing, past } = cachedEvents.reduce(
|
||||
(acc, event) => {
|
||||
try {
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
|
||||
if (startDate > now) {
|
||||
acc.upcoming.push(event);
|
||||
} else if (endDate < now) {
|
||||
acc.past.push(event);
|
||||
} else {
|
||||
acc.ongoing.push(event);
|
||||
}
|
||||
} catch (e) {
|
||||
acc.past.push(event);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
upcoming: [] as Event[],
|
||||
ongoing: [] as Event[],
|
||||
past: [] as Event[],
|
||||
}
|
||||
);
|
||||
|
||||
// Sort and set events
|
||||
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
|
||||
ongoing.sort((a, b) => new Date(a.end_date).getTime() - new Date(b.end_date).getTime());
|
||||
past.sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime());
|
||||
|
||||
setEvents({
|
||||
upcoming: upcoming.slice(0, 50),
|
||||
ongoing: ongoing.slice(0, 50),
|
||||
past: past.slice(0, 50)
|
||||
});
|
||||
console.log("Successfully loaded events from cache");
|
||||
}
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.error("Failed to load events from cache:", cacheError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{/* Ongoing Events */}
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Past Events */}
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if there are no events at all
|
||||
const noEvents = events.ongoing.length === 0 && events.upcoming.length === 0 && events.past.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* No Events Message */}
|
||||
{noEvents && (
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 mx-4 sm:mx-6 p-8">
|
||||
<div className="text-center">
|
||||
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-base-content/30 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">No Events Found</h3>
|
||||
<p className="text-base-content/70 mb-4">
|
||||
There are currently no events to display. This could be due to:
|
||||
</p>
|
||||
<ul className="list-disc text-left max-w-md mx-auto text-base-content/70 mb-6">
|
||||
<li className="mb-1">No events have been published yet</li>
|
||||
<li className="mb-1">There might be a connection issue with the event database</li>
|
||||
<li className="mb-1">The events data might be temporarily unavailable</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={refreshEvents}
|
||||
className="btn btn-primary"
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? (
|
||||
<>
|
||||
<Icon icon="heroicons:arrow-path" className="w-5 h-5 mr-2 animate-spin" />
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="heroicons:arrow-path" className="w-5 h-5 mr-2" />
|
||||
Refresh Events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ongoing Events */}
|
||||
{events.ongoing.length > 0 && (
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{events.ongoing.map(renderEventCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming Events */}
|
||||
{events.upcoming.length > 0 && (
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{events.upcoming.map(renderEventCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past Events */}
|
||||
{events.past.length > 0 && (
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
|
||||
<div className="card-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{events.past.map(renderEventCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventLoad;
|
2204
src/components/dashboard/Officer_EventManagement.astro
Normal file
2204
src/components/dashboard/Officer_EventManagement.astro
Normal file
File diff suppressed because it is too large
Load diff
701
src/components/dashboard/Officer_EventManagement/Attendees.tsx
Normal file
701
src/components/dashboard/Officer_EventManagement/Attendees.tsx
Normal file
|
@ -0,0 +1,701 @@
|
|||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { SendLog } from '../../../scripts/pocketbase/SendLog';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { Event, User as SchemaUser, EventAttendee } from "../../../schemas/pocketbase";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Extended User interface with additional properties needed for this component
|
||||
interface User extends SchemaUser {
|
||||
member_type: string;
|
||||
}
|
||||
|
||||
// Define AttendeeEntry interface locally
|
||||
interface AttendeeEntry {
|
||||
user_id: string;
|
||||
time_checked_in: string;
|
||||
food: string;
|
||||
points_earned?: number;
|
||||
}
|
||||
|
||||
// Cache for storing user data
|
||||
const userCache = new Map<string, {
|
||||
data: User;
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
// Add HighlightText component
|
||||
const HighlightText = ({ text, searchTerms }: { text: string | number | null | undefined, searchTerms: string[] }) => {
|
||||
// Convert input to string and handle null/undefined
|
||||
const textStr = String(text ?? '');
|
||||
|
||||
if (!searchTerms.length || !textStr) return <>{textStr}</>;
|
||||
|
||||
try {
|
||||
const escapedTerms = searchTerms.map(term =>
|
||||
term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
);
|
||||
const parts = textStr.split(new RegExp(`(${escapedTerms.join('|')})`, 'gi'));
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
const isMatch = searchTerms.some(term =>
|
||||
part.toLowerCase().includes(term.toLowerCase())
|
||||
);
|
||||
return isMatch ? (
|
||||
<mark key={i} className="bg-primary/20 rounded px-1">{part}</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in HighlightText:', error);
|
||||
return <>{textStr}</>;
|
||||
}
|
||||
};
|
||||
|
||||
// Add new interface for selected fields
|
||||
interface EventFields {
|
||||
id: true;
|
||||
event_name: true;
|
||||
}
|
||||
|
||||
interface UserFields {
|
||||
id: true;
|
||||
name: true;
|
||||
email: true;
|
||||
pid: true;
|
||||
member_id: true;
|
||||
member_type: true;
|
||||
graduation_year: true;
|
||||
major: true;
|
||||
}
|
||||
|
||||
// Constants for field selection
|
||||
const EVENT_FIELDS: (keyof EventFields)[] = ['id', 'event_name'];
|
||||
const USER_FIELDS: (keyof UserFields)[] = ['id', 'name', 'email', 'pid', 'member_id', 'member_type', 'graduation_year', 'major'];
|
||||
|
||||
export default function Attendees() {
|
||||
const [eventId, setEventId] = useState<string>('');
|
||||
const [eventName, setEventName] = useState<string>('');
|
||||
const [users, setUsers] = useState<Map<string, User>>(new Map());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attendeesList, setAttendeesList] = useState<AttendeeEntry[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0); // Add a refresh key to force re-fetching
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Memoize search terms processing
|
||||
const updateProcessedSearchTerms = useCallback((searchTerm: string) => {
|
||||
const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
setProcessedSearchTerms(terms);
|
||||
setCurrentPage(1); // Reset to first page on new search
|
||||
}, []);
|
||||
|
||||
// Memoize filtered attendees
|
||||
const filteredAttendees = useMemo(() => {
|
||||
if (!searchTerm.trim()) return attendeesList;
|
||||
|
||||
return attendeesList.filter(attendee => {
|
||||
const user = users.get(attendee.user_id);
|
||||
if (!user) return false;
|
||||
|
||||
const searchableValues = [
|
||||
user.name,
|
||||
user.email,
|
||||
user.pid,
|
||||
user.member_id,
|
||||
user.member_type,
|
||||
user.graduation_year,
|
||||
user.major,
|
||||
attendee.food,
|
||||
new Date(attendee.time_checked_in).toLocaleString(),
|
||||
].map(value => (value || '').toString().toLowerCase());
|
||||
|
||||
return processedSearchTerms.every(term =>
|
||||
searchableValues.some(value => value.includes(term))
|
||||
);
|
||||
});
|
||||
}, [attendeesList, users, processedSearchTerms]);
|
||||
|
||||
// Memoize paginated attendees
|
||||
const paginatedAttendees = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredAttendees.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredAttendees, currentPage]);
|
||||
|
||||
// Memoize pagination info
|
||||
const paginationInfo = useMemo(() => {
|
||||
const totalPages = Math.ceil(filteredAttendees.length / ITEMS_PER_PAGE);
|
||||
return {
|
||||
totalPages,
|
||||
hasNextPage: currentPage < totalPages,
|
||||
hasPrevPage: currentPage > 1
|
||||
};
|
||||
}, [filteredAttendees.length, currentPage]);
|
||||
|
||||
// Optimized user data fetching with cache
|
||||
const fetchUserData = useCallback(async (userIds: string[]) => {
|
||||
if (!userIds.length) return new Map<string, User>();
|
||||
|
||||
const now = Date.now();
|
||||
const uncachedIds: string[] = [];
|
||||
const cachedUsers = new Map<string, User>();
|
||||
|
||||
// Check cache first
|
||||
userIds.forEach(id => {
|
||||
const cached = userCache.get(id);
|
||||
if (cached && now - cached.timestamp < CACHE_DURATION) {
|
||||
cachedUsers.set(id, cached.data);
|
||||
} else {
|
||||
uncachedIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// If we have all users in cache, return early
|
||||
if (uncachedIds.length === 0) {
|
||||
return cachedUsers;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a filter to get all uncached users in one request
|
||||
const userFilter = uncachedIds.map(id => `id="${id}"`).join(" || ");
|
||||
|
||||
// Fetch all uncached users in one request
|
||||
const usersResponse = await get.getAll<User>(
|
||||
Collections.USERS,
|
||||
userFilter
|
||||
);
|
||||
|
||||
// Process the fetched users
|
||||
usersResponse.forEach(user => {
|
||||
// Add member_type if it doesn't exist
|
||||
const userWithMemberType = {
|
||||
...user,
|
||||
member_type: user.member_type || "N/A"
|
||||
};
|
||||
cachedUsers.set(user.id, userWithMemberType);
|
||||
|
||||
// Update cache
|
||||
userCache.set(user.id, {
|
||||
data: userWithMemberType,
|
||||
timestamp: now
|
||||
});
|
||||
});
|
||||
|
||||
// Create placeholders for any users that weren't found
|
||||
const fetchedIds = new Set(usersResponse.map(user => user.id));
|
||||
uncachedIds.forEach(id => {
|
||||
if (!fetchedIds.has(id) && !cachedUsers.has(id)) {
|
||||
// Create a placeholder user
|
||||
const placeholderUser: User = {
|
||||
id,
|
||||
name: `User ${id}`,
|
||||
email: "N/A",
|
||||
emailVisibility: false,
|
||||
verified: false,
|
||||
created: "",
|
||||
updated: "",
|
||||
member_type: "N/A"
|
||||
};
|
||||
cachedUsers.set(id, placeholderUser);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
|
||||
// Create placeholders for all uncached users that failed to fetch
|
||||
uncachedIds.forEach(id => {
|
||||
if (!cachedUsers.has(id)) {
|
||||
const placeholderUser: User = {
|
||||
id,
|
||||
name: `User ${id}`,
|
||||
email: "N/A",
|
||||
emailVisibility: false,
|
||||
verified: false,
|
||||
created: "",
|
||||
updated: "",
|
||||
member_type: "N/A"
|
||||
};
|
||||
cachedUsers.set(id, placeholderUser);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cachedUsers;
|
||||
}, []);
|
||||
|
||||
// Function to refresh attendees data
|
||||
const refreshAttendees = useCallback(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for the custom event
|
||||
useEffect(() => {
|
||||
const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => {
|
||||
setEventId(e.detail.eventId);
|
||||
setEventName(e.detail.eventName);
|
||||
setCurrentPage(1); // Reset pagination on new event
|
||||
setSearchTerm(''); // Clear search on new event
|
||||
|
||||
// Log the attendees view action
|
||||
try {
|
||||
const sendLog = SendLog.getInstance();
|
||||
await sendLog.send(
|
||||
"view",
|
||||
"event_attendees",
|
||||
`Viewed attendees for event: ${e.detail.eventName}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log attendees view:', error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
|
||||
|
||||
// Expose refresh function to window
|
||||
(window as any).refreshAttendees = refreshAttendees;
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
|
||||
delete (window as any).refreshAttendees;
|
||||
};
|
||||
}, [refreshAttendees]);
|
||||
|
||||
// Update search terms when search input changes
|
||||
useEffect(() => {
|
||||
updateProcessedSearchTerms(searchTerm);
|
||||
}, [searchTerm, updateProcessedSearchTerms]);
|
||||
|
||||
// Fetch event data when eventId changes or refreshKey changes
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const fetchEventData = async () => {
|
||||
if (!eventId || !auth.isAuthenticated()) {
|
||||
if (!auth.isAuthenticated()) {
|
||||
console.log('User not authenticated');
|
||||
setError('Authentication required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!eventId) {
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear cache to ensure fresh data
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.clearCache();
|
||||
await dataSync.syncCollection(Collections.EVENTS, `id="${eventId}"`);
|
||||
|
||||
const event = await get.getOne<Event>(Collections.EVENTS, eventId);
|
||||
|
||||
if (!event) {
|
||||
setError("Event not found");
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch attendees from event_attendees collection with a higher limit
|
||||
const attendeesList = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
2000, // Increased limit to handle more attendees
|
||||
`event="${eventId}"`
|
||||
);
|
||||
|
||||
if (!attendeesList.items.length) {
|
||||
if (isMounted) {
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform EventAttendee records to match the expected format
|
||||
const transformedAttendees = attendeesList.items.map(attendee => ({
|
||||
user_id: attendee.user, // This is the user ID (relation)
|
||||
time_checked_in: attendee.time_checked_in,
|
||||
food: attendee.food_ate,
|
||||
points_earned: attendee.points_earned
|
||||
}));
|
||||
|
||||
if (isMounted) {
|
||||
setAttendeesList(transformedAttendees);
|
||||
}
|
||||
|
||||
// Fetch all users at once to improve performance
|
||||
const userIds = transformedAttendees.map(a => a.user_id);
|
||||
|
||||
// Create a filter to get all users in one request
|
||||
const userFilter = userIds.map(id => `id="${id}"`).join(" || ");
|
||||
|
||||
try {
|
||||
// Fetch all users directly from PocketBase in one request
|
||||
const usersResponse = await get.getAll<User>(
|
||||
Collections.USERS,
|
||||
userFilter
|
||||
);
|
||||
|
||||
// Create a map of users
|
||||
const userMap = new Map<string, User>();
|
||||
usersResponse.forEach(user => {
|
||||
// Add member_type if it doesn't exist
|
||||
const userWithMemberType = {
|
||||
...user,
|
||||
member_type: user.member_type || "N/A"
|
||||
};
|
||||
userMap.set(user.id, userWithMemberType);
|
||||
|
||||
// Update cache
|
||||
userCache.set(user.id, {
|
||||
data: userWithMemberType,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// For any missing users, create placeholders
|
||||
userIds.forEach(id => {
|
||||
if (!userMap.has(id)) {
|
||||
const placeholderUser: User = {
|
||||
id,
|
||||
name: `User ${id}`,
|
||||
email: "N/A",
|
||||
emailVisibility: false,
|
||||
verified: false,
|
||||
created: "",
|
||||
updated: "",
|
||||
member_type: "N/A"
|
||||
};
|
||||
userMap.set(id, placeholderUser);
|
||||
}
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setUsers(userMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
|
||||
// Fallback to individual user fetching
|
||||
const userMap = await fetchUserData(userIds);
|
||||
if (isMounted) {
|
||||
setUsers(userMap);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Loaded ${attendeesList.items.length} attendees for ${event.event_name}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching event data:", error);
|
||||
setError("Failed to load event data. Please try refreshing.");
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchEventData();
|
||||
return () => { isMounted = false; };
|
||||
}, [eventId, auth, fetchUserData, refreshKey]);
|
||||
|
||||
// Reset state when modal is closed
|
||||
useEffect(() => {
|
||||
const handleModalClose = () => {
|
||||
setEventId('');
|
||||
setEventName('');
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
setError(null);
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const modal = document.getElementById('attendeesModal');
|
||||
if (modal) {
|
||||
modal.addEventListener('close', handleModalClose);
|
||||
return () => modal.removeEventListener('close', handleModalClose);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to download attendees as CSV
|
||||
const downloadAttendeesCSV = () => {
|
||||
// Function to sanitize and format CSV cell content
|
||||
const escapeCSV = (cell: any): string => {
|
||||
// Convert to string and replace any newlines with spaces
|
||||
const value = (cell?.toString() || '').replace(/[\r\n]+/g, ' ').trim();
|
||||
|
||||
// If the value contains quotes or commas, wrap in quotes and escape internal quotes
|
||||
if (value.includes('"') || value.includes(',')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Create CSV headers
|
||||
const headers = [
|
||||
'Name',
|
||||
'Email',
|
||||
'PID',
|
||||
'Member ID',
|
||||
'Member Type',
|
||||
'Graduation Year',
|
||||
'Major',
|
||||
'Check-in Time',
|
||||
'Food Choice',
|
||||
'Points Earned'
|
||||
].map(escapeCSV);
|
||||
|
||||
// Create CSV rows
|
||||
const rows = attendeesList.map(attendee => {
|
||||
const user = users.get(attendee.user_id);
|
||||
let checkInTime = '';
|
||||
try {
|
||||
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
|
||||
} catch (e) {
|
||||
checkInTime = attendee.time_checked_in || 'N/A';
|
||||
}
|
||||
|
||||
return [
|
||||
user?.name || `User ${attendee.user_id}`,
|
||||
user?.email || 'N/A',
|
||||
user?.pid || 'N/A',
|
||||
user?.member_id || 'N/A',
|
||||
user?.member_type || 'N/A',
|
||||
user?.graduation_year || 'N/A',
|
||||
user?.major || 'N/A',
|
||||
checkInTime,
|
||||
attendee.food || 'N/A',
|
||||
attendee.points_earned || 'N/A'
|
||||
].map(escapeCSV);
|
||||
});
|
||||
|
||||
// Combine headers and rows with Windows-style line endings
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\r\n');
|
||||
|
||||
// Create blob with UTF-8 BOM for better Excel compatibility
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create filename with date and time
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
const filename = `${eventName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_attendees_${timestamp}.csv`;
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url); // Clean up the URL object
|
||||
|
||||
toast.success(`Downloaded ${rows.length} attendee records`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
<Icon icon="heroicons:exclamation-circle" className="h-6 w-6" />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={refreshAttendees}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!attendeesList || attendeesList.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-base-content/70">
|
||||
<Icon icon="heroicons:user-group" className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No attendees yet</p>
|
||||
<button
|
||||
className="btn btn-sm btn-outline mt-4"
|
||||
onClick={refreshAttendees}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-16rem)]">
|
||||
<div className="flex flex-col gap-4 mb-4">
|
||||
{/* Search and Actions Row */}
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<div className="join flex-1">
|
||||
<div className="join-item bg-base-200 flex items-center px-3">
|
||||
<Icon icon="heroicons:magnifying-glass" className="h-5 w-5 opacity-70" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search attendees..."
|
||||
className="input input-bordered join-item w-full"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={refreshAttendees}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-path" className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm gap-2"
|
||||
onClick={downloadAttendeesCSV}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
|
||||
Download CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm opacity-70">
|
||||
Total Attendees: {attendeesList.length}
|
||||
</div>
|
||||
{searchTerm && (
|
||||
<div className="text-sm opacity-70">
|
||||
Showing: {filteredAttendees.length} matches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table with pagination */}
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead className="sticky top-0 bg-base-100">
|
||||
<tr>
|
||||
<th className="bg-base-100">Name</th>
|
||||
<th className="bg-base-100">Email</th>
|
||||
<th className="bg-base-100">PID</th>
|
||||
<th className="bg-base-100">Member ID</th>
|
||||
<th className="bg-base-100">Member Type</th>
|
||||
<th className="bg-base-100">Graduation Year</th>
|
||||
<th className="bg-base-100">Major</th>
|
||||
<th className="bg-base-100">Check-in Time</th>
|
||||
<th className="bg-base-100">Food Choice</th>
|
||||
<th className="bg-base-100">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedAttendees.map((attendee, index) => {
|
||||
const user = users.get(attendee.user_id);
|
||||
let checkInTime = '';
|
||||
try {
|
||||
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
|
||||
} catch (e) {
|
||||
checkInTime = attendee.time_checked_in || 'N/A';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={`${attendee.user_id}-${index}`}>
|
||||
<td><HighlightText text={user?.name || `User ${attendee.user_id}`} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.email || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.pid || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.member_id || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.member_type || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.graduation_year || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.major || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={checkInTime} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={attendee.food || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={attendee.points_earned || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{paginationInfo.totalPages > 1 && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<div className="join">
|
||||
<button
|
||||
className="join-item btn btn-sm"
|
||||
disabled={!paginationInfo.hasPrevPage}
|
||||
onClick={() => setCurrentPage(1)}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
className="join-item btn btn-sm"
|
||||
disabled={!paginationInfo.hasPrevPage}
|
||||
onClick={() => setCurrentPage(p => p - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button className="join-item btn btn-sm">
|
||||
Page {currentPage} of {paginationInfo.totalPages}
|
||||
</button>
|
||||
<button
|
||||
className="join-item btn btn-sm"
|
||||
disabled={!paginationInfo.hasNextPage}
|
||||
onClick={() => setCurrentPage(p => p + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
className="join-item btn btn-sm"
|
||||
disabled={!paginationInfo.hasNextPage}
|
||||
onClick={() => setCurrentPage(paginationInfo.totalPages)}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
934
src/components/dashboard/Officer_EventManagement/EventEditor.tsx
Normal file
934
src/components/dashboard/Officer_EventManagement/EventEditor.tsx
Normal file
|
@ -0,0 +1,934 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { Update } from "../../../scripts/pocketbase/Update";
|
||||
import { FileManager } from "../../../scripts/pocketbase/FileManager";
|
||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||
import FilePreview from "../universal/FilePreview";
|
||||
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Note: Date conversion is now handled automatically by the Get and Update classes.
|
||||
// When fetching events, UTC dates are converted to local time by the Get class.
|
||||
// When saving events, local dates are converted back to UTC by the Update class.
|
||||
// For datetime-local inputs, we format dates without seconds (YYYY-MM-DDThh:mm).
|
||||
|
||||
// Extended Event interface with optional created and updated fields
|
||||
interface Event extends Omit<SchemaEvent, 'created' | 'updated'> {
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
// Extend Window interface
|
||||
declare global {
|
||||
interface Window {
|
||||
showLoading?: () => void;
|
||||
hideLoading?: () => void;
|
||||
lastCacheUpdate?: number;
|
||||
fetchEvents?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface EventEditorProps {
|
||||
onEventSaved?: () => void;
|
||||
}
|
||||
|
||||
// Memoize the FilePreview component
|
||||
const MemoizedFilePreview = memo(FilePreview);
|
||||
|
||||
// Define EventForm props interface
|
||||
interface EventFormProps {
|
||||
event: Event | null;
|
||||
setEvent: (field: keyof Event, value: any) => void;
|
||||
selectedFiles: Map<string, File>;
|
||||
setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>;
|
||||
filesToDelete: Set<string>;
|
||||
setFilesToDelete: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
handlePreviewFile: (url: string, filename: string) => void;
|
||||
isSubmitting: boolean;
|
||||
fileManager: FileManager;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Create a memoized form component
|
||||
const EventForm = memo(({
|
||||
event,
|
||||
setEvent,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
filesToDelete,
|
||||
setFilesToDelete,
|
||||
handlePreviewFile,
|
||||
isSubmitting,
|
||||
fileManager,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: EventFormProps): React.ReactElement => {
|
||||
const handleChange = (field: keyof Event, value: any) => {
|
||||
setEvent(field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="editFormSection">
|
||||
<h3 className="font-bold text-lg mb-4" id="editModalTitle">
|
||||
{event?.id ? 'Edit Event' : 'Add New Event'}
|
||||
</h3>
|
||||
<form
|
||||
id="editEventForm"
|
||||
className="space-y-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Event Name */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Event Name</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="editEventName"
|
||||
className="input input-bordered"
|
||||
value={event?.event_name || ""}
|
||||
onChange={(e) => handleChange('event_name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Code */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Event Code</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="editEventCode"
|
||||
className="input input-bordered"
|
||||
value={event?.event_code || ""}
|
||||
onChange={(e) => handleChange('event_code', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Location</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="editEventLocation"
|
||||
className="input input-bordered"
|
||||
value={event?.location || ""}
|
||||
onChange={(e) => handleChange('location', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Points to Reward */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Points to Reward</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="editEventPoints"
|
||||
className="input input-bordered"
|
||||
value={event?.points_to_reward || 0}
|
||||
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Date */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Start Date</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="editEventStartDate"
|
||||
className="input input-bordered"
|
||||
value={event?.start_date || ""}
|
||||
onChange={(e) => handleChange('start_date', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End Date */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">End Date</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="editEventEndDate"
|
||||
className="input input-bordered"
|
||||
value={event?.end_date || ""}
|
||||
onChange={(e) => handleChange('end_date', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Description</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="editEventDescription"
|
||||
className="textarea textarea-bordered"
|
||||
value={event?.event_description || ""}
|
||||
onChange={(e) => handleChange('event_description', e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Upload Files</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="editEventFiles"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const newFiles = new Map(selectedFiles);
|
||||
const rejectedFiles: { name: string, reason: string }[] = [];
|
||||
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
|
||||
|
||||
Array.from(e.target.files).forEach(file => {
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
rejectedFiles.push({
|
||||
name: file.name,
|
||||
reason: `exceeds size limit (${fileSizeMB}MB > 200MB)`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validation = fileManager.validateFileType(file);
|
||||
if (!validation.valid) {
|
||||
rejectedFiles.push({
|
||||
name: file.name,
|
||||
reason: validation.reason || 'unsupported file type'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add valid files
|
||||
newFiles.set(file.name, file);
|
||||
});
|
||||
|
||||
// Show error for rejected files
|
||||
if (rejectedFiles.length > 0) {
|
||||
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setSelectedFiles(newFiles);
|
||||
}
|
||||
}}
|
||||
className="file-input file-input-bordered"
|
||||
multiple
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
{/* New Files */}
|
||||
{Array.from(selectedFiles.entries()).map(([name, file]) => (
|
||||
<div key={name} className="flex items-center justify-between p-2 bg-base-200 rounded-lg">
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="badge badge-primary">New</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
onClick={() => {
|
||||
const updatedFiles = new Map(selectedFiles);
|
||||
updatedFiles.delete(name);
|
||||
setSelectedFiles(updatedFiles);
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:x-circle" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Current Files */}
|
||||
{event?.files && event.files.length > 0 && (
|
||||
<>
|
||||
<div className="divider">Current Files</div>
|
||||
{event.files.map((filename) => (
|
||||
<div key={filename} className={`flex items-center justify-between p-2 bg-base-200 rounded-lg${filesToDelete.has(filename) ? " opacity-50" : ""}`}>
|
||||
<span className="truncate">{filename}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => {
|
||||
if (event?.id) {
|
||||
handlePreviewFile(
|
||||
fileManager.getFileUrl("events", event.id, filename),
|
||||
filename
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="text-error">
|
||||
{filesToDelete.has(filename) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => {
|
||||
const newFilesToDelete = new Set(filesToDelete);
|
||||
newFilesToDelete.delete(filename);
|
||||
setFilesToDelete(newFilesToDelete);
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
onClick={() => {
|
||||
const newFilesToDelete = new Set(filesToDelete);
|
||||
newFilesToDelete.add(filename);
|
||||
setFilesToDelete(newFilesToDelete);
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Published */}
|
||||
<div className="form-control">
|
||||
<label className="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="editEventPublished"
|
||||
className="toggle"
|
||||
checked={event?.published || false}
|
||||
onChange={(e) => handleChange('published', e.target.checked)}
|
||||
/>
|
||||
<span className="label-text">Publish Event</span>
|
||||
</label>
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-info">
|
||||
This has to be clicked if you want to make this event available
|
||||
to the public
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Has Food */}
|
||||
<div className="form-control">
|
||||
<label className="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="editEventHasFood"
|
||||
className="toggle"
|
||||
checked={event?.has_food || false}
|
||||
onChange={(e) => handleChange('has_food', e.target.checked)}
|
||||
/>
|
||||
<span className="label-text">Has Food</span>
|
||||
</label>
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-info">
|
||||
Check this if food will be provided at the event
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="modal-action mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${isSubmitting ? 'loading' : ''}`}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Add new interfaces for change tracking
|
||||
interface EventChanges {
|
||||
event_name?: string;
|
||||
event_description?: string;
|
||||
event_code?: string;
|
||||
location?: string;
|
||||
points_to_reward?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
published?: boolean;
|
||||
has_food?: boolean;
|
||||
}
|
||||
|
||||
interface FileChanges {
|
||||
added: Map<string, File>;
|
||||
deleted: Set<string>;
|
||||
unchanged: string[];
|
||||
}
|
||||
|
||||
// Add queue management for large datasets
|
||||
class UploadQueue {
|
||||
private queue: Array<() => Promise<void>> = [];
|
||||
private processing = false;
|
||||
private readonly BATCH_SIZE = 5;
|
||||
|
||||
async add(task: () => Promise<void>) {
|
||||
this.queue.push(task);
|
||||
if (!this.processing) {
|
||||
await this.process();
|
||||
}
|
||||
}
|
||||
|
||||
private async process() {
|
||||
if (this.processing || this.queue.length === 0) return;
|
||||
|
||||
this.processing = true;
|
||||
try {
|
||||
while (this.queue.length > 0) {
|
||||
const batch = this.queue.splice(0, this.BATCH_SIZE);
|
||||
await Promise.all(batch.map(task => task()));
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add change tracking utility
|
||||
class ChangeTracker {
|
||||
private initialState: Event | null = null;
|
||||
private currentState: Event | null = null;
|
||||
private fileChanges: FileChanges = {
|
||||
added: new Map(),
|
||||
deleted: new Set(),
|
||||
unchanged: []
|
||||
};
|
||||
|
||||
initialize(event: Event | null) {
|
||||
this.initialState = event ? { ...event } : null;
|
||||
this.currentState = event ? { ...event } : null;
|
||||
this.fileChanges = {
|
||||
added: new Map(),
|
||||
deleted: new Set(),
|
||||
unchanged: event?.files || []
|
||||
};
|
||||
}
|
||||
|
||||
trackChange(field: keyof Event, value: any) {
|
||||
if (!this.currentState) {
|
||||
this.currentState = {} as Event;
|
||||
}
|
||||
(this.currentState as any)[field] = value;
|
||||
}
|
||||
|
||||
trackFileChange(added: Map<string, File>, deleted: Set<string>) {
|
||||
this.fileChanges.added = added;
|
||||
this.fileChanges.deleted = deleted;
|
||||
if (this.initialState?.files) {
|
||||
this.fileChanges.unchanged = this.initialState.files.filter(
|
||||
file => !deleted.has(file)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getChanges(): EventChanges {
|
||||
if (!this.initialState || !this.currentState) return {};
|
||||
|
||||
const changes: EventChanges = {};
|
||||
const fields: (keyof EventChanges)[] = [
|
||||
'event_name',
|
||||
'event_description',
|
||||
'event_code',
|
||||
'location',
|
||||
'points_to_reward',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'published',
|
||||
'has_food'
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
if (this.initialState[field] !== this.currentState[field]) {
|
||||
(changes[field] as any) = this.currentState[field];
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
getFileChanges(): FileChanges {
|
||||
return this.fileChanges;
|
||||
}
|
||||
|
||||
hasChanges(): boolean {
|
||||
return Object.keys(this.getChanges()).length > 0 ||
|
||||
this.fileChanges.added.size > 0 ||
|
||||
this.fileChanges.deleted.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new interfaces for loading states
|
||||
interface LoadingState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
// Add loading spinner component
|
||||
const LoadingSpinner = memo(() => (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
||||
<div className="loading loading-spinner loading-lg text-primary"></div>
|
||||
<p className="text-base-content/70">Loading event data...</p>
|
||||
</div>
|
||||
));
|
||||
|
||||
// Add error display component
|
||||
const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
||||
<div className="text-error">
|
||||
<Icon icon="heroicons:x-circle" className="h-12 w-12" />
|
||||
</div>
|
||||
<p className="text-error font-medium">{error}</p>
|
||||
<button className="btn btn-error btn-sm" onClick={onRetry}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
|
||||
// Modify EventEditor component
|
||||
export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||
// State for form data and UI
|
||||
const [event, setEvent] = useState<Event>({
|
||||
id: "",
|
||||
created: "",
|
||||
updated: "",
|
||||
event_name: "",
|
||||
event_description: "",
|
||||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: 0,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false
|
||||
});
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewFilename, setPreviewFilename] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map());
|
||||
const [filesToDelete, setFilesToDelete] = useState<Set<string>>(new Set());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// Memoize service instances
|
||||
const services = useMemo(() => ({
|
||||
get: Get.getInstance(),
|
||||
auth: Authentication.getInstance(),
|
||||
update: Update.getInstance(),
|
||||
fileManager: FileManager.getInstance(),
|
||||
sendLog: SendLog.getInstance()
|
||||
}), []);
|
||||
|
||||
// Handle field changes
|
||||
const handleFieldChange = useCallback((field: keyof Event, value: any) => {
|
||||
setEvent(prev => {
|
||||
const newEvent = { ...prev, [field]: value };
|
||||
// Only set hasUnsavedChanges if the value actually changed
|
||||
if (prev[field] !== value) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
return newEvent;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initialize event data
|
||||
const initializeEventData = useCallback(async (eventId: string) => {
|
||||
try {
|
||||
if (eventId) {
|
||||
// Clear cache to ensure fresh data
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.clearCache();
|
||||
|
||||
// Fetch fresh event data
|
||||
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
|
||||
|
||||
if (!eventData) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
|
||||
// Ensure dates are properly formatted for datetime-local input
|
||||
if (eventData.start_date) {
|
||||
// Convert to Date object first to ensure proper formatting
|
||||
const startDate = new Date(eventData.start_date);
|
||||
eventData.start_date = Get.formatLocalDate(startDate, false);
|
||||
}
|
||||
|
||||
if (eventData.end_date) {
|
||||
// Convert to Date object first to ensure proper formatting
|
||||
const endDate = new Date(eventData.end_date);
|
||||
eventData.end_date = Get.formatLocalDate(endDate, false);
|
||||
}
|
||||
|
||||
// Ensure all fields are properly set
|
||||
setEvent({
|
||||
id: eventData.id || '',
|
||||
created: eventData.created || '',
|
||||
updated: eventData.updated || '',
|
||||
event_name: eventData.event_name || '',
|
||||
event_description: eventData.event_description || '',
|
||||
event_code: eventData.event_code || '',
|
||||
location: eventData.location || '',
|
||||
files: eventData.files || [],
|
||||
points_to_reward: eventData.points_to_reward || 0,
|
||||
start_date: eventData.start_date || '',
|
||||
end_date: eventData.end_date || '',
|
||||
published: eventData.published || false,
|
||||
has_food: eventData.has_food || false
|
||||
});
|
||||
|
||||
console.log("Event data loaded successfully:", eventData);
|
||||
} else {
|
||||
setEvent({
|
||||
id: '',
|
||||
created: '',
|
||||
updated: '',
|
||||
event_name: '',
|
||||
event_description: '',
|
||||
event_code: '',
|
||||
location: '',
|
||||
files: [],
|
||||
points_to_reward: 0,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
published: false,
|
||||
has_food: false
|
||||
});
|
||||
}
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
setShowPreview(false);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize event data:", error);
|
||||
toast.error("Failed to load event data. Please try again.");
|
||||
}
|
||||
}, [services.get]);
|
||||
|
||||
// Expose initializeEventData to window
|
||||
useEffect(() => {
|
||||
(window as any).openEditModal = async (event?: Event) => {
|
||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||
if (!modal) return;
|
||||
|
||||
try {
|
||||
if (event?.id) {
|
||||
// Always fetch fresh data from PocketBase for the event
|
||||
await initializeEventData(event.id);
|
||||
} else {
|
||||
// Reset form for new event
|
||||
await initializeEventData('');
|
||||
}
|
||||
modal.showModal();
|
||||
} catch (error) {
|
||||
console.error("Failed to open edit modal:", error);
|
||||
toast.error("Failed to open edit modal. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
delete (window as any).openEditModal;
|
||||
};
|
||||
}, [initializeEventData]);
|
||||
|
||||
// Handler functions
|
||||
const handlePreviewFile = useCallback((url: string, filename: string) => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewFilename(filename);
|
||||
setShowPreview(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?');
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setEvent({
|
||||
id: "",
|
||||
created: "",
|
||||
updated: "",
|
||||
event_name: "",
|
||||
event_description: "",
|
||||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: 0,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false
|
||||
});
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
setShowPreview(false);
|
||||
setPreviewUrl("");
|
||||
setPreviewFilename("");
|
||||
|
||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||
if (modal) modal.close();
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
window.showLoading?.();
|
||||
|
||||
const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement;
|
||||
const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement;
|
||||
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add("btn-disabled");
|
||||
}
|
||||
if (cancelButton) cancelButton.disabled = true;
|
||||
|
||||
// Get form data
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
// Create updated event object
|
||||
const updatedEvent: Omit<Event, 'created' | 'updated'> = {
|
||||
id: event.id,
|
||||
event_name: formData.get("editEventName") as string,
|
||||
event_description: formData.get("editEventDescription") as string,
|
||||
event_code: formData.get("editEventCode") as string,
|
||||
location: formData.get("editEventLocation") as string,
|
||||
files: event.files || [],
|
||||
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
|
||||
start_date: formData.get("editEventStartDate") as string,
|
||||
end_date: formData.get("editEventEndDate") as string,
|
||||
published: formData.get("editEventPublished") === "on",
|
||||
has_food: formData.get("editEventHasFood") === "on"
|
||||
};
|
||||
|
||||
// Log the update attempt
|
||||
await services.sendLog.send(
|
||||
"update",
|
||||
"event",
|
||||
`${event.id ? "Updating" : "Creating"} event: ${updatedEvent.event_name} (${event.id || "new"})`
|
||||
);
|
||||
|
||||
if (event.id) {
|
||||
// We're updating an existing event
|
||||
|
||||
// First, update the event data without touching files
|
||||
const { files, ...cleanPayload } = updatedEvent;
|
||||
await services.update.updateFields<Event>(
|
||||
Collections.EVENTS,
|
||||
event.id,
|
||||
cleanPayload
|
||||
);
|
||||
|
||||
// Handle file operations
|
||||
if (filesToDelete.size > 0 || selectedFiles.size > 0) {
|
||||
// Get the current event with its files
|
||||
const currentEvent = await services.get.getOne<Event>(Collections.EVENTS, event.id);
|
||||
let currentFiles = currentEvent?.files || [];
|
||||
|
||||
// 1. Remove files marked for deletion
|
||||
if (filesToDelete.size > 0) {
|
||||
console.log(`Removing ${filesToDelete.size} files from event ${event.id}`);
|
||||
currentFiles = currentFiles.filter(file => !filesToDelete.has(file));
|
||||
|
||||
// Update the files field first to remove deleted files
|
||||
await services.update.updateFields<Event>(
|
||||
Collections.EVENTS,
|
||||
event.id,
|
||||
{ files: currentFiles }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Add new files one by one to preserve existing ones
|
||||
if (selectedFiles.size > 0) {
|
||||
console.log(`Adding ${selectedFiles.size} new files to event ${event.id}`);
|
||||
|
||||
// Convert Map to array of File objects
|
||||
const newFiles = Array.from(selectedFiles.values());
|
||||
|
||||
// Use FileManager to upload each file individually
|
||||
for (const file of newFiles) {
|
||||
// Use the FileManager to upload this file
|
||||
await services.fileManager.uploadFile(
|
||||
Collections.EVENTS,
|
||||
event.id,
|
||||
'files',
|
||||
file,
|
||||
true // Set append mode to true to preserve existing files
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the final updated event with all changes
|
||||
const savedEvent = await services.get.getOne<Event>(Collections.EVENTS, event.id);
|
||||
|
||||
// Clear cache to ensure fresh data
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.clearCache();
|
||||
|
||||
// Update the window object with the latest event data
|
||||
const eventDataId = `event_${event.id}`;
|
||||
if ((window as any)[eventDataId]) {
|
||||
(window as any)[eventDataId] = savedEvent;
|
||||
}
|
||||
|
||||
toast.success("Event updated successfully!");
|
||||
|
||||
// Call the onEventSaved callback if provided
|
||||
if (onEventSaved) onEventSaved();
|
||||
|
||||
// Close the modal
|
||||
handleModalClose();
|
||||
|
||||
} else {
|
||||
// We're creating a new event
|
||||
|
||||
// Create the event first without files
|
||||
const { files, ...cleanPayload } = updatedEvent;
|
||||
const newEvent = await services.update.create<Event>(
|
||||
Collections.EVENTS,
|
||||
cleanPayload
|
||||
);
|
||||
|
||||
// Then upload files if any
|
||||
if (selectedFiles.size > 0 && newEvent?.id) {
|
||||
console.log(`Adding ${selectedFiles.size} files to new event ${newEvent.id}`);
|
||||
|
||||
// Convert Map to array of File objects
|
||||
const newFiles = Array.from(selectedFiles.values());
|
||||
|
||||
// Upload files to the new event
|
||||
for (const file of newFiles) {
|
||||
await services.fileManager.uploadFile(
|
||||
Collections.EVENTS,
|
||||
newEvent.id,
|
||||
'files',
|
||||
file,
|
||||
true // Set append mode to true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache to ensure fresh data
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.clearCache();
|
||||
|
||||
toast.success("Event created successfully!");
|
||||
|
||||
// Call the onEventSaved callback if provided
|
||||
if (onEventSaved) onEventSaved();
|
||||
|
||||
// Close the modal
|
||||
handleModalClose();
|
||||
}
|
||||
|
||||
// Refresh events list if available
|
||||
if (window.fetchEvents) window.fetchEvents();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to save event:", error);
|
||||
toast.error(`Failed to save event: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
window.hideLoading?.();
|
||||
}
|
||||
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, handleModalClose]);
|
||||
|
||||
|
||||
return (
|
||||
<dialog id="editEventModal" className="modal">
|
||||
{showPreview ? (
|
||||
<div className="modal-box max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<FilePreview
|
||||
url={previewUrl}
|
||||
filename={previewFilename}
|
||||
isModal={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="modal-box max-w-2xl">
|
||||
<EventForm
|
||||
event={event}
|
||||
setEvent={handleFieldChange}
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
filesToDelete={filesToDelete}
|
||||
setFilesToDelete={setFilesToDelete}
|
||||
handlePreviewFile={handlePreviewFile}
|
||||
isSubmitting={isSubmitting}
|
||||
fileManager={services.fileManager}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleModalClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
337
src/components/dashboard/Officer_EventRequestForm.astro
Normal file
337
src/components/dashboard/Officer_EventRequestForm.astro
Normal file
|
@ -0,0 +1,337 @@
|
|||
---
|
||||
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||
import { Get } from "../../scripts/pocketbase/Get";
|
||||
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
|
||||
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||
import { EventRequestFormPreviewModal } from "./Officer_EventRequestForm/EventRequestFormPreview";
|
||||
|
||||
// Import the EventRequest type from UserEventRequests to ensure consistency
|
||||
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
|
||||
|
||||
// Use the imported type
|
||||
type EventRequest = UserEventRequest;
|
||||
|
||||
// Get instances
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Initialize variables for user's submissions
|
||||
let userEventRequests: EventRequest[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
// Fetch user's event request submissions if authenticated
|
||||
// This provides initial data for server-side rendering
|
||||
// Client-side will use IndexedDB for data management
|
||||
if (auth.isAuthenticated()) {
|
||||
try {
|
||||
const userId = auth.getUserId();
|
||||
if (userId) {
|
||||
userEventRequests = await get.getAll<EventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
`requested_user="${userId}"`,
|
||||
"-created"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user event requests:", err);
|
||||
error = "Failed to load your event requests. Please try again later.";
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<div class="w-full max-w-6xl mx-auto py-8 px-4">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
|
||||
<p class="text-gray-300 mb-4">
|
||||
Submit your event request at least 6 weeks before your event. After
|
||||
submitting, please notify PR and/or Coordinators in the #-events
|
||||
Slack channel.
|
||||
</p>
|
||||
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
|
||||
<p class="font-medium mb-2">This form includes sections for:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>PR Materials (if needed)</li>
|
||||
<li>Event Details</li>
|
||||
<li>TAP Form Information</li>
|
||||
<li>AS Funding (if needed)</li>
|
||||
</ul>
|
||||
<p class="mt-3">
|
||||
Your progress is automatically saved as you fill out the form.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed mb-6">
|
||||
<a class="tab tab-lg tab-active" id="form-tab">Submit Event Request</a>
|
||||
<a class="tab tab-lg" id="submissions-tab">View Your Submissions</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Tab Content -->
|
||||
<div
|
||||
id="form-content"
|
||||
class="bg-base-200 rounded-lg shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="p-6">
|
||||
<EventRequestForm client:load />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submissions Tab Content -->
|
||||
<div id="submissions-content" class="hidden">
|
||||
<div class="bg-base-200 rounded-lg shadow-xl overflow-hidden p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">
|
||||
Your Event Request Submissions
|
||||
</h2>
|
||||
|
||||
{
|
||||
error && (
|
||||
<div class="alert alert-error mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 stroke-current shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!error && (
|
||||
<UserEventRequests
|
||||
client:load
|
||||
eventRequests={userEventRequests}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style is:global>
|
||||
/* Ensure the modal container is always visible */
|
||||
#event-request-preview-modal-container {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 99999 !important;
|
||||
overflow: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Style for the modal backdrop */
|
||||
#event-request-preview-modal-container > div > div:first-child {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 99999 !important;
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* Style for the modal content */
|
||||
#event-request-preview-modal-container > div > div > div {
|
||||
z-index: 100000 !important;
|
||||
position: relative !important;
|
||||
max-width: 90vw !important;
|
||||
width: 100% !important;
|
||||
max-height: 90vh !important;
|
||||
overflow: auto !important;
|
||||
margin: 2rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Add the modal component -->
|
||||
<EventRequestFormPreviewModal client:load />
|
||||
|
||||
<div class="dashboard-section hidden" id="eventRequestFormSection">
|
||||
<!-- ... existing code ... -->
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
// Define the global function immediately to ensure it's available
|
||||
window.showEventRequestFormPreview = function (formData) {
|
||||
console.log(
|
||||
"Global showEventRequestFormPreview called with data",
|
||||
formData
|
||||
);
|
||||
|
||||
// Remove any elements that might be obstructing the view
|
||||
const removeObstructions = () => {
|
||||
// Find any elements with high z-index that might be obstructing
|
||||
document.querySelectorAll('[style*="z-index"]').forEach((el) => {
|
||||
if (
|
||||
el.id !== "event-request-preview-modal-container" &&
|
||||
!el.closest("#event-request-preview-modal-container")
|
||||
) {
|
||||
// Store original z-index to restore later
|
||||
if (!el.dataset.originalZIndex) {
|
||||
el.dataset.originalZIndex = el.style.zIndex;
|
||||
}
|
||||
// Temporarily lower z-index
|
||||
el.style.zIndex = "0";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Create a custom event to trigger the preview
|
||||
const event = new CustomEvent("showEventRequestPreviewModal", {
|
||||
detail: { formData },
|
||||
});
|
||||
|
||||
// Remove obstructions before showing modal
|
||||
removeObstructions();
|
||||
|
||||
// Dispatch event to show modal
|
||||
document.dispatchEvent(event);
|
||||
console.log("showEventRequestPreviewModal event dispatched");
|
||||
|
||||
// Ensure modal container is visible
|
||||
setTimeout(() => {
|
||||
const modalContainer = document.getElementById(
|
||||
"event-request-preview-modal-container"
|
||||
);
|
||||
if (modalContainer) {
|
||||
modalContainer.style.zIndex = "99999";
|
||||
modalContainer.style.position = "fixed";
|
||||
modalContainer.style.top = "0";
|
||||
modalContainer.style.left = "0";
|
||||
modalContainer.style.width = "100vw";
|
||||
modalContainer.style.height = "100vh";
|
||||
modalContainer.style.overflow = "auto";
|
||||
modalContainer.style.margin = "0";
|
||||
modalContainer.style.padding = "0";
|
||||
|
||||
// Force body to allow scrolling
|
||||
document.body.style.overflow = "auto";
|
||||
|
||||
// Ensure the modal content is properly sized
|
||||
const modalContent =
|
||||
modalContainer.querySelector("div > div > div");
|
||||
if (modalContent) {
|
||||
modalContent.style.maxWidth = "90vw";
|
||||
modalContent.style.width = "100%";
|
||||
modalContent.style.maxHeight = "90vh";
|
||||
modalContent.style.overflow = "auto";
|
||||
modalContent.style.margin = "2rem";
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Import the DataSyncService for client-side use
|
||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||
|
||||
// Tab switching logic
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Initialize DataSyncService for client-side
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Prefetch data into IndexedDB if authenticated
|
||||
if (auth.isAuthenticated()) {
|
||||
try {
|
||||
const userId = auth.getUserId();
|
||||
if (userId) {
|
||||
// Force sync to ensure we have the latest data
|
||||
await dataSync.syncCollection(
|
||||
Collections.EVENT_REQUESTS,
|
||||
`requested_user="${userId}"`,
|
||||
"-created"
|
||||
);
|
||||
console.log(
|
||||
"Initial data sync complete for user event requests"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error during initial data sync:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const formTab = document.getElementById("form-tab");
|
||||
const submissionsTab = document.getElementById("submissions-tab");
|
||||
const formContent = document.getElementById("form-content");
|
||||
const submissionsContent = document.getElementById(
|
||||
"submissions-content"
|
||||
);
|
||||
|
||||
// Function to switch tabs
|
||||
const switchTab = (
|
||||
activeTab: HTMLElement,
|
||||
activeContent: HTMLElement,
|
||||
inactiveTab: HTMLElement,
|
||||
inactiveContent: HTMLElement
|
||||
) => {
|
||||
// Update tab classes
|
||||
activeTab.classList.add("tab-active");
|
||||
inactiveTab.classList.remove("tab-active");
|
||||
|
||||
// Show/hide content
|
||||
activeContent.classList.remove("hidden");
|
||||
inactiveContent.classList.add("hidden");
|
||||
|
||||
// Dispatch event to refresh submissions when switching to submissions tab
|
||||
if (activeTab.id === "submissions-tab") {
|
||||
// Dispatch a custom event that the UserEventRequests component listens for
|
||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||
}
|
||||
};
|
||||
|
||||
// Add click event listeners to tabs
|
||||
formTab?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (formContent && submissionsContent && submissionsTab) {
|
||||
switchTab(
|
||||
formTab,
|
||||
formContent,
|
||||
submissionsTab,
|
||||
submissionsContent
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
submissionsTab?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (formContent && submissionsContent && formTab) {
|
||||
switchTab(
|
||||
submissionsTab,
|
||||
submissionsContent,
|
||||
formTab,
|
||||
formContent
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for visibility changes
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Dispatch custom event that components can listen for
|
||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,207 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import InvoiceBuilder from './InvoiceBuilder';
|
||||
import type { InvoiceData } from './InvoiceBuilder';
|
||||
|
||||
// Animation variants
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface ASFundingSectionProps {
|
||||
formData: EventRequestFormData;
|
||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||
}
|
||||
|
||||
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [invoiceFile, setInvoiceFile] = useState<File | null>(formData.invoice);
|
||||
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
|
||||
|
||||
// Handle single invoice file upload (for backward compatibility)
|
||||
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
setInvoiceFile(file);
|
||||
onDataChange({ invoice: file });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle multiple invoice files upload
|
||||
const handleMultipleInvoiceFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const files = Array.from(e.target.files) as File[];
|
||||
|
||||
// Check file sizes
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit per file
|
||||
const oversizedFiles = files.filter(file => file.size > MAX_FILE_SIZE);
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
toast.error(`Some files exceed the 10MB size limit: ${oversizedFiles.map(f => f.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state with new files
|
||||
const updatedFiles = [...invoiceFiles, ...files];
|
||||
setInvoiceFiles(updatedFiles);
|
||||
onDataChange({ invoice_files: updatedFiles });
|
||||
|
||||
// Also set the first file as the main invoice file for backward compatibility
|
||||
if (files.length > 0 && !formData.invoice) {
|
||||
setInvoiceFile(files[0]);
|
||||
onDataChange({ invoice: files[0] });
|
||||
}
|
||||
|
||||
toast.success(`Added ${files.length} file${files.length > 1 ? 's' : ''} successfully`);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove an invoice file
|
||||
const handleRemoveInvoiceFile = (index: number) => {
|
||||
const updatedFiles = [...invoiceFiles];
|
||||
const removedFileName = updatedFiles[index].name;
|
||||
updatedFiles.splice(index, 1);
|
||||
setInvoiceFiles(updatedFiles);
|
||||
onDataChange({ invoice_files: updatedFiles });
|
||||
|
||||
// Update the main invoice file if needed
|
||||
if (invoiceFile && invoiceFile.name === removedFileName) {
|
||||
const newMainFile = updatedFiles.length > 0 ? updatedFiles[0] : null;
|
||||
setInvoiceFile(newMainFile);
|
||||
onDataChange({ invoice: newMainFile });
|
||||
}
|
||||
|
||||
toast.success(`Removed ${removedFileName}`);
|
||||
};
|
||||
|
||||
// Handle invoice data change
|
||||
const handleInvoiceDataChange = (invoiceData: InvoiceData) => {
|
||||
// Update the invoiceData in the form
|
||||
onDataChange({ invoiceData });
|
||||
|
||||
// For backward compatibility, create a properly formatted JSON string
|
||||
const jsonFormat = {
|
||||
items: invoiceData.items.map(item => ({
|
||||
item: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice
|
||||
})),
|
||||
tax: invoiceData.taxAmount,
|
||||
tip: invoiceData.tipAmount,
|
||||
total: invoiceData.total,
|
||||
vendor: invoiceData.vendor
|
||||
};
|
||||
|
||||
// For backward compatibility, still update the itemized_invoice field
|
||||
// but with a more structured format that's easier to parse if needed
|
||||
const itemizedText = JSON.stringify(jsonFormat, null, 2);
|
||||
|
||||
// Update the itemized_invoice field for backward compatibility
|
||||
onDataChange({ itemized_invoice: itemizedText });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">AS Funding Information</h2>
|
||||
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-sm">
|
||||
Please make sure the restaurant is a valid AS Funding food vendor! An invoice can be an unofficial receipt. Just make sure that the restaurant name and location, desired pickup or delivery date and time, all the items ordered plus their prices, discount/fees/tax/tip, and total are on the invoice! We don't recommend paying out of pocket because reimbursements can be a hassle when you're not a Principal Member.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Builder Instructions */}
|
||||
<motion.div variants={itemVariants} className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<h3 className="font-bold text-lg mb-2">How to Use the Invoice Builder</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||
<li>Enter the vendor/restaurant name in the field provided.</li>
|
||||
<li>Add each item from your invoice by filling in the description, quantity, and unit price, then click "Add Item".</li>
|
||||
<li>The subtotal, tax, and tip will be calculated automatically based on the tax rate and tip percentage.</li>
|
||||
<li>You can adjust the tax rate (default is 7.75% for San Diego) and tip percentage as needed.</li>
|
||||
<li>Remove items by clicking the "X" button next to each item.</li>
|
||||
<li>Upload your actual invoice file (receipt, screenshot, etc.) using the file upload below.</li>
|
||||
</ol>
|
||||
<p className="text-sm mt-3 text-warning">Note: The invoice builder helps you itemize your expenses for AS funding. You must still upload the actual invoice file.</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Invoice Builder */}
|
||||
<InvoiceBuilder
|
||||
invoiceData={formData.invoiceData}
|
||||
onChange={handleInvoiceDataChange}
|
||||
/>
|
||||
|
||||
{/* Invoice file upload */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">
|
||||
Upload your invoice files (receipts, screenshots, etc.)
|
||||
</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
|
||||
onChange={handleMultipleInvoiceFilesChange}
|
||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
required={invoiceFiles.length === 0}
|
||||
/>
|
||||
|
||||
{invoiceFiles.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">Uploaded files:</p>
|
||||
<div className="space-y-2">
|
||||
{invoiceFiles.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-base-300/30 p-2 rounded">
|
||||
<span className="text-sm truncate max-w-[80%]">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => handleRemoveInvoiceFile(index)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Official food invoices will be required 2 weeks before the start of your event. Please use the following naming format: EventName_OrderLocation_DateOfEvent (i.e. QPWorkathon#1_PapaJohns_01/06/2025)
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="alert alert-warning"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-bold">Important Note</h3>
|
||||
<div className="text-sm">
|
||||
AS Funding requests must be submitted at least 6 weeks before your event. Please check the Funding Guide or the Google Calendar for the funding request deadlines.
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ASFundingSection;
|
|
@ -0,0 +1,147 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||
|
||||
// Animation variants
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface EventDetailsSectionProps {
|
||||
formData: EventRequestFormData;
|
||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||
}
|
||||
|
||||
const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onDataChange }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">Event Details</h2>
|
||||
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event Name */}
|
||||
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Event Name</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
||||
value={formData.name}
|
||||
onChange={(e) => onDataChange({ name: e.target.value })}
|
||||
placeholder="Enter event name"
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Event Description */}
|
||||
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Event Description</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px] mt-2"
|
||||
value={formData.event_description}
|
||||
onChange={(e) => onDataChange({ event_description: e.target.value })}
|
||||
placeholder="Provide a detailed description of your event"
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Event Start Date */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Event Start Date & Time</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={formData.start_date_time}
|
||||
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Event End Date */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Event End Date & Time</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={formData.end_date_time}
|
||||
onChange={(e) => onDataChange({ end_date_time: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Event Location */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Event Location</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={formData.location}
|
||||
onChange={(e) => onDataChange({ location: e.target.value })}
|
||||
placeholder="Enter event location"
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Room Booking */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Do you/will you have a room booking for this event?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.will_or_have_room_booking === true}
|
||||
onChange={() => onDataChange({ will_or_have_room_booking: true })}
|
||||
required
|
||||
/>
|
||||
<span>Yes</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.will_or_have_room_booking === false}
|
||||
onChange={() => onDataChange({ will_or_have_room_booking: false })}
|
||||
required
|
||||
/>
|
||||
<span>No</span>
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailsSection;
|
|
@ -0,0 +1,821 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { EventRequestStatus } from '../../../schemas/pocketbase';
|
||||
|
||||
// Form sections
|
||||
import PRSection from './PRSection';
|
||||
import EventDetailsSection from './EventDetailsSection';
|
||||
import TAPFormSection from './TAPFormSection';
|
||||
import ASFundingSection from './ASFundingSection';
|
||||
import EventRequestFormPreview from './EventRequestFormPreview';
|
||||
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Form data interface - based on the schema EventRequest but with form-specific fields
|
||||
export interface EventRequestFormData {
|
||||
// Fields from EventRequest
|
||||
name: string;
|
||||
location: string;
|
||||
start_date_time: string;
|
||||
end_date_time: string;
|
||||
event_description: string;
|
||||
flyers_needed: boolean;
|
||||
photography_needed: boolean;
|
||||
as_funding_required: boolean;
|
||||
food_drinks_being_served: boolean;
|
||||
itemized_invoice?: string;
|
||||
status?: string;
|
||||
created_by?: string;
|
||||
id?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
|
||||
// Additional form-specific fields
|
||||
flyer_type: string[];
|
||||
other_flyer_type: string;
|
||||
flyer_advertising_start_date: string;
|
||||
flyer_additional_requests: string;
|
||||
required_logos: string[];
|
||||
other_logos: File[]; // Form uses File objects, schema uses strings
|
||||
advertising_format: string;
|
||||
will_or_have_room_booking: boolean;
|
||||
expected_attendance: number;
|
||||
room_booking: File | null;
|
||||
invoice: File | null;
|
||||
invoice_files: File[];
|
||||
invoiceData: InvoiceData;
|
||||
needs_graphics?: boolean | null;
|
||||
needs_as_funding?: boolean | null;
|
||||
formReviewed?: boolean; // Track if the form has been reviewed
|
||||
}
|
||||
|
||||
const EventRequestForm: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize form data
|
||||
const [formData, setFormData] = useState<EventRequestFormData>({
|
||||
name: '',
|
||||
location: '',
|
||||
start_date_time: '',
|
||||
end_date_time: '',
|
||||
event_description: '',
|
||||
flyers_needed: false,
|
||||
flyer_type: [],
|
||||
other_flyer_type: '',
|
||||
flyer_advertising_start_date: '',
|
||||
flyer_additional_requests: '',
|
||||
photography_needed: false,
|
||||
required_logos: [],
|
||||
other_logos: [],
|
||||
advertising_format: '',
|
||||
will_or_have_room_booking: false,
|
||||
expected_attendance: 0,
|
||||
room_booking: null,
|
||||
as_funding_required: false,
|
||||
food_drinks_being_served: false,
|
||||
itemized_invoice: '',
|
||||
invoice: null,
|
||||
invoice_files: [], // Initialize empty array for multiple invoice files
|
||||
needs_graphics: null,
|
||||
needs_as_funding: false,
|
||||
invoiceData: {
|
||||
items: [],
|
||||
subtotal: 0,
|
||||
taxRate: 7.75, // Default tax rate for San Diego
|
||||
taxAmount: 0,
|
||||
tipPercentage: 15, // Default tip percentage
|
||||
tipAmount: 0,
|
||||
total: 0,
|
||||
vendor: ''
|
||||
},
|
||||
formReviewed: false // Initialize as false
|
||||
});
|
||||
|
||||
// Save form data to localStorage
|
||||
useEffect(() => {
|
||||
const formDataToSave = { ...formData };
|
||||
// Remove file objects before saving to localStorage
|
||||
const dataToStore = {
|
||||
...formDataToSave,
|
||||
other_logos: [],
|
||||
room_booking: null,
|
||||
invoice: null,
|
||||
invoice_files: []
|
||||
};
|
||||
|
||||
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
||||
|
||||
// Also update the preview data
|
||||
window.dispatchEvent(new CustomEvent('formDataUpdated', {
|
||||
detail: { formData: formDataToSave }
|
||||
}));
|
||||
}, [formData]);
|
||||
|
||||
// Load form data from localStorage on initial load
|
||||
useEffect(() => {
|
||||
const savedData = localStorage.getItem('eventRequestFormData');
|
||||
if (savedData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(savedData);
|
||||
setFormData(prevData => ({
|
||||
...prevData,
|
||||
...parsedData
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved form data:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle form section data changes
|
||||
const handleSectionDataChange = (sectionData: Partial<EventRequestFormData>) => {
|
||||
setFormData(prevData => ({
|
||||
...prevData,
|
||||
...sectionData
|
||||
}));
|
||||
};
|
||||
|
||||
// Add this function before the handleSubmit function
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
location: '',
|
||||
start_date_time: '',
|
||||
end_date_time: '',
|
||||
event_description: '',
|
||||
flyers_needed: false,
|
||||
flyer_type: [],
|
||||
other_flyer_type: '',
|
||||
flyer_advertising_start_date: '',
|
||||
flyer_additional_requests: '',
|
||||
photography_needed: false,
|
||||
required_logos: [],
|
||||
other_logos: [],
|
||||
advertising_format: '',
|
||||
will_or_have_room_booking: false,
|
||||
expected_attendance: 0,
|
||||
room_booking: null,
|
||||
as_funding_required: false,
|
||||
food_drinks_being_served: false,
|
||||
itemized_invoice: '',
|
||||
invoice: null,
|
||||
invoice_files: [], // Reset multiple invoice files
|
||||
needs_graphics: null,
|
||||
needs_as_funding: false,
|
||||
invoiceData: {
|
||||
items: [],
|
||||
subtotal: 0,
|
||||
taxRate: 7.75, // Default tax rate for San Diego
|
||||
taxAmount: 0,
|
||||
tipPercentage: 15, // Default tip percentage
|
||||
tipAmount: 0,
|
||||
total: 0,
|
||||
vendor: ''
|
||||
},
|
||||
formReviewed: false // Reset review status
|
||||
});
|
||||
|
||||
// Reset to first step
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if the form has been reviewed
|
||||
if (!formData.formReviewed) {
|
||||
toast.error('Please review your form before submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const fileManager = FileManager.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
toast.error('You must be logged in to submit an event request');
|
||||
throw new Error('You must be logged in to submit an event request');
|
||||
}
|
||||
|
||||
// Create the event request record
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
toast.error('User ID not found');
|
||||
throw new Error('User ID not found');
|
||||
}
|
||||
|
||||
// Prepare data for submission
|
||||
const submissionData = {
|
||||
requested_user: userId,
|
||||
name: formData.name,
|
||||
location: formData.location,
|
||||
start_date_time: new Date(formData.start_date_time).toISOString(),
|
||||
end_date_time: new Date(formData.end_date_time).toISOString(),
|
||||
event_description: formData.event_description,
|
||||
flyers_needed: formData.flyers_needed,
|
||||
flyer_type: formData.flyer_type,
|
||||
other_flyer_type: formData.other_flyer_type,
|
||||
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
|
||||
flyer_additional_requests: formData.flyer_additional_requests,
|
||||
photography_needed: formData.photography_needed,
|
||||
required_logos: formData.required_logos,
|
||||
advertising_format: formData.advertising_format,
|
||||
will_or_have_room_booking: formData.will_or_have_room_booking,
|
||||
expected_attendance: formData.expected_attendance,
|
||||
as_funding_required: formData.as_funding_required,
|
||||
food_drinks_being_served: formData.food_drinks_being_served,
|
||||
// Store the itemized_invoice as a string for backward compatibility
|
||||
itemized_invoice: formData.itemized_invoice,
|
||||
// Store the invoice data as a properly formatted JSON object
|
||||
invoice_data: {
|
||||
items: formData.invoiceData.items.map(item => ({
|
||||
item: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice
|
||||
})),
|
||||
tax: formData.invoiceData.taxAmount,
|
||||
tip: formData.invoiceData.tipAmount,
|
||||
total: formData.invoiceData.total,
|
||||
vendor: formData.invoiceData.vendor
|
||||
},
|
||||
// Set the initial status to "submitted"
|
||||
status: EventRequestStatus.SUBMITTED,
|
||||
};
|
||||
|
||||
// Create the record using the Update service
|
||||
// This will send the data to the server
|
||||
const record = await update.create('event_request', submissionData);
|
||||
|
||||
// Force sync the event requests collection to update IndexedDB
|
||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||
|
||||
// Upload files if they exist
|
||||
if (formData.other_logos.length > 0) {
|
||||
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
||||
}
|
||||
|
||||
if (formData.room_booking) {
|
||||
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
|
||||
}
|
||||
|
||||
// Upload multiple invoice files
|
||||
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
||||
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
|
||||
|
||||
// For backward compatibility, also upload the first file as the main invoice
|
||||
if (formData.invoice || formData.invoice_files[0]) {
|
||||
const mainInvoice = formData.invoice || formData.invoice_files[0];
|
||||
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
|
||||
}
|
||||
} else if (formData.invoice) {
|
||||
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
||||
}
|
||||
|
||||
// Clear form data from localStorage
|
||||
localStorage.removeItem('eventRequestFormData');
|
||||
|
||||
// Keep success toast for form submission since it's a user action
|
||||
toast.success('Event request submitted successfully!');
|
||||
|
||||
// Reset form
|
||||
resetForm();
|
||||
|
||||
// Switch to the submissions tab
|
||||
const submissionsTab = document.getElementById('submissions-tab');
|
||||
if (submissionsTab) {
|
||||
submissionsTab.click();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting event request:', error);
|
||||
toast.error('Failed to submit event request. Please try again.');
|
||||
setError('Failed to submit event request. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate PR Section
|
||||
const validatePRSection = () => {
|
||||
if (formData.flyer_type.length === 0) {
|
||||
toast.error('Please select at least one flyer type');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.flyer_type.includes('other') && !formData.other_flyer_type) {
|
||||
toast.error('Please specify the other flyer type');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.flyer_type.some(type =>
|
||||
type === 'digital_with_social' ||
|
||||
type === 'physical_with_advertising' ||
|
||||
type === 'newsletter'
|
||||
) && !formData.flyer_advertising_start_date) {
|
||||
toast.error('Please specify when to start advertising');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.required_logos.includes('OTHER') && (!formData.other_logos || formData.other_logos.length === 0)) {
|
||||
toast.error('Please upload your logo files');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.advertising_format) {
|
||||
toast.error('Please select a format');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.photography_needed === null || formData.photography_needed === undefined) {
|
||||
toast.error('Please specify if photography is needed');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Validate Event Details Section
|
||||
const validateEventDetailsSection = () => {
|
||||
if (!formData.name) {
|
||||
toast.error('Please enter an event name');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.event_description) {
|
||||
toast.error('Please enter an event description');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.start_date_time) {
|
||||
toast.error('Please enter a start date and time');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.end_date_time) {
|
||||
toast.error('Please enter an end date and time');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.location) {
|
||||
toast.error('Please enter an event location');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.will_or_have_room_booking === null || formData.will_or_have_room_booking === undefined) {
|
||||
toast.error('Please specify if you have a room booking');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Validate TAP Form Section
|
||||
const validateTAPFormSection = () => {
|
||||
if (!formData.expected_attendance) {
|
||||
toast.error('Please enter the expected attendance');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.room_booking && formData.will_or_have_room_booking) {
|
||||
toast.error('Please upload your room booking confirmation');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.food_drinks_being_served === null || formData.food_drinks_being_served === undefined) {
|
||||
toast.error('Please specify if food/drinks will be served');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Validate AS Funding Section
|
||||
const validateASFundingSection = () => {
|
||||
if (formData.needs_as_funding) {
|
||||
// Check if vendor is provided
|
||||
if (!formData.invoiceData.vendor) {
|
||||
toast.error('Please enter the vendor/restaurant name');
|
||||
return false;
|
||||
}
|
||||
|
||||
// No longer require items in the invoice
|
||||
// Check if at least one invoice file is uploaded
|
||||
if (!formData.invoice && (!formData.invoice_files || formData.invoice_files.length === 0)) {
|
||||
toast.error('Please upload at least one invoice file');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Validate all sections before submission
|
||||
const validateAllSections = () => {
|
||||
// Validate Event Details
|
||||
if (!validateEventDetailsSection()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate TAP Form
|
||||
if (!validateTAPFormSection()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate PR Section if needed
|
||||
if (formData.needs_graphics && !validatePRSection()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate AS Funding if needed
|
||||
if (formData.food_drinks_being_served && formData.needs_as_funding && !validateASFundingSection()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Handle next button click with validation
|
||||
const handleNextStep = (nextStep: number) => {
|
||||
let isValid = true;
|
||||
|
||||
// Validate current section before proceeding
|
||||
if (currentStep === 2 && formData.needs_graphics) {
|
||||
isValid = validatePRSection();
|
||||
} else if (currentStep === 3) {
|
||||
isValid = validateEventDetailsSection();
|
||||
} else if (currentStep === 4) {
|
||||
isValid = validateTAPFormSection();
|
||||
} else if (currentStep === 5 && formData.food_drinks_being_served && formData.needs_as_funding) {
|
||||
isValid = validateASFundingSection();
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
// Set the current step
|
||||
setCurrentStep(nextStep);
|
||||
|
||||
// If moving to the review step, mark the form as reviewed
|
||||
// but don't submit it automatically
|
||||
if (nextStep === 6) {
|
||||
setFormData(prevData => ({
|
||||
...prevData,
|
||||
formReviewed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission with validation
|
||||
const handleSubmitWithValidation = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// If we're on the review step, we've already validated all sections
|
||||
// Only submit if the user explicitly clicks the submit button
|
||||
if (currentStep === 6 && formData.formReviewed) {
|
||||
handleSubmit(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, validate all sections before proceeding to the review step
|
||||
if (validateAllSections()) {
|
||||
// If we're not on the review step, go to the review step instead of submitting
|
||||
handleNextStep(6);
|
||||
}
|
||||
};
|
||||
|
||||
// Render the current step
|
||||
const renderCurrentSection = () => {
|
||||
// Step 1: Ask if they need graphics from the design team
|
||||
if (currentStep === 1) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">Event Request Form</h2>
|
||||
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
Welcome to the IEEE UCSD Event Request Form. This form will help you request PR materials,
|
||||
provide event details, and request AS funding if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-base-200/50 p-6 rounded-lg">
|
||||
<h3 className="text-xl font-semibold mb-4">Do you need graphics from the design team?</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
className={`btn btn-lg ${formData.needs_graphics ? 'btn-primary' : 'btn-outline'} flex-1`}
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, needs_graphics: true, flyers_needed: true });
|
||||
setCurrentStep(2);
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-lg ${!formData.needs_graphics && formData.needs_graphics !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, needs_graphics: false, flyers_needed: false });
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: PR Section (if they need graphics)
|
||||
if (currentStep === 2) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
<PRSection formData={formData} onDataChange={handleSectionDataChange} />
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button className="btn btn-outline" onClick={() => setCurrentStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => handleNextStep(3)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Event Details Section
|
||||
if (currentStep === 3) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
<EventDetailsSection formData={formData} onDataChange={handleSectionDataChange} />
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button className="btn btn-outline" onClick={() => setCurrentStep(formData.needs_graphics ? 2 : 1)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => handleNextStep(4)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: TAP Form Section
|
||||
if (currentStep === 4) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
<TAPFormSection formData={formData} onDataChange={handleSectionDataChange} />
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button className="btn btn-outline" onClick={() => setCurrentStep(3)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => handleNextStep(5)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: AS Funding Section
|
||||
if (currentStep === 5) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
{formData.food_drinks_being_served && (
|
||||
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
|
||||
<h3 className="text-xl font-semibold mb-4">Do you need AS funding for this event?</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
className={`btn btn-lg ${formData.needs_as_funding ? 'btn-primary' : 'btn-outline'} flex-1`}
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, needs_as_funding: true, as_funding_required: true });
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-lg ${!formData.needs_as_funding && formData.needs_as_funding !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, needs_as_funding: false, as_funding_required: false });
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formData.food_drinks_being_served && (
|
||||
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
|
||||
<h3 className="text-xl font-semibold mb-4">AS Funding Information</h3>
|
||||
<p className="mb-4">Since you're not serving food or drinks, AS funding is not applicable for this event.</p>
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p>If you need to request AS funding for other purposes, please contact the AS office directly.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.needs_as_funding && formData.food_drinks_being_served && (
|
||||
<ASFundingSection formData={formData} onDataChange={handleSectionDataChange} />
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button className="btn btn-outline" onClick={() => setCurrentStep(4)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => handleNextStep(6)}>
|
||||
Review Form
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 6: Review Form
|
||||
if (currentStep === 6) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">Review Your Event Request</h2>
|
||||
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
Please review all information carefully before submitting. You can go back to any section to make changes if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-base-200/50 p-6 rounded-lg">
|
||||
<EventRequestFormPreview formData={formData} isModal={false} />
|
||||
|
||||
<div className="divider my-6">Ready to Submit?</div>
|
||||
|
||||
<div className="alert alert-info mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p>Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<button className="btn btn-outline" onClick={() => setCurrentStep(5)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success btn-lg"
|
||||
onClick={handleSubmitWithValidation}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner"></span>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Event Request'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
// Prevent default form submission behavior
|
||||
e.preventDefault();
|
||||
// Only submit if the user explicitly clicks the submit button
|
||||
// The actual submission is handled by handleSubmitWithValidation
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="alert alert-error shadow-lg"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="w-full mb-6">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm font-medium">Step {currentStep} of 6</span>
|
||||
<span className="text-sm font-medium">{Math.min(Math.round((currentStep / 6) * 100), 100)}% complete</span>
|
||||
</div>
|
||||
<div className="w-full bg-base-300 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-primary h-2.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min((currentStep / 6) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current section */}
|
||||
<AnimatePresence mode="wait">
|
||||
{renderCurrentSection()}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventRequestForm;
|
|
@ -0,0 +1,497 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import type { InvoiceItem } from './InvoiceBuilder';
|
||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||
import { FlyerTypes, LogoOptions, EventRequestStatus } from '../../../schemas/pocketbase';
|
||||
|
||||
// Create a standalone component that can be used to show the preview as a modal
|
||||
export const EventRequestFormPreviewModal: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [formData, setFormData] = useState<EventRequestFormData | null>(null);
|
||||
|
||||
// Function to handle showing the modal
|
||||
const showModal = (data: any) => {
|
||||
console.log('showModal called with data', data);
|
||||
setFormData(data);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Add the global function to the window object directly from the component
|
||||
useEffect(() => {
|
||||
// Store the original function if it exists
|
||||
const originalFunction = window.showEventRequestFormPreview;
|
||||
|
||||
// Define the global function
|
||||
window.showEventRequestFormPreview = (data: any) => {
|
||||
console.log('Global showEventRequestFormPreview called with data', data);
|
||||
showModal(data);
|
||||
};
|
||||
|
||||
// Listen for the custom event as a fallback
|
||||
const handleShowModal = (event: CustomEvent) => {
|
||||
console.log('Received showEventRequestPreviewModal event', event.detail);
|
||||
if (event.detail && event.detail.formData) {
|
||||
showModal(event.detail.formData);
|
||||
} else {
|
||||
console.error('Event detail or formData is missing', event.detail);
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener('showEventRequestPreviewModal', handleShowModal as EventListener);
|
||||
console.log('Event listener for showEventRequestPreviewModal added');
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
// Restore the original function if it existed
|
||||
if (originalFunction) {
|
||||
window.showEventRequestFormPreview = originalFunction;
|
||||
} else {
|
||||
// Otherwise delete our function
|
||||
delete window.showEventRequestFormPreview;
|
||||
}
|
||||
|
||||
document.removeEventListener('showEventRequestPreviewModal', handleShowModal as EventListener);
|
||||
console.log('Event listener for showEventRequestPreviewModal removed');
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
const handleClose = () => {
|
||||
console.log('Modal closed');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Force the modal to be in the document body to avoid nesting issues
|
||||
return (
|
||||
<div
|
||||
id="event-request-preview-modal-container"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 99999,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
<EventRequestFormPreview
|
||||
formData={formData || undefined}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
isModal={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface EventRequestFormPreviewProps {
|
||||
formData?: EventRequestFormData; // Optional prop to directly pass form data
|
||||
isOpen?: boolean; // Control whether the modal is open
|
||||
onClose?: () => void; // Callback when modal is closed
|
||||
isModal?: boolean; // Whether to render as a modal or inline component
|
||||
}
|
||||
|
||||
const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
||||
formData: propFormData,
|
||||
isOpen = true,
|
||||
onClose = () => { },
|
||||
isModal = false
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<EventRequestFormData | null>(propFormData || null);
|
||||
const [loading, setLoading] = useState<boolean>(!propFormData);
|
||||
|
||||
// Load form data from localStorage on initial load and when updated
|
||||
useEffect(() => {
|
||||
// If formData is provided as a prop, use it directly
|
||||
if (propFormData) {
|
||||
setFormData(propFormData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFormData = () => {
|
||||
setLoading(true);
|
||||
const savedData = localStorage.getItem('eventRequestFormData');
|
||||
if (savedData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(savedData);
|
||||
setFormData(parsedData);
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved form data:', e);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Load initial data
|
||||
loadFormData();
|
||||
|
||||
// Listen for form data updates
|
||||
const handleFormDataUpdate = (event: CustomEvent) => {
|
||||
if (event.detail && event.detail.formData) {
|
||||
setFormData(event.detail.formData);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('formDataUpdated', handleFormDataUpdate as EventListener);
|
||||
document.addEventListener('updatePreview', loadFormData);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('formDataUpdated', handleFormDataUpdate as EventListener);
|
||||
document.removeEventListener('updatePreview', loadFormData);
|
||||
};
|
||||
}, [propFormData]);
|
||||
|
||||
// Format date and time for display
|
||||
const formatDateTime = (dateTimeString: string) => {
|
||||
if (!dateTimeString) return 'Not specified';
|
||||
|
||||
try {
|
||||
const date = new Date(dateTimeString);
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
return dateTimeString;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click on the backdrop to close the modal
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Render the content of the preview
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formData) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-xl font-bold mb-4">No Form Data Available</h3>
|
||||
<p className="text-gray-400">Please fill out the form to see a preview.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className={`${isModal ? 'bg-base-300' : ''} p-6 rounded-lg`}>
|
||||
{isModal && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-4">Event Request Preview</h2>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
This is a preview of your event request. Please review all information before submitting.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Event Details Section */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
|
||||
Event Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Event Name</p>
|
||||
<p className="font-medium">{formData.name || 'Not specified'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Location</p>
|
||||
<p className="font-medium">{formData.location || 'Not specified'}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<p className="text-sm text-gray-400">Event Description</p>
|
||||
<p className="font-medium whitespace-pre-line">{formData.event_description || 'Not specified'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Start Date & Time</p>
|
||||
<p className="font-medium">{formatDateTime(formData.start_date_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">End Date & Time</p>
|
||||
<p className="font-medium">{formatDateTime(formData.end_date_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Room Booking</p>
|
||||
<p className="font-medium">{formData.will_or_have_room_booking ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Expected Attendance</p>
|
||||
<p className="font-medium">{formData.expected_attendance || 'Not specified'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PR Materials Section */}
|
||||
{formData.flyers_needed && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
|
||||
PR Materials
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Flyer Types</p>
|
||||
<ul className="list-disc list-inside">
|
||||
{formData.flyer_type.map((type, index) => (
|
||||
<li key={index} className="font-medium">
|
||||
{type === 'digital_with_social' && 'Digital flyer with social media advertising'}
|
||||
{type === 'digital_no_social' && 'Digital flyer without social media advertising'}
|
||||
{type === 'physical_with_advertising' && 'Physical flyer with advertising'}
|
||||
{type === 'physical_no_advertising' && 'Physical flyer without advertising'}
|
||||
{type === 'newsletter' && 'Newsletter'}
|
||||
{type === 'other' && 'Other: ' + formData.other_flyer_type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Advertising Start Date</p>
|
||||
<p className="font-medium">{formData.flyer_advertising_start_date || 'Not specified'}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<p className="text-sm text-gray-400">Required Logos</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formData.required_logos.map((logo, index) => (
|
||||
<span key={index} className="badge badge-primary">{logo}</span>
|
||||
))}
|
||||
{formData.required_logos.length === 0 && <p className="font-medium">None specified</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Advertising Format</p>
|
||||
<p className="font-medium">
|
||||
{formData.advertising_format === 'pdf' && 'PDF'}
|
||||
{formData.advertising_format === 'jpeg' && 'JPEG'}
|
||||
{formData.advertising_format === 'png' && 'PNG'}
|
||||
{formData.advertising_format === 'does_not_matter' && 'Does not matter'}
|
||||
{!formData.advertising_format && 'Not specified'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Photography Needed</p>
|
||||
<p className="font-medium">{formData.photography_needed ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
{formData.flyer_additional_requests && (
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<p className="text-sm text-gray-400">Additional Requests</p>
|
||||
<p className="font-medium whitespace-pre-line">{formData.flyer_additional_requests}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAP Form Section */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
|
||||
TAP Form Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Expected Attendance</p>
|
||||
<p className="font-medium">{formData.expected_attendance || 'Not specified'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Room Booking</p>
|
||||
<p className="font-medium">
|
||||
{formData.room_booking ? formData.room_booking.name : 'No file uploaded'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">AS Funding Required</p>
|
||||
<p className="font-medium">{formData.as_funding_required ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Food/Drinks Being Served</p>
|
||||
<p className="font-medium">{formData.food_drinks_being_served ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AS Funding Section */}
|
||||
{formData.as_funding_required && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
|
||||
AS Funding Details
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Vendor</p>
|
||||
<p className="font-medium">{formData.invoiceData.vendor || 'Not specified'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.invoiceData.items.length > 0 ? (
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="text-right">Qty</th>
|
||||
<th className="text-right">Unit Price</th>
|
||||
<th className="text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.invoiceData.items.map((item: InvoiceItem) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.description}</td>
|
||||
<td className="text-right">{item.quantity}</td>
|
||||
<td className="text-right">${item.unitPrice.toFixed(2)}</td>
|
||||
<td className="text-right">${item.amount.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
|
||||
<td className="text-right">${formData.invoiceData.subtotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tax ({formData.invoiceData.taxRate}%):</td>
|
||||
<td className="text-right">${formData.invoiceData.taxAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tip ({formData.invoiceData.tipPercentage}%):</td>
|
||||
<td className="text-right">${formData.invoiceData.tipAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-bold">Total:</td>
|
||||
<td className="text-right font-bold">${formData.invoiceData.total.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-info mb-4">
|
||||
<div>No invoice items have been added yet.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-4">
|
||||
<p className="text-sm text-gray-400 mb-2">JSON Format (For Submission):</p>
|
||||
<pre className="bg-base-300 p-4 rounded-lg overflow-x-auto text-xs">
|
||||
{JSON.stringify({
|
||||
items: formData.invoiceData.items.map(item => ({
|
||||
item: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice
|
||||
})),
|
||||
tax: formData.invoiceData.taxAmount,
|
||||
tip: formData.invoiceData.tipAmount,
|
||||
total: formData.invoiceData.total,
|
||||
vendor: formData.invoiceData.vendor
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
This is the structured format that will be submitted to our database.
|
||||
It ensures that your invoice data is properly organized and can be
|
||||
processed correctly by our system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Invoice Files</p>
|
||||
{formData.invoice_files && formData.invoice_files.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{formData.invoice_files.map((file, index) => (
|
||||
<p key={index} className="font-medium">{file.name}</p>
|
||||
))}
|
||||
</div>
|
||||
) : formData.invoice ? (
|
||||
<p className="font-medium">{formData.invoice.name}</p>
|
||||
) : (
|
||||
<p className="font-medium">No files uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// If not a modal, render the content directly
|
||||
if (!isModal) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="w-full"
|
||||
>
|
||||
{renderContent()}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// If it's a modal, render with the modal wrapper
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/80 backdrop-blur-md overflow-hidden"
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 99999,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="bg-base-100 rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] overflow-y-auto m-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 100000,
|
||||
maxWidth: '90vw',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-base-100 px-6 py-4 border-b border-base-300 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">Event Request Preview</h2>
|
||||
<button
|
||||
className="btn btn-sm btn-circle btn-ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventRequestFormPreview;
|
|
@ -0,0 +1,423 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Animation variants
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Invoice item interface
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
// Invoice data interface
|
||||
export interface InvoiceData {
|
||||
items: InvoiceItem[];
|
||||
subtotal: number;
|
||||
taxRate: number;
|
||||
taxAmount: number;
|
||||
tipPercentage: number;
|
||||
tipAmount: number;
|
||||
total: number;
|
||||
vendor: string;
|
||||
}
|
||||
|
||||
interface InvoiceBuilderProps {
|
||||
invoiceData: InvoiceData;
|
||||
onChange: (data: InvoiceData) => void;
|
||||
}
|
||||
|
||||
const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }) => {
|
||||
// State for new item form
|
||||
const [newItem, setNewItem] = useState<Omit<InvoiceItem, 'id' | 'amount'>>({
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0
|
||||
});
|
||||
|
||||
// Use a counter for generating IDs to avoid hydration issues
|
||||
const [idCounter, setIdCounter] = useState(1);
|
||||
|
||||
// Generate a unique ID for new items without using non-deterministic functions
|
||||
const generateId = () => {
|
||||
const id = `item-${idCounter}`;
|
||||
setIdCounter(prev => prev + 1);
|
||||
return id;
|
||||
};
|
||||
|
||||
// State for validation errors
|
||||
const [errors, setErrors] = useState<{
|
||||
description?: string;
|
||||
quantity?: string;
|
||||
unitPrice?: string;
|
||||
vendor?: string;
|
||||
}>({});
|
||||
|
||||
// Calculate totals whenever invoice data changes
|
||||
useEffect(() => {
|
||||
calculateTotals();
|
||||
}, [invoiceData.items, invoiceData.taxRate, invoiceData.tipPercentage]);
|
||||
|
||||
// Calculate all totals
|
||||
const calculateTotals = () => {
|
||||
// Calculate subtotal
|
||||
const subtotal = invoiceData.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
// Calculate tax amount (ensure it's based on the current subtotal)
|
||||
const taxAmount = subtotal * (invoiceData.taxRate / 100);
|
||||
|
||||
// Calculate tip amount (ensure it's based on the current subtotal)
|
||||
const tipAmount = subtotal * (invoiceData.tipPercentage / 100);
|
||||
|
||||
// Calculate total
|
||||
const total = subtotal + taxAmount + tipAmount;
|
||||
|
||||
// Update invoice data
|
||||
onChange({
|
||||
...invoiceData,
|
||||
subtotal,
|
||||
taxAmount,
|
||||
tipAmount,
|
||||
total
|
||||
});
|
||||
};
|
||||
|
||||
// Validate new item before adding
|
||||
const validateNewItem = () => {
|
||||
const newErrors: {
|
||||
description?: string;
|
||||
quantity?: string;
|
||||
unitPrice?: string;
|
||||
vendor?: string;
|
||||
} = {};
|
||||
|
||||
if (!newItem.description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
}
|
||||
|
||||
if (newItem.quantity <= 0) {
|
||||
newErrors.quantity = 'Quantity must be greater than 0';
|
||||
}
|
||||
|
||||
if (newItem.unitPrice < 0) {
|
||||
newErrors.unitPrice = 'Unit price must be 0 or greater';
|
||||
}
|
||||
|
||||
// Check for duplicate description
|
||||
const isDuplicate = invoiceData.items.some(
|
||||
item => item.description.toLowerCase() === newItem.description.toLowerCase()
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
newErrors.description = 'An item with this description already exists';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Add a new item to the invoice
|
||||
const handleAddItem = () => {
|
||||
if (!validateNewItem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate amount
|
||||
const amount = newItem.quantity * newItem.unitPrice;
|
||||
|
||||
// Create new item
|
||||
const item: InvoiceItem = {
|
||||
id: generateId(),
|
||||
description: newItem.description,
|
||||
quantity: newItem.quantity,
|
||||
unitPrice: newItem.unitPrice,
|
||||
amount
|
||||
};
|
||||
|
||||
// Add item to invoice
|
||||
onChange({
|
||||
...invoiceData,
|
||||
items: [...invoiceData.items, item]
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
toast.success(`Added ${item.description} to invoice`);
|
||||
|
||||
// Reset new item form
|
||||
setNewItem({
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0
|
||||
});
|
||||
|
||||
// Clear errors
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
// Remove an item
|
||||
const handleRemoveItem = (id: string) => {
|
||||
const itemToRemove = invoiceData.items.find(item => item.id === id);
|
||||
onChange({
|
||||
...invoiceData,
|
||||
items: invoiceData.items.filter(item => item.id !== id)
|
||||
});
|
||||
|
||||
if (itemToRemove) {
|
||||
toast.success(`Removed ${itemToRemove.description} from invoice`);
|
||||
}
|
||||
};
|
||||
|
||||
// Update tax rate
|
||||
const handleTaxRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
onChange({
|
||||
...invoiceData,
|
||||
taxRate: isNaN(value) ? 0 : Math.max(0, value)
|
||||
});
|
||||
};
|
||||
|
||||
// Update tip percentage
|
||||
const handleTipPercentageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
onChange({
|
||||
...invoiceData,
|
||||
tipPercentage: isNaN(value) ? 0 : Math.max(0, value)
|
||||
});
|
||||
};
|
||||
|
||||
// Update vendor
|
||||
const handleVendorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...invoiceData,
|
||||
vendor: e.target.value
|
||||
});
|
||||
|
||||
// Clear vendor error if it exists
|
||||
if (errors.vendor && e.target.value.trim()) {
|
||||
setErrors({ ...errors, vendor: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div variants={itemVariants} className="space-y-6">
|
||||
<div className="bg-base-200/50 p-4 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">Invoice Builder</h3>
|
||||
|
||||
{/* AS Funding Limit Notice */}
|
||||
<div className="alert alert-warning mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="font-bold">AS Funding Limits:</span> Maximum of $10.00 per expected student attendee and $5,000 per event.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor information */}
|
||||
<div className="form-control mb-4">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Vendor/Restaurant Name</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`input input-bordered ${errors.vendor ? 'input-error' : ''}`}
|
||||
value={invoiceData.vendor}
|
||||
onChange={handleVendorChange}
|
||||
placeholder="e.g. L&L Hawaiian Barbeque"
|
||||
/>
|
||||
{errors.vendor && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error">{errors.vendor}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="text-right">Qty</th>
|
||||
<th className="text-right">Unit Price</th>
|
||||
<th className="text-right">Amount</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoiceData.items.map(item => (
|
||||
<tr key={item.id} className="hover">
|
||||
<td>{item.description}</td>
|
||||
<td className="text-right">{item.quantity}</td>
|
||||
<td className="text-right">{formatCurrency(item.unitPrice)}</td>
|
||||
<td className="text-right">{formatCurrency(item.amount)}</td>
|
||||
<td className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{invoiceData.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-4 text-gray-500">
|
||||
No items added yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add new item form */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`input input-bordered input-sm ${errors.description ? 'input-error' : ''}`}
|
||||
value={newItem.description}
|
||||
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
|
||||
placeholder="e.g. Chicken Cutlet with Gravy"
|
||||
/>
|
||||
{errors.description && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error">{errors.description}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Quantity</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
className={`input input-bordered input-sm ${errors.quantity ? 'input-error' : ''}`}
|
||||
value={newItem.quantity}
|
||||
onChange={(e) => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
|
||||
min="1"
|
||||
step="1"
|
||||
/>
|
||||
{errors.quantity && <div className="text-error text-xs mt-1">{errors.quantity}</div>}
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Unit Price ($)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="unitPrice"
|
||||
className={`input input-bordered input-sm ${errors.unitPrice ? 'input-error' : ''}`}
|
||||
value={newItem.unitPrice}
|
||||
onChange={(e) => setNewItem({ ...newItem, unitPrice: parseFloat(e.target.value) || 0 })}
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
{errors.unitPrice && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error">{errors.unitPrice}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleAddItem}
|
||||
>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tax and tip */}
|
||||
<div className="divider"></div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Tax Rate (%)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered input-sm"
|
||||
value={invoiceData.taxRate}
|
||||
onChange={handleTaxRateChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Tip Percentage (%)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered input-sm"
|
||||
value={invoiceData.tipPercentage}
|
||||
onChange={handleTipPercentageChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-base-300/30 p-4 rounded-lg">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>Subtotal:</span>
|
||||
<span className="font-medium">{formatCurrency(invoiceData.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>Tax ({invoiceData.taxRate}%):</span>
|
||||
<span className="font-medium">{formatCurrency(invoiceData.taxAmount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>Tip ({invoiceData.tipPercentage}%):</span>
|
||||
<span className="font-medium">{formatCurrency(invoiceData.tipAmount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Total:</span>
|
||||
<span>{formatCurrency(invoiceData.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceBuilder;
|
266
src/components/dashboard/Officer_EventRequestForm/PRSection.tsx
Normal file
266
src/components/dashboard/Officer_EventRequestForm/PRSection.tsx
Normal file
|
@ -0,0 +1,266 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||
import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
|
||||
|
||||
// Animation variants
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Flyer type options
|
||||
const FLYER_TYPES = [
|
||||
{ value: FlyerTypes.DIGITAL_WITH_SOCIAL, label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' },
|
||||
{ value: FlyerTypes.DIGITAL_NO_SOCIAL, label: 'Digital flyer (with NO social media advertising)' },
|
||||
{ value: FlyerTypes.PHYSICAL_WITH_ADVERTISING, label: 'Physical flyer (with advertising)' },
|
||||
{ value: FlyerTypes.PHYSICAL_NO_ADVERTISING, label: 'Physical flyer (with NO advertising)' },
|
||||
{ value: FlyerTypes.NEWSLETTER, label: 'Newsletter (IEEE, ECE, IDEA)' },
|
||||
{ value: FlyerTypes.OTHER, label: 'Other' }
|
||||
];
|
||||
|
||||
// Logo options
|
||||
const LOGO_OPTIONS = [
|
||||
{ value: LogoOptions.IEEE, label: 'IEEE' },
|
||||
{ value: LogoOptions.AS, label: 'AS (required if funded by AS)' },
|
||||
{ value: LogoOptions.HKN, label: 'HKN' },
|
||||
{ value: LogoOptions.TESC, label: 'TESC' },
|
||||
{ value: LogoOptions.PIB, label: 'PIB' },
|
||||
{ value: LogoOptions.TNT, label: 'TNT' },
|
||||
{ value: LogoOptions.SWE, label: 'SWE' },
|
||||
{ value: LogoOptions.OTHER, label: 'OTHER (please upload transparent logo files)' }
|
||||
];
|
||||
|
||||
// Format options
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'jpeg', label: 'JPEG' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'does_not_matter', label: 'DOES NOT MATTER' }
|
||||
];
|
||||
|
||||
interface PRSectionProps {
|
||||
formData: EventRequestFormData;
|
||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||
}
|
||||
|
||||
const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
|
||||
|
||||
// Handle checkbox change for flyer types
|
||||
const handleFlyerTypeChange = (type: string) => {
|
||||
const updatedTypes = formData.flyer_type.includes(type)
|
||||
? formData.flyer_type.filter(t => t !== type)
|
||||
: [...formData.flyer_type, type];
|
||||
|
||||
onDataChange({ flyer_type: updatedTypes });
|
||||
};
|
||||
|
||||
// Handle checkbox change for required logos
|
||||
const handleLogoChange = (logo: string) => {
|
||||
const updatedLogos = formData.required_logos.includes(logo)
|
||||
? formData.required_logos.filter(l => l !== logo)
|
||||
: [...formData.required_logos, logo];
|
||||
|
||||
onDataChange({ required_logos: updatedLogos });
|
||||
};
|
||||
|
||||
// Handle file upload for other logos
|
||||
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const newFiles = Array.from(e.target.files) as File[];
|
||||
setOtherLogoFiles(newFiles);
|
||||
onDataChange({ other_logos: newFiles });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">PR Materials</h2>
|
||||
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Type of material needed */}
|
||||
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Type of material needed?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{FLYER_TYPES.map((type) => (
|
||||
<label key={type.value} className="flex items-start gap-2 cursor-pointer hover:bg-base-300/30 p-2 rounded-md transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary mt-1"
|
||||
checked={formData.flyer_type.includes(type.value)}
|
||||
onChange={() => handleFlyerTypeChange(type.value)}
|
||||
/>
|
||||
<span>{type.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Other flyer type input */}
|
||||
{formData.flyer_type.includes(FlyerTypes.OTHER) && (
|
||||
<div className="mt-3 pl-7">
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Please specify other material needed"
|
||||
value={formData.other_flyer_type}
|
||||
onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Advertising start date */}
|
||||
{formData.flyer_type.some(type =>
|
||||
type === FlyerTypes.DIGITAL_WITH_SOCIAL ||
|
||||
type === FlyerTypes.PHYSICAL_WITH_ADVERTISING ||
|
||||
type === FlyerTypes.NEWSLETTER
|
||||
) && (
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">When do you need us to start advertising?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={formData.flyer_advertising_start_date}
|
||||
onChange={(e) => onDataChange({ flyer_advertising_start_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Logos Required */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Logos Required</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{LOGO_OPTIONS.map((logo) => (
|
||||
<label key={logo.value} className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary mt-1"
|
||||
checked={formData.required_logos.includes(logo.value)}
|
||||
onChange={() => handleLogoChange(logo.value)}
|
||||
/>
|
||||
<span>{logo.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Logo file upload */}
|
||||
{formData.required_logos.includes(LogoOptions.OTHER) && (
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Please share your logo files here</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
|
||||
onChange={handleLogoFileChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
required
|
||||
/>
|
||||
{otherLogoFiles.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium mb-1">Selected files:</p>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
{otherLogoFiles.map((file, index) => (
|
||||
<li key={index}>{file.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">What format do you need it to be in?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered focus:select-primary transition-all duration-300"
|
||||
value={formData.advertising_format}
|
||||
onChange={(e) => onDataChange({ advertising_format: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Select format</option>
|
||||
{FORMAT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</motion.div>
|
||||
|
||||
{/* Additional specifications */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Any other specifications and requests?</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
|
||||
value={formData.flyer_additional_requests}
|
||||
onChange={(e) => onDataChange({ flyer_additional_requests: e.target.value })}
|
||||
placeholder="Color scheme, overall design, examples to consider, etc."
|
||||
rows={4}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Photography Needed */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Photography Needed?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.photography_needed === true}
|
||||
onChange={() => onDataChange({ photography_needed: true })}
|
||||
required
|
||||
/>
|
||||
<span>Yes</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.photography_needed === false}
|
||||
onChange={() => onDataChange({ photography_needed: false })}
|
||||
required
|
||||
/>
|
||||
<span>No</span>
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PRSection;
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||
|
||||
// Animation variants
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface TAPFormSectionProps {
|
||||
formData: EventRequestFormData;
|
||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||
}
|
||||
|
||||
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
|
||||
|
||||
// Handle room booking file upload
|
||||
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
setRoomBookingFile(file);
|
||||
onDataChange({ room_booking: file });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">TAP Form Information</h2>
|
||||
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
Please ensure you have ALL sections completed. If something is not available, let the coordinators know and be advised on how to proceed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expected attendance */}
|
||||
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Expected attendance? Include a number NOT a range please.</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300 w-full"
|
||||
value={formData.expected_attendance || ''}
|
||||
onChange={(e) => onDataChange({ expected_attendance: parseInt(e.target.value) || 0 })}
|
||||
min="0"
|
||||
placeholder="Enter expected attendance"
|
||||
required
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
||||
people
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
<p>PROGRAMMING FUNDS EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.</p>
|
||||
<p>ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.</p>
|
||||
<p>EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Room booking confirmation */}
|
||||
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Room booking confirmation</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="file"
|
||||
className="file-input file-input-bordered file-input-primary w-full"
|
||||
onChange={handleRoomBookingFileChange}
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
/>
|
||||
{roomBookingFile && (
|
||||
<p className="text-sm mt-2">
|
||||
Selected file: {roomBookingFile.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Please upload a screenshot of your room booking confirmation. Accepted formats: PDF, PNG, JPG.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Food/Drinks */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Will you be serving food/drinks at your event?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.food_drinks_being_served === true}
|
||||
onChange={() => onDataChange({ food_drinks_being_served: true })}
|
||||
required
|
||||
/>
|
||||
<span>Yes</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.food_drinks_being_served === false}
|
||||
onChange={() => onDataChange({ food_drinks_being_served: false })}
|
||||
required
|
||||
/>
|
||||
<span>No</span>
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* AS Funding Notice - only show if food/drinks are being served */}
|
||||
{formData.food_drinks_being_served && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="alert alert-info"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="font-bold">Food and Drinks Information</h3>
|
||||
<div className="text-xs">
|
||||
If you're serving food or drinks, you'll be asked about AS funding in the next step. Please be prepared with vendor information and invoice details.
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TAPFormSection;
|
|
@ -0,0 +1,764 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase';
|
||||
|
||||
// Declare the global window interface to include our custom function
|
||||
declare global {
|
||||
interface Window {
|
||||
showEventRequestFormPreview?: (formData: any) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Extended EventRequest interface with additional properties needed for this component
|
||||
export interface EventRequest extends SchemaEventRequest {
|
||||
invoice_data?: any;
|
||||
}
|
||||
|
||||
interface UserEventRequestsProps {
|
||||
eventRequests: EventRequest[];
|
||||
}
|
||||
|
||||
const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: initialEventRequests }) => {
|
||||
const [eventRequests, setEventRequests] = useState<EventRequest[]>(initialEventRequests);
|
||||
const [selectedRequest, setSelectedRequest] = useState<EventRequest | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Refresh event requests
|
||||
const refreshEventRequests = async () => {
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const updatedRequests = await dataSync.getData<EventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
true, // Force sync
|
||||
`requested_user="${userId}"`,
|
||||
'-created'
|
||||
);
|
||||
|
||||
setEventRequests(updatedRequests);
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh event requests:', err);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto refresh on component mount
|
||||
useEffect(() => {
|
||||
refreshEventRequests();
|
||||
}, []);
|
||||
|
||||
// Listen for tab visibility changes and refresh data when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleTabVisible = () => {
|
||||
console.log("Tab became visible, refreshing event requests...");
|
||||
refreshEventRequests();
|
||||
};
|
||||
|
||||
// Add event listener for custom dashboardTabVisible event
|
||||
document.addEventListener("dashboardTabVisible", handleTabVisible);
|
||||
|
||||
// Clean up event listener on component unmount
|
||||
return () => {
|
||||
document.removeEventListener("dashboardTabVisible", handleTabVisible);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Not specified';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status badge class based on status
|
||||
const getStatusBadge = (status?: string) => {
|
||||
if (!status) return 'badge-warning';
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case 'approved':
|
||||
case 'completed':
|
||||
return 'badge-success text-white';
|
||||
case 'rejected':
|
||||
case 'declined':
|
||||
return 'badge-error text-white';
|
||||
case 'pending':
|
||||
return 'badge-warning text-black';
|
||||
case 'submitted':
|
||||
return 'badge-info text-white';
|
||||
default:
|
||||
return 'badge-warning text-black';
|
||||
}
|
||||
};
|
||||
|
||||
// Get card border class based on status
|
||||
const getCardBorderClass = (status?: string) => {
|
||||
if (!status) return 'border-l-warning';
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case 'approved':
|
||||
case 'completed':
|
||||
return 'border-l-success';
|
||||
case 'rejected':
|
||||
case 'declined':
|
||||
return 'border-l-error';
|
||||
case 'pending':
|
||||
return 'border-l-warning';
|
||||
case 'submitted':
|
||||
return 'border-l-info';
|
||||
default:
|
||||
return 'border-l-warning';
|
||||
}
|
||||
};
|
||||
|
||||
// Open modal with event request details
|
||||
const openDetailModal = (request: EventRequest) => {
|
||||
setSelectedRequest(request);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Close modal
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedRequest(null);
|
||||
};
|
||||
|
||||
if (eventRequests.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
|
||||
<p className="text-base-content/60 mb-6 max-w-md">You haven't submitted any event requests yet. Use the form above to submit a new event request.</p>
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={refreshEventRequests}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||
<h3 className="text-lg font-semibold">Your Submissions</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="join">
|
||||
<button
|
||||
className={`join-item btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
|
||||
onClick={() => setViewMode('table')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`join-item btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
|
||||
onClick={() => setViewMode('cards')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={refreshEventRequests}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'table' ? (
|
||||
<div className="overflow-x-auto rounded-xl shadow-sm">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead className="bg-base-300/50">
|
||||
<tr>
|
||||
<th>Event Name</th>
|
||||
<th>Date</th>
|
||||
<th>Location</th>
|
||||
<th>PR Materials</th>
|
||||
<th>AS Funding</th>
|
||||
<th>Submitted</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eventRequests.map((request) => (
|
||||
<tr key={request.id} className={`hover border-l-4 ${getCardBorderClass(request.status)}`}>
|
||||
<td className="font-medium">{request.name}</td>
|
||||
<td>{formatDate(request.start_date_time)}</td>
|
||||
<td>{request.location}</td>
|
||||
<td>
|
||||
{request.flyers_needed ? (
|
||||
<span className="badge badge-success badge-sm">Yes</span>
|
||||
) : (
|
||||
<span className="badge badge-ghost badge-sm">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{request.as_funding_required ? (
|
||||
<span className="badge badge-success badge-sm">Yes</span>
|
||||
) : (
|
||||
<span className="badge badge-ghost badge-sm">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDate(request.created)}</td>
|
||||
<td>
|
||||
<span className={`badge ${getStatusBadge(request.status)} badge-sm`}>
|
||||
{request.status || 'Submitted'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm rounded-full"
|
||||
onClick={() => openDetailModal(request)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{eventRequests.map((request) => (
|
||||
<motion.div
|
||||
key={request.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`card bg-base-200 shadow-sm hover:shadow-md transition-shadow border-l-4 ${getCardBorderClass(request.status)}`}
|
||||
>
|
||||
<div className="card-body p-5">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="card-title text-base">{request.name}</h3>
|
||||
<span className={`badge ${getStatusBadge(request.status)}`}>
|
||||
{request.status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 mt-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{formatDate(request.start_date_time)}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{request.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{request.flyers_needed && (
|
||||
<span className="badge badge-outline badge-sm">PR Materials</span>
|
||||
)}
|
||||
{request.as_funding_required && (
|
||||
<span className="badge badge-outline badge-sm">AS Funding</span>
|
||||
)}
|
||||
{request.photography_needed && (
|
||||
<span className="badge badge-outline badge-sm">Photography</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-actions justify-end mt-4">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => openDetailModal(request)}
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-base-300/30 p-5 rounded-xl text-sm shadow-sm">
|
||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
About Your Submissions
|
||||
</h3>
|
||||
<ul className="space-y-2 ml-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Event requests are typically reviewed within 1-2 business days.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
You'll receive email notifications when your request status changes.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
For urgent inquiries, please contact the PR team or coordinators in the #-events Slack channel.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Event Request Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{isModalOpen && selectedRequest && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm"
|
||||
onClick={closeModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="bg-base-100 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-base-100 px-6 py-4 border-b border-base-300 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">{selectedRequest.name}</h2>
|
||||
<span className={`badge ${getStatusBadge(selectedRequest.status)}`}>
|
||||
{selectedRequest.status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('Full Preview button clicked', selectedRequest);
|
||||
try {
|
||||
// Direct call to the global function
|
||||
if (typeof window.showEventRequestFormPreview === 'function') {
|
||||
window.showEventRequestFormPreview(selectedRequest);
|
||||
} else {
|
||||
console.error('showEventRequestFormPreview is not a function', window.showEventRequestFormPreview);
|
||||
// Fallback to event dispatch if function is not available
|
||||
const event = new CustomEvent("showEventRequestPreviewModal", {
|
||||
detail: { formData: selectedRequest }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
console.log('Fallback: showEventRequestPreviewModal event dispatched');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error showing full preview:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Full Preview
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-circle btn-ghost"
|
||||
onClick={closeModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Event Details
|
||||
</h3>
|
||||
<div className="space-y-4 bg-base-200/50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Event Name</p>
|
||||
<p className="font-medium">{selectedRequest.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Location</p>
|
||||
<p className="font-medium">{selectedRequest.location}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Start Date & Time</p>
|
||||
<p className="font-medium">{formatDate(selectedRequest.start_date_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">End Date & Time</p>
|
||||
<p className="font-medium">{formatDate(selectedRequest.end_date_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Room Booking</p>
|
||||
<p className="font-medium">{selectedRequest.will_or_have_room_booking ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Expected Attendance</p>
|
||||
<p className="font-medium">{selectedRequest.expected_attendance || 'Not specified'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Event Description
|
||||
</h3>
|
||||
<div className="bg-base-200/50 p-4 rounded-lg h-full">
|
||||
<p className="whitespace-pre-line">{selectedRequest.event_description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRequest.flyers_needed && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
PR Materials
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-base-200/50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Flyer Types</p>
|
||||
<p className="font-medium">
|
||||
{selectedRequest.flyer_type?.join(', ') || 'Not specified'}
|
||||
{selectedRequest.other_flyer_type && ` (${selectedRequest.other_flyer_type})`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Advertising Start Date</p>
|
||||
<p className="font-medium">
|
||||
{selectedRequest.flyer_advertising_start_date
|
||||
? formatDate(selectedRequest.flyer_advertising_start_date)
|
||||
: 'Not specified'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Required Logos</p>
|
||||
<p className="font-medium">
|
||||
{selectedRequest.required_logos?.join(', ') || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Advertising Format</p>
|
||||
<p className="font-medium">{selectedRequest.advertising_format || 'Not specified'}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-sm text-base-content/60">Additional Requests</p>
|
||||
<p className="font-medium whitespace-pre-line">
|
||||
{selectedRequest.flyer_additional_requests || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.as_funding_required && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
AS Funding Details
|
||||
</h3>
|
||||
<div className="space-y-4 bg-base-200/50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Food/Drinks Being Served</p>
|
||||
<p className="font-medium">
|
||||
{selectedRequest.food_drinks_being_served ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
{selectedRequest.invoice_data && (
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Vendor</p>
|
||||
<p className="font-medium">
|
||||
{selectedRequest.invoice_data.vendor || 'Not specified'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Itemized Invoice</p>
|
||||
{(() => {
|
||||
try {
|
||||
let invoiceData: any = null;
|
||||
|
||||
// Parse the invoice data if it's a string, or use it directly if it's an object
|
||||
if (typeof selectedRequest.itemized_invoice === 'string') {
|
||||
try {
|
||||
invoiceData = JSON.parse(selectedRequest.itemized_invoice);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse invoice JSON:', e);
|
||||
return (
|
||||
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
|
||||
{selectedRequest.itemized_invoice || 'Not provided'}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
} else if (typeof selectedRequest.itemized_invoice === 'object') {
|
||||
invoiceData = selectedRequest.itemized_invoice;
|
||||
}
|
||||
|
||||
// If we have valid invoice data with items
|
||||
if (invoiceData && Array.isArray(invoiceData.items) && invoiceData.items.length > 0) {
|
||||
// Calculate total from items if not provided or if NaN
|
||||
let calculatedTotal = 0;
|
||||
|
||||
// Try to use the provided total first
|
||||
if (invoiceData.total !== undefined) {
|
||||
const parsedTotal = typeof invoiceData.total === 'string'
|
||||
? parseFloat(invoiceData.total)
|
||||
: invoiceData.total;
|
||||
|
||||
if (!isNaN(parsedTotal)) {
|
||||
calculatedTotal = parsedTotal;
|
||||
}
|
||||
}
|
||||
|
||||
// If total is NaN or not provided, calculate from items
|
||||
if (calculatedTotal === 0 || isNaN(calculatedTotal)) {
|
||||
calculatedTotal = invoiceData.items.reduce((sum: number, item: any) => {
|
||||
const quantity = typeof item.quantity === 'string'
|
||||
? parseFloat(item.quantity)
|
||||
: (item.quantity || 1);
|
||||
|
||||
const unitPrice = typeof item.unit_price === 'string'
|
||||
? parseFloat(item.unit_price)
|
||||
: (item.unit_price || 0);
|
||||
|
||||
const itemTotal = !isNaN(quantity) && !isNaN(unitPrice)
|
||||
? quantity * unitPrice
|
||||
: 0;
|
||||
|
||||
return sum + itemTotal;
|
||||
}, 0);
|
||||
|
||||
// Add tax and tip if available
|
||||
if (invoiceData.tax && !isNaN(parseFloat(invoiceData.tax))) {
|
||||
calculatedTotal += parseFloat(invoiceData.tax);
|
||||
}
|
||||
|
||||
if (invoiceData.tip && !isNaN(parseFloat(invoiceData.tip))) {
|
||||
calculatedTotal += parseFloat(invoiceData.tip);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-base-300 p-3 rounded-lg overflow-x-auto mt-2">
|
||||
<table className="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="text-right">Qty</th>
|
||||
<th className="text-right">Price</th>
|
||||
<th className="text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoiceData.items.map((item: any, index: number) => {
|
||||
const quantity = typeof item.quantity === 'string'
|
||||
? parseFloat(item.quantity)
|
||||
: (item.quantity || 1);
|
||||
|
||||
const unitPrice = typeof item.unit_price === 'string'
|
||||
? parseFloat(item.unit_price)
|
||||
: (item.unit_price || 0);
|
||||
|
||||
const itemTotal = !isNaN(quantity) && !isNaN(unitPrice)
|
||||
? quantity * unitPrice
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{item.item || 'Unnamed item'}</td>
|
||||
<td className="text-right">{!isNaN(quantity) ? quantity : 1}</td>
|
||||
<td className="text-right">${!isNaN(unitPrice) ? unitPrice.toFixed(2) : '0.00'}</td>
|
||||
<td className="text-right">${!isNaN(itemTotal) ? itemTotal.toFixed(2) : '0.00'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{invoiceData.tax !== undefined && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tax:</td>
|
||||
<td className="text-right">
|
||||
${typeof invoiceData.tax === 'string'
|
||||
? (parseFloat(invoiceData.tax) || 0).toFixed(2)
|
||||
: (invoiceData.tax || 0).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{invoiceData.tip !== undefined && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tip:</td>
|
||||
<td className="text-right">
|
||||
${typeof invoiceData.tip === 'string'
|
||||
? (parseFloat(invoiceData.tip) || 0).toFixed(2)
|
||||
: (invoiceData.tip || 0).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-bold">Total:</td>
|
||||
<td className="text-right font-bold">
|
||||
${!isNaN(calculatedTotal) ? calculatedTotal.toFixed(2) : '0.00'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{invoiceData.vendor && (
|
||||
<div className="mt-3 text-sm">
|
||||
<span className="font-medium">Vendor:</span> {invoiceData.vendor}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (invoiceData && typeof invoiceData.total !== 'undefined') {
|
||||
// If we have a total but no items, show a simplified view
|
||||
const total = typeof invoiceData.total === 'string'
|
||||
? parseFloat(invoiceData.total)
|
||||
: invoiceData.total;
|
||||
|
||||
return (
|
||||
<div className="bg-base-300 p-3 rounded-lg mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Total Amount:</span>
|
||||
<span className="font-bold">${!isNaN(total) ? total.toFixed(2) : '0.00'}</span>
|
||||
</div>
|
||||
{invoiceData.vendor && (
|
||||
<div className="mt-2">
|
||||
<span className="font-medium">Vendor:</span> {invoiceData.vendor}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Fallback to display the JSON in a readable format
|
||||
return (
|
||||
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
|
||||
{typeof selectedRequest.itemized_invoice === 'object'
|
||||
? JSON.stringify(selectedRequest.itemized_invoice, null, 2)
|
||||
: (selectedRequest.itemized_invoice || 'Not provided')}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rendering invoice:', error);
|
||||
return (
|
||||
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
|
||||
Error displaying invoice. Please check the console for details.
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-base-300">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-base-content/60">Submission Date</p>
|
||||
<p className="font-medium">{formatDate(selectedRequest.created)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-base-content/60">Status:</p>
|
||||
<span className={`badge ${getStatusBadge(selectedRequest.status)} badge-lg`}>
|
||||
{selectedRequest.status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEventRequests;
|
208
src/components/dashboard/Officer_EventRequestManagement.astro
Normal file
208
src/components/dashboard/Officer_EventRequestManagement.astro
Normal file
|
@ -0,0 +1,208 @@
|
|||
---
|
||||
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||
import { Get } from "../../scripts/pocketbase/Get";
|
||||
import { toast } from "react-hot-toast";
|
||||
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
|
||||
import type { EventRequest } from "../../schemas/pocketbase";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
// Get instances
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Extended EventRequest interface with additional properties needed for this component
|
||||
interface ExtendedEventRequest extends EventRequest {
|
||||
requested_user_expand?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
expand?: {
|
||||
requested_user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any; // For other optional properties
|
||||
}
|
||||
|
||||
// Initialize variables for all event requests
|
||||
let allEventRequests: ExtendedEventRequest[] = [];
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
// Don't check authentication here - let the client component handle it
|
||||
// The server-side check is causing issues when the token is valid client-side but not server-side
|
||||
|
||||
console.log("Fetching event requests in Astro component...");
|
||||
// Expand the requested_user field to get user details
|
||||
allEventRequests = await get
|
||||
.getAll<ExtendedEventRequest>(Collections.EVENT_REQUESTS, "", "-created", {
|
||||
expand: ["requested_user"],
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error in get.getAll:", err);
|
||||
// Return empty array instead of throwing
|
||||
return [];
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Fetched ${allEventRequests.length} event requests in Astro component`,
|
||||
);
|
||||
|
||||
// Process the event requests to add the requested_user_expand property
|
||||
allEventRequests = allEventRequests.map((request) => {
|
||||
const requestWithExpand = { ...request };
|
||||
|
||||
// Add the requested_user_expand property if the expand data is available
|
||||
if (
|
||||
request.expand &&
|
||||
request.expand.requested_user &&
|
||||
request.expand.requested_user.name &&
|
||||
request.expand.requested_user.email
|
||||
) {
|
||||
requestWithExpand.requested_user_expand = {
|
||||
name: request.expand.requested_user.name,
|
||||
email: request.expand.requested_user.email,
|
||||
};
|
||||
}
|
||||
|
||||
return requestWithExpand;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error fetching event requests:", err);
|
||||
error = err;
|
||||
}
|
||||
---
|
||||
|
||||
<div class="w-full max-w-7xl mx-auto py-8 px-4">
|
||||
<style>
|
||||
.event-table-container {
|
||||
min-height: 600px;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.event-table-container table {
|
||||
height: auto !important;
|
||||
}
|
||||
.event-table-container .overflow-x-auto {
|
||||
max-height: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Event Request Management</h1>
|
||||
<p class="text-gray-300 mb-4">
|
||||
Review and manage event requests submitted by officers. Update status and
|
||||
coordinate with the team.
|
||||
</p>
|
||||
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
|
||||
<p class="font-medium mb-2">As an executive officer, you can:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>View all submitted event requests</li>
|
||||
<li>Update the status of requests (Pending, Completed, Declined)</li>
|
||||
<li>Filter and sort requests by various criteria</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
error && (
|
||||
<div class="alert alert-error mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 stroke-current shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!error && (
|
||||
<div class="bg-base-200 rounded-lg shadow-xl min-h-[600px] event-table-container">
|
||||
<div class="p-4 md:p-6 h-auto">
|
||||
<EventRequestManagementTable
|
||||
client:load
|
||||
eventRequests={allEventRequests}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Import the DataSyncService for client-side use
|
||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
|
||||
// Remove the visibilitychange event listener that causes full page refresh
|
||||
// Instead, we'll use a more efficient approach to refresh data only when needed
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Instead of reloading the entire page, dispatch a custom event
|
||||
// that components can listen for to refresh their data
|
||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle authentication errors
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Initialize DataSyncService for client-side
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Prefetch data into IndexedDB
|
||||
try {
|
||||
await dataSync.syncCollection(
|
||||
Collections.EVENT_REQUESTS,
|
||||
"",
|
||||
"-created",
|
||||
{ expand: "requested_user" },
|
||||
);
|
||||
console.log("Initial data sync complete");
|
||||
} catch (err) {
|
||||
console.error("Error during initial data sync:", err);
|
||||
}
|
||||
|
||||
// Check for error message in the UI
|
||||
const errorElement = document.querySelector(".alert-error span");
|
||||
if (
|
||||
errorElement &&
|
||||
errorElement.textContent?.includes("Authentication error")
|
||||
) {
|
||||
console.log(
|
||||
"Authentication error detected in UI, redirecting to login...",
|
||||
);
|
||||
// Redirect to login page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = "/login";
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check if we have any event requests
|
||||
const tableContainer = document.querySelector(".event-table-container");
|
||||
if (tableContainer) {
|
||||
console.log(
|
||||
"Event table container found, component should load normally",
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Event table container not found, might be an issue with rendering",
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,466 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import toast from 'react-hot-toast';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
// Extended EventRequest interface with additional properties needed for this component
|
||||
interface ExtendedEventRequest extends SchemaEventRequest {
|
||||
requested_user_expand?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
invoice_data?: string | any;
|
||||
}
|
||||
|
||||
interface EventRequestDetailsProps {
|
||||
request: ExtendedEventRequest;
|
||||
onClose: () => void;
|
||||
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
|
||||
}
|
||||
|
||||
// Separate component for AS Funding tab to isolate any issues
|
||||
const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
|
||||
if (!request.as_funding_required) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">AS Funding Required</h4>
|
||||
<p>No</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Process invoice data for display
|
||||
let invoiceData = request.invoice_data;
|
||||
|
||||
// If invoice_data is not available, try to parse itemized_invoice
|
||||
if (!invoiceData && request.itemized_invoice) {
|
||||
try {
|
||||
if (typeof request.itemized_invoice === 'string') {
|
||||
invoiceData = JSON.parse(request.itemized_invoice);
|
||||
} else if (typeof request.itemized_invoice === 'object') {
|
||||
invoiceData = request.itemized_invoice;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse itemized_invoice:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">AS Funding Required</h4>
|
||||
<p>Yes</p>
|
||||
</div>
|
||||
|
||||
{request.food_drinks_being_served && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Food/Drinks Being Served</h4>
|
||||
<p>Yes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Invoice Data</h4>
|
||||
<InvoiceTable invoiceData={invoiceData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Separate component for invoice table
|
||||
const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
||||
try {
|
||||
// Parse invoice data if it's a string
|
||||
let parsedInvoice = null;
|
||||
|
||||
if (typeof invoiceData === 'string') {
|
||||
try {
|
||||
parsedInvoice = JSON.parse(invoiceData);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse invoice data string:', e);
|
||||
return (
|
||||
<div className="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>Invalid invoice data format.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
|
||||
parsedInvoice = invoiceData;
|
||||
}
|
||||
|
||||
// Check if we have valid invoice data
|
||||
if (!parsedInvoice || typeof parsedInvoice !== 'object') {
|
||||
return (
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>No structured invoice data available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract items array
|
||||
let items = [];
|
||||
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
|
||||
items = parsedInvoice.items;
|
||||
} else if (Array.isArray(parsedInvoice)) {
|
||||
items = parsedInvoice;
|
||||
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
|
||||
items = [parsedInvoice.items]; // Wrap single item in array
|
||||
} else {
|
||||
// Try to find any array in the object
|
||||
for (const key in parsedInvoice) {
|
||||
if (Array.isArray(parsedInvoice[key])) {
|
||||
items = parsedInvoice[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have items, check if the object itself looks like an item
|
||||
if (items.length === 0 && parsedInvoice.item || parsedInvoice.description || parsedInvoice.name) {
|
||||
items = [parsedInvoice];
|
||||
}
|
||||
|
||||
// If we still don't have items, show a message
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>No invoice items found in the data.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate subtotal from items
|
||||
const subtotal = items.reduce((sum: number, item: any) => {
|
||||
const quantity = parseFloat(item?.quantity || 1);
|
||||
const price = parseFloat(item?.unit_price || item?.price || 0);
|
||||
return sum + (quantity * price);
|
||||
}, 0);
|
||||
|
||||
// Get tax, tip and total
|
||||
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
|
||||
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
|
||||
const total = parseFloat(parsedInvoice.total || 0) || (subtotal + tax + tip);
|
||||
|
||||
// Render the invoice table
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item: any, index: number) => {
|
||||
// Ensure we're not trying to render an object directly
|
||||
const itemName = typeof item?.item === 'object'
|
||||
? JSON.stringify(item.item)
|
||||
: (item?.item || item?.description || item?.name || 'N/A');
|
||||
|
||||
const quantity = parseFloat(item?.quantity || 1);
|
||||
const unitPrice = parseFloat(item?.unit_price || item?.price || 0);
|
||||
const itemTotal = quantity * unitPrice;
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{itemName}</td>
|
||||
<td>{quantity}</td>
|
||||
<td>${unitPrice.toFixed(2)}</td>
|
||||
<td>${itemTotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
|
||||
<td>${subtotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
{tax > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tax:</td>
|
||||
<td>${tax.toFixed(2)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{tip > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tip:</td>
|
||||
<td>${tip.toFixed(2)}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-bold">Total:</td>
|
||||
<td className="font-bold">${total.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{parsedInvoice.vendor && (
|
||||
<div className="mt-3">
|
||||
<span className="font-medium">Vendor:</span> {parsedInvoice.vendor}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering invoice table:', error);
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>An unexpected error occurred while processing the invoice.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const EventRequestDetails = ({
|
||||
request,
|
||||
onClose,
|
||||
onStatusChange
|
||||
}: EventRequestDetailsProps): React.ReactNode => {
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'pr' | 'funding'>('details');
|
||||
const [status, setStatus] = useState<"submitted" | "pending" | "completed" | "declined">(request.status);
|
||||
const [isStatusChanging, setIsStatusChanging] = useState(false);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Not specified';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status badge class based on status
|
||||
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
||||
if (!status) return 'badge-warning';
|
||||
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'badge-success';
|
||||
case 'declined':
|
||||
return 'badge-error';
|
||||
case 'pending':
|
||||
return 'badge-warning';
|
||||
case 'submitted':
|
||||
return 'badge-info';
|
||||
default:
|
||||
return 'badge-warning';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle status change
|
||||
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
|
||||
setIsStatusChanging(true);
|
||||
await onStatusChange(request.id, newStatus);
|
||||
setStatus(newStatus);
|
||||
setIsStatusChanging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-base-200 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-base-300 p-4 flex justify-between items-center">
|
||||
<h3 className="text-xl font-bold">{request.name}</h3>
|
||||
<button
|
||||
className="btn btn-sm btn-circle"
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status and controls */}
|
||||
<div className="bg-base-300/50 p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-400">Status:</span>
|
||||
<span className={`badge ${getStatusBadge(status)}`}>
|
||||
{status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Requested by: <span className="text-white">{request.requested_user_expand?.name || request.requested_user || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="dropdown dropdown-end">
|
||||
<label tabIndex={0} className="btn btn-sm">
|
||||
Update Status
|
||||
</label>
|
||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
|
||||
<li><a onClick={() => handleStatusChange('pending')}>Pending</a></li>
|
||||
<li><a onClick={() => handleStatusChange('completed')}>Completed</a></li>
|
||||
<li><a onClick={() => handleStatusChange('declined')}>Declined</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="tabs tabs-boxed bg-base-300/30 px-4 pt-4">
|
||||
<a
|
||||
className={`tab ${activeTab === 'details' ? 'tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('details')}
|
||||
>
|
||||
Event Details
|
||||
</a>
|
||||
<a
|
||||
className={`tab ${activeTab === 'pr' ? 'tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('pr')}
|
||||
>
|
||||
PR Materials
|
||||
</a>
|
||||
<a
|
||||
className={`tab ${activeTab === 'funding' ? 'tab-active' : ''}`}
|
||||
onClick={() => setActiveTab('funding')}
|
||||
>
|
||||
AS Funding
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto flex-grow">
|
||||
{/* Event Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Event Name</h4>
|
||||
<p className="text-lg">{request.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Location</h4>
|
||||
<p>{request.location || 'Not specified'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Start Date & Time</h4>
|
||||
<p>{formatDate(request.start_date_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">End Date & Time</h4>
|
||||
<p>{formatDate(request.end_date_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Expected Attendance</h4>
|
||||
<p>{request.expected_attendance || 'Not specified'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Event Description</h4>
|
||||
<p className="whitespace-pre-line">{request.event_description || 'No description provided'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Room Booking</h4>
|
||||
<p>{request.will_or_have_room_booking ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Food/Drinks Served</h4>
|
||||
<p>{request.food_drinks_being_served ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Submission Date</h4>
|
||||
<p>{formatDate(request.created)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR Materials Tab */}
|
||||
{activeTab === 'pr' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyers Needed</h4>
|
||||
<p>{request.flyers_needed ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
{request.flyers_needed && (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyer Types</h4>
|
||||
<ul className="list-disc list-inside">
|
||||
{request.flyer_type?.map((type, index) => (
|
||||
<li key={index}>{type}</li>
|
||||
))}
|
||||
{request.other_flyer_type && <li>{request.other_flyer_type}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Start Date</h4>
|
||||
<p>{formatDate(request.flyer_advertising_start_date || '')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Format</h4>
|
||||
<p>{request.advertising_format || 'Not specified'}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Photography Needed</h4>
|
||||
<p>{request.photography_needed ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
{request.flyers_needed && (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Required Logos</h4>
|
||||
<ul className="list-disc list-inside">
|
||||
{request.required_logos?.map((logo, index) => (
|
||||
<li key={index}>{logo}</li>
|
||||
))}
|
||||
{(!request.required_logos || request.required_logos.length === 0) &&
|
||||
<li>No specific logos required</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-1">Additional Requests</h4>
|
||||
<p className="whitespace-pre-line">{request.flyer_additional_requests || 'None'}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AS Funding Tab */}
|
||||
{activeTab === 'funding' && (
|
||||
<ASFundingTab request={request} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventRequestDetails;
|
|
@ -0,0 +1,662 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import toast from 'react-hot-toast';
|
||||
import EventRequestDetails from './EventRequestDetails';
|
||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
// Extended EventRequest interface with additional properties needed for this component
|
||||
interface ExtendedEventRequest extends SchemaEventRequest {
|
||||
requested_user_expand?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
expand?: {
|
||||
requested_user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
invoice_data?: any;
|
||||
status: "submitted" | "pending" | "completed" | "declined";
|
||||
}
|
||||
|
||||
interface EventRequestManagementTableProps {
|
||||
eventRequests: ExtendedEventRequest[];
|
||||
}
|
||||
|
||||
const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: EventRequestManagementTableProps) => {
|
||||
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||
const [selectedRequest, setSelectedRequest] = useState<ExtendedEventRequest | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [sortField, setSortField] = useState<string>('created');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
// Add state for update modal
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
|
||||
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
|
||||
|
||||
// Refresh event requests
|
||||
const refreshEventRequests = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Don't check authentication here - try to fetch anyway
|
||||
// The token might be valid for the API even if isAuthenticated() returns false
|
||||
|
||||
console.log("Fetching event requests...");
|
||||
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
true, // Force sync
|
||||
'', // No filter
|
||||
'-created',
|
||||
'requested_user'
|
||||
);
|
||||
|
||||
console.log(`Fetched ${updatedRequests.length} event requests`);
|
||||
|
||||
setEventRequests(updatedRequests);
|
||||
applyFilters(updatedRequests);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing event requests:', error);
|
||||
toast.error('Failed to refresh event requests');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply filters and sorting
|
||||
const applyFilters = (requests = eventRequests) => {
|
||||
let filtered = [...requests];
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(request =>
|
||||
request.status?.toLowerCase() === statusFilter.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(request =>
|
||||
request.name.toLowerCase().includes(term) ||
|
||||
request.location.toLowerCase().includes(term) ||
|
||||
request.event_description.toLowerCase().includes(term) ||
|
||||
request.expand?.requested_user?.name?.toLowerCase().includes(term) ||
|
||||
request.expand?.requested_user?.email?.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof ExtendedEventRequest];
|
||||
let bValue: any = b[sortField as keyof ExtendedEventRequest];
|
||||
|
||||
// Handle special cases
|
||||
if (sortField === 'requested_user') {
|
||||
aValue = a.expand?.requested_user?.name || '';
|
||||
bValue = b.expand?.requested_user?.name || '';
|
||||
}
|
||||
|
||||
// Handle date fields
|
||||
if (sortField === 'created' || sortField === 'updated' ||
|
||||
sortField === 'start_date_time' || sortField === 'end_date_time') {
|
||||
aValue = new Date(aValue || '').getTime();
|
||||
bValue = new Date(bValue || '').getTime();
|
||||
}
|
||||
|
||||
// Compare values based on sort direction
|
||||
if (sortDirection === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
setFilteredRequests(filtered);
|
||||
};
|
||||
|
||||
// Update event request status
|
||||
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
|
||||
try {
|
||||
const update = Update.getInstance();
|
||||
const result = await update.updateField('event_request', id, 'status', status);
|
||||
|
||||
// Find the event request to get its name
|
||||
const eventRequest = eventRequests.find(req => req.id === id);
|
||||
const eventName = eventRequest?.name || 'Event';
|
||||
|
||||
// Update local state
|
||||
setEventRequests(prev =>
|
||||
prev.map(request =>
|
||||
request.id === id ? { ...request, status } : request
|
||||
)
|
||||
);
|
||||
|
||||
setFilteredRequests(prev =>
|
||||
prev.map(request =>
|
||||
request.id === id ? { ...request, status } : request
|
||||
)
|
||||
);
|
||||
|
||||
// Update selected request if open
|
||||
if (selectedRequest && selectedRequest.id === id) {
|
||||
setSelectedRequest({ ...selectedRequest, status });
|
||||
}
|
||||
|
||||
// Force sync to update IndexedDB
|
||||
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
|
||||
|
||||
// Show success toast with event name
|
||||
toast.success(`"${eventName}" status updated to ${status}`);
|
||||
} catch (error) {
|
||||
// Find the event request to get its name
|
||||
const eventRequest = eventRequests.find(req => req.id === id);
|
||||
const eventName = eventRequest?.name || 'Event';
|
||||
|
||||
console.error('Error updating status:', error);
|
||||
toast.error(`Failed to update status for "${eventName}"`);
|
||||
throw error; // Re-throw the error to be caught by the caller
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Not specified';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status badge class based on status
|
||||
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
||||
if (!status) return 'badge-warning';
|
||||
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'badge-success';
|
||||
case 'declined':
|
||||
return 'badge-error';
|
||||
case 'pending':
|
||||
return 'badge-warning';
|
||||
case 'submitted':
|
||||
return 'badge-info';
|
||||
default:
|
||||
return 'badge-warning';
|
||||
}
|
||||
};
|
||||
|
||||
// Open modal with event request details
|
||||
const openDetailModal = (request: ExtendedEventRequest) => {
|
||||
setSelectedRequest(request);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Close modal
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedRequest(null);
|
||||
};
|
||||
|
||||
// Open update modal
|
||||
const openUpdateModal = (request: ExtendedEventRequest) => {
|
||||
setRequestToUpdate(request);
|
||||
setIsUpdateModalOpen(true);
|
||||
};
|
||||
|
||||
// Close update modal
|
||||
const closeUpdateModal = () => {
|
||||
setIsUpdateModalOpen(false);
|
||||
setRequestToUpdate(null);
|
||||
};
|
||||
|
||||
// Update status and close modal
|
||||
const handleUpdateStatus = async (status: "submitted" | "pending" | "completed" | "declined") => {
|
||||
if (requestToUpdate) {
|
||||
try {
|
||||
await updateEventRequestStatus(requestToUpdate.id, status);
|
||||
// Toast is now shown in updateEventRequestStatus
|
||||
closeUpdateModal();
|
||||
} catch (error) {
|
||||
console.error('Error in handleUpdateStatus:', error);
|
||||
// Toast is now shown in updateEventRequestStatus
|
||||
// Keep modal open so user can try again
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sort change
|
||||
const handleSortChange = (field: string) => {
|
||||
if (sortField === field) {
|
||||
// Toggle direction if same field
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
// Set new field and default to descending
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// Apply filters when filter state changes
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
||||
|
||||
// Check authentication and refresh token if needed
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Check if we're authenticated
|
||||
if (!auth.isAuthenticated()) {
|
||||
console.log("Authentication check failed - attempting to continue anyway");
|
||||
|
||||
// Don't show error or redirect immediately - try to refresh first
|
||||
try {
|
||||
// Try to refresh event requests anyway - the token might be valid
|
||||
await refreshEventRequests();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh after auth check:", err);
|
||||
toast.error("Authentication error. Please log in again.");
|
||||
|
||||
// Only redirect if refresh fails
|
||||
setTimeout(() => {
|
||||
window.location.href = "/login";
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
console.log("Authentication check passed");
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Auto refresh on component mount
|
||||
useEffect(() => {
|
||||
refreshEventRequests();
|
||||
}, []);
|
||||
|
||||
// Listen for tab visibility changes and refresh data when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleTabVisible = () => {
|
||||
console.log("Tab became visible, refreshing event requests...");
|
||||
refreshEventRequests();
|
||||
};
|
||||
|
||||
// Add event listener for custom dashboardTabVisible event
|
||||
document.addEventListener("dashboardTabVisible", handleTabVisible);
|
||||
|
||||
// Clean up event listener on component unmount
|
||||
return () => {
|
||||
document.removeEventListener("dashboardTabVisible", handleTabVisible);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (filteredRequests.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
|
||||
<p className="text-base-content/60 mb-6 max-w-md">
|
||||
{statusFilter !== 'all' || searchTerm
|
||||
? 'No event requests match your current filters. Try adjusting your search criteria.'
|
||||
: 'There are no event requests in the system yet.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={refreshEventRequests}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{(statusFilter !== 'all' || searchTerm) && (
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-6"
|
||||
style={{ minHeight: "500px" }}
|
||||
>
|
||||
{/* Filters and controls */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 mb-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
|
||||
<div className="form-control w-full sm:w-auto">
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search events..."
|
||||
className="input input-bordered w-full sm:w-64"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-square">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
className="select select-bordered w-full sm:w-auto"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="declined">Declined</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full lg:w-auto justify-between sm:justify-end">
|
||||
<span className="text-sm text-gray-400">
|
||||
{filteredRequests.length} {filteredRequests.length === 1 ? 'request' : 'requests'} found
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={refreshEventRequests}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event requests table */}
|
||||
<div
|
||||
className="rounded-xl shadow-sm overflow-x-auto"
|
||||
style={{
|
||||
maxHeight: "unset",
|
||||
height: "auto"
|
||||
}}
|
||||
>
|
||||
<table className="table table-zebra w-full">
|
||||
<thead className="bg-base-300/50">
|
||||
<tr>
|
||||
<th
|
||||
className="cursor-pointer hover:bg-base-300"
|
||||
onClick={() => handleSortChange('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Event Name
|
||||
{sortField === 'name' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
|
||||
onClick={() => handleSortChange('start_date_time')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Date
|
||||
{sortField === 'start_date_time' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer hover:bg-base-300"
|
||||
onClick={() => handleSortChange('requested_user')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Requested By
|
||||
{sortField === 'requested_user' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="hidden lg:table-cell">PR Materials</th>
|
||||
<th className="hidden lg:table-cell">AS Funding</th>
|
||||
<th
|
||||
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
|
||||
onClick={() => handleSortChange('created')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Submitted
|
||||
{sortField === 'created' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer hover:bg-base-300"
|
||||
onClick={() => handleSortChange('status')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Status
|
||||
{sortField === 'status' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRequests.map((request) => (
|
||||
<tr key={request.id} className="hover">
|
||||
<td className="font-medium">{request.name}</td>
|
||||
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
|
||||
<td>
|
||||
<div className="flex flex-col">
|
||||
<span>{request.expand?.requested_user?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-gray-400">{request.expand?.requested_user?.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden lg:table-cell">
|
||||
{request.flyers_needed ? (
|
||||
<span className="badge badge-success badge-sm">Yes</span>
|
||||
) : (
|
||||
<span className="badge badge-ghost badge-sm">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden lg:table-cell">
|
||||
{request.as_funding_required ? (
|
||||
<span className="badge badge-success badge-sm">Yes</span>
|
||||
) : (
|
||||
<span className="badge badge-ghost badge-sm">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{formatDate(request.created)}</td>
|
||||
<td>
|
||||
<span className={`badge ${getStatusBadge(request.status)}`}>
|
||||
{request.status || 'Pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => openUpdateModal(request)}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => openDetailModal(request)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Event request details modal - Now outside the main component div */}
|
||||
{isModalOpen && selectedRequest && (
|
||||
<AnimatePresence>
|
||||
<EventRequestDetails
|
||||
request={selectedRequest}
|
||||
onClose={closeModal}
|
||||
onStatusChange={updateEventRequestStatus}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* Update status modal */}
|
||||
{isUpdateModalOpen && requestToUpdate && (
|
||||
<AnimatePresence>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-base-200 rounded-lg shadow-xl w-full max-w-md overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-base-300 p-4 flex justify-between items-center">
|
||||
<h3 className="text-xl font-bold">Update Status</h3>
|
||||
<button
|
||||
className="btn btn-sm btn-circle"
|
||||
onClick={closeUpdateModal}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="mb-4">
|
||||
Update status for event: <span className="font-semibold">{requestToUpdate.name}</span>
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="btn btn-outline w-full justify-start"
|
||||
onClick={() => handleUpdateStatus("pending")}
|
||||
>
|
||||
<span className="badge badge-warning mr-2">Pending</span>
|
||||
Mark as Pending
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline w-full justify-start"
|
||||
onClick={() => handleUpdateStatus("completed")}
|
||||
>
|
||||
<span className="badge badge-success mr-2">Completed</span>
|
||||
Mark as Completed
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline w-full justify-start"
|
||||
onClick={() => handleUpdateStatus("declined")}
|
||||
>
|
||||
<span className="badge badge-error mr-2">Declined</span>
|
||||
Mark as Declined
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-base-300 flex justify-end">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={closeUpdateModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventRequestManagementTable;
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
import ReimbursementManagementPortal from "./reimbursement/ReimbursementManagementPortal";
|
||||
---
|
||||
|
||||
<div class="w-full">
|
||||
<ReimbursementManagementPortal client:load />
|
||||
</div>
|
3
src/components/dashboard/Officer_UserManagement.astro
Normal file
3
src/components/dashboard/Officer_UserManagement.astro
Normal file
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
|
||||
---
|
40
src/components/dashboard/ProfileSection.astro
Normal file
40
src/components/dashboard/ProfileSection.astro
Normal file
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import ShowProfileLogs from "./ProfileSection/ShowProfileLogs";
|
||||
import { Stats } from "./ProfileSection/Stats";
|
||||
---
|
||||
|
||||
<div id="" class="">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">Dashboard Overview</h2>
|
||||
<p class="opacity-70">Welcome to your IEEE UCSD dashboard</p>
|
||||
</div>
|
||||
|
||||
<Stats client:load />
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-lg border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-xl font-bold flex items-center gap-3">
|
||||
<div class="badge badge-primary p-3">
|
||||
<Icon name="heroicons:eye" class="h-5 w-5" />
|
||||
</div>
|
||||
Recent Activity
|
||||
<div class="badge badge-ghost text-xs font-normal">
|
||||
Real-time updates
|
||||
</div>
|
||||
</h3>
|
||||
<p class="text-sm opacity-70">
|
||||
Track your recent interactions with the IEEE UCSD platform
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="">
|
||||
<div class="min-h-[300px]">
|
||||
<ShowProfileLogs client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
579
src/components/dashboard/ProfileSection/ShowProfileLogs.tsx
Normal file
579
src/components/dashboard/ProfileSection/ShowProfileLogs.tsx
Normal file
|
@ -0,0 +1,579 @@
|
|||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { Log } from "../../../schemas/pocketbase";
|
||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||
|
||||
const LOGS_PER_PAGE = 5;
|
||||
|
||||
export default function ShowProfileLogs() {
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalLogs, setTotalLogs] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [allLogs, setAllLogs] = useState<Log[]>([]);
|
||||
const [isFetchingAll, setIsFetchingAll] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
// Auto-refresh logs every 30 seconds if enabled
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!isFetchingAll) {
|
||||
fetchLogs(true);
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, isFetchingAll]);
|
||||
|
||||
const fetchLogs = async (skipCache = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
const currentUser = auth.getPocketBase().authStore.model;
|
||||
const userId = currentUser?.id;
|
||||
|
||||
if (!userId) {
|
||||
setError("Not authenticated");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingAll(true);
|
||||
console.log("Fetching logs for user:", userId);
|
||||
|
||||
// Use DataSyncService to fetch logs
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// First sync logs for this user
|
||||
await dataSync.syncCollection(
|
||||
Collections.LOGS,
|
||||
`user = "${userId}"`,
|
||||
"-created",
|
||||
{ expand: "user" }
|
||||
);
|
||||
|
||||
// Then get all logs from IndexedDB
|
||||
const fetchedLogs = await dataSync.getData<Log>(
|
||||
Collections.LOGS,
|
||||
false, // Don't force sync again
|
||||
`user = "${userId}"`,
|
||||
"-created"
|
||||
);
|
||||
|
||||
console.log("Fetched logs:", fetchedLogs.length);
|
||||
|
||||
if (fetchedLogs.length === 0) {
|
||||
// If no logs found, try to fetch directly from PocketBase
|
||||
console.log("No logs found in IndexedDB, trying direct fetch from PocketBase");
|
||||
try {
|
||||
const sendLog = SendLog.getInstance();
|
||||
const directLogs = await sendLog.getUserLogs(userId);
|
||||
console.log("Direct fetch logs:", directLogs.length);
|
||||
|
||||
if (directLogs.length > 0) {
|
||||
setAllLogs(directLogs);
|
||||
setTotalPages(Math.ceil(directLogs.length / LOGS_PER_PAGE));
|
||||
setTotalLogs(directLogs.length);
|
||||
} else {
|
||||
setAllLogs(fetchedLogs);
|
||||
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
|
||||
setTotalLogs(fetchedLogs.length);
|
||||
}
|
||||
} catch (directError) {
|
||||
console.error("Failed to fetch logs directly:", directError);
|
||||
setAllLogs(fetchedLogs);
|
||||
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
|
||||
setTotalLogs(fetchedLogs.length);
|
||||
}
|
||||
} else {
|
||||
setAllLogs(fetchedLogs);
|
||||
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
|
||||
setTotalLogs(fetchedLogs.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch logs:", error);
|
||||
setError("Error loading activity");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsFetchingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized search function
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (!allLogs.length) return [];
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
// When not searching, return only the current page of logs
|
||||
const startIndex = (currentPage - 1) * LOGS_PER_PAGE;
|
||||
const endIndex = startIndex + LOGS_PER_PAGE;
|
||||
return allLogs.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allLogs.filter(log => {
|
||||
return (
|
||||
log.message?.toLowerCase().includes(query) ||
|
||||
log.type?.toLowerCase().includes(query) ||
|
||||
log.part?.toLowerCase().includes(query) ||
|
||||
(log.created && new Date(log.created).toLocaleString().toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
}, [searchQuery, allLogs, currentPage]);
|
||||
|
||||
// Update displayed logs whenever filtered results change
|
||||
useEffect(() => {
|
||||
setLogs(filteredLogs);
|
||||
console.log("Filtered logs updated:", filteredLogs.length, "logs");
|
||||
}, [filteredLogs]);
|
||||
|
||||
// Debounced search handler
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((query: string) => {
|
||||
setSearchQuery(query);
|
||||
// Reset to first page when searching
|
||||
if (query.trim()) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSearch(event.target.value);
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLogs(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadLogsWithRetry = async () => {
|
||||
try {
|
||||
await fetchLogs();
|
||||
|
||||
// Wait a moment for state to update
|
||||
setTimeout(async () => {
|
||||
// Check if logs were loaded
|
||||
if (allLogs.length === 0) {
|
||||
console.log("No logs found after initial fetch, trying direct fetch");
|
||||
await directFetchLogs();
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs with retry:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadLogsWithRetry();
|
||||
checkLogsExist();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Check if there are any logs in the database at all
|
||||
const checkLogsExist = async () => {
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const pb = auth.getPocketBase();
|
||||
|
||||
// Check if the logs collection exists and has any records
|
||||
const result = await pb.collection(Collections.LOGS).getList(1, 1);
|
||||
console.log("Logs collection check:", {
|
||||
totalItems: result.totalItems,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to check logs collection:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate log statistics
|
||||
const logStats = useMemo(() => {
|
||||
if (!allLogs.length) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
|
||||
const todayLogs = allLogs.filter(log => new Date(log.created) >= today);
|
||||
const weekLogs = allLogs.filter(log => new Date(log.created) >= lastWeek);
|
||||
|
||||
// Count by type
|
||||
const typeCount: Record<string, number> = {};
|
||||
allLogs.forEach(log => {
|
||||
const type = log.type || 'unknown';
|
||||
typeCount[type] = (typeCount[type] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total: allLogs.length,
|
||||
today: todayLogs.length,
|
||||
week: weekLogs.length,
|
||||
types: typeCount
|
||||
};
|
||||
}, [allLogs]);
|
||||
|
||||
// Direct fetch from PocketBase as a fallback
|
||||
const directFetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
const pb = auth.getPocketBase();
|
||||
const userId = auth.getPocketBase().authStore.model?.id;
|
||||
|
||||
if (!userId) {
|
||||
setError("Not authenticated");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Direct fetching logs for user:", userId);
|
||||
|
||||
// Fetch logs directly from PocketBase
|
||||
const result = await pb.collection(Collections.LOGS).getList<Log>(1, 100, {
|
||||
filter: `user = "${userId}"`,
|
||||
sort: "-created",
|
||||
expand: "user"
|
||||
});
|
||||
|
||||
console.log("Direct fetch result:", {
|
||||
totalItems: result.totalItems,
|
||||
items: result.items.length
|
||||
});
|
||||
|
||||
if (result.items.length > 0) {
|
||||
setAllLogs(result.items);
|
||||
setTotalPages(Math.ceil(result.items.length / LOGS_PER_PAGE));
|
||||
setTotalLogs(result.items.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to direct fetch logs:", error);
|
||||
setError("Error loading activity");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add a button to try direct fetch
|
||||
const renderDirectFetchButton = () => (
|
||||
<button
|
||||
className="btn btn-sm btn-outline mt-4"
|
||||
onClick={directFetchLogs}
|
||||
>
|
||||
Try Direct Fetch
|
||||
</button>
|
||||
);
|
||||
|
||||
if (loading && !allLogs.length) {
|
||||
return (
|
||||
<p className="text-base-content/70 flex items-center gap-2">
|
||||
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{isFetchingAll ? 'Fetching your activity...' : 'Loading activity...'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-base-content/70 flex items-center gap-2">
|
||||
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Debug logs
|
||||
console.log("Render state:", {
|
||||
logsLength: logs.length,
|
||||
allLogsLength: allLogs.length,
|
||||
searchQuery,
|
||||
loading,
|
||||
currentPage
|
||||
});
|
||||
|
||||
if (allLogs.length === 0 && !searchQuery && !loading) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-base-content/70 flex items-center gap-2 mb-4">
|
||||
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
No recent activity to display.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const userId = auth.getPocketBase().authStore.model?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const sendLog = SendLog.getInstance();
|
||||
await sendLog.send(
|
||||
"create",
|
||||
"test",
|
||||
"Test log created for debugging",
|
||||
userId
|
||||
);
|
||||
console.log("Created test log");
|
||||
setTimeout(() => fetchLogs(true), 1000);
|
||||
} catch (error) {
|
||||
console.error("Failed to create test log:", error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create Test Log
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={directFetchLogs}
|
||||
>
|
||||
Try Direct Fetch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Activity Summary */}
|
||||
{logStats && !searchQuery && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="stat bg-base-200 rounded-lg p-4">
|
||||
<div className="stat-title">Today</div>
|
||||
<div className="stat-value">{logStats.today}</div>
|
||||
<div className="stat-desc">Activities recorded today</div>
|
||||
</div>
|
||||
<div className="stat bg-base-200 rounded-lg p-4">
|
||||
<div className="stat-title">This Week</div>
|
||||
<div className="stat-value">{logStats.week}</div>
|
||||
<div className="stat-desc">Activities in the last 7 days</div>
|
||||
</div>
|
||||
<div className="stat bg-base-200 rounded-lg p-4">
|
||||
<div className="stat-title">Total</div>
|
||||
<div className="stat-value">{logStats.total}</div>
|
||||
<div className="stat-desc">All-time activities</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Refresh Controls */}
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search activity..."
|
||||
onChange={handleSearch}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="dropdown dropdown-end dropdown-hover">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className={`btn btn-ghost btn-square ${isFetchingAll ? 'loading' : ''}`}
|
||||
title="Refresh logs"
|
||||
disabled={isFetchingAll}
|
||||
>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-3.183a.75.75 0 100 1.5h4.992a.75.75 0 00.75-.75V4.356a.75.75 0 00-1.5 0v3.18l-1.9-1.9A9 9 0 003.306 9.67a.75.75 0 101.45.388zm15.408 3.352a.75.75 0 00-.919.53 7.5 7.5 0 01-12.548 3.364l-1.902-1.903h3.183a.75.75 0 000-1.5H2.984a.75.75 0 00-.75.75v4.992a.75.75 0 001.5 0v-3.18l1.9 1.9a9 9 0 0015.059-4.035.75.75 0 00-.53-.918z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>Auto-refresh</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-sm"
|
||||
checked={autoRefresh}
|
||||
onChange={() => { }}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<li><button onClick={directFetchLogs}>Direct fetch from server</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingAll && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm opacity-70">Fetching all activity, please wait...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results Message */}
|
||||
{searchQuery && (
|
||||
<p className="text-sm opacity-70 mb-4">
|
||||
Found {logs.length} results for "{searchQuery}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Logs Display */}
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-4 p-4 rounded-lg hover:bg-base-200 transition-colors duration-200">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${getLogTypeColor(log.type)}`}>
|
||||
{getLogTypeIcon(log.type)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base font-medium">{log.message}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{log.part && (
|
||||
<span className="badge badge-sm">{log.part}</span>
|
||||
)}
|
||||
<p className="text-sm opacity-50">
|
||||
{new Date(log.created).toLocaleString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{!searchQuery && totalLogs > LOGS_PER_PAGE && (
|
||||
<div className="flex justify-between items-center mt-6 pt-4 border-t border-base-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm opacity-70">
|
||||
Showing {totalLogs ? (currentPage - 1) * LOGS_PER_PAGE + 1 : 0}-{Math.min(currentPage * LOGS_PER_PAGE, totalLogs)} of {totalLogs} results
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
Next
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getLogTypeColor(type: string): string {
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'error':
|
||||
return 'bg-error/10 text-error';
|
||||
case 'update':
|
||||
return 'bg-info/10 text-info';
|
||||
case 'delete':
|
||||
return 'bg-warning/10 text-warning';
|
||||
case 'create':
|
||||
return 'bg-success/10 text-success';
|
||||
case 'login':
|
||||
case 'logout':
|
||||
return 'bg-primary/10 text-primary';
|
||||
default:
|
||||
return 'bg-base-300/50 text-base-content';
|
||||
}
|
||||
}
|
||||
|
||||
function getLogTypeIcon(type: string) {
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'error':
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
);
|
||||
case 'update':
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-8.4 8.4a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32l8.4-8.4z" />
|
||||
<path d="M5.25 5.25a3 3 0 00-3 3v10.5a3 3 0 003 3h10.5a3 3 0 003-3V13.5a.75.75 0 00-1.5 0v5.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5V8.25a1.5 1.5 0 011.5-1.5h5.25a.75.75 0 000-1.5H5.25z" />
|
||||
</svg>
|
||||
);
|
||||
case 'delete':
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.5 4.478v.227a48.816 48.816 0 013.878.512.75.75 0 11-.256 1.478l-.209-.035-1.005 13.07a3 3 0 01-2.991 2.77H8.084a3 3 0 01-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 01-.256-1.478A48.567 48.567 0 017.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 013.369 0c1.603.051 2.815 1.387 2.815 2.951zm-6.136-1.452a51.196 51.196 0 013.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 00-6 0v-.113c0-.794.609-1.428 1.364-1.452zm-.355 5.945a.75.75 0 10-1.5.058l.347 9a.75.75 0 101.499-.058l-.346-9zm5.48.058a.75.75 0 10-1.498-.058l-.347 9a.75.75 0 001.5.058l.345-9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'create':
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'login':
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm10.72 4.72a.75.75 0 011.06 0l3 3a.75.75 0 010 1.06l-3 3a.75.75 0 11-1.06-1.06l1.72-1.72H9a.75.75 0 010-1.5h10.94l-1.72-1.72a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'logout':
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm5.03 4.72a.75.75 0 010 1.06l-1.72 1.72h10.94a.75.75 0 010 1.5H10.81l1.72 1.72a.75.75 0 11-1.06 1.06l-3-3a.75.75 0 010-1.06l3-3a.75.75 0 011.06 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm1-13h-2v6l5.25 3.15.75-1.23-4-2.37z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
223
src/components/dashboard/ProfileSection/Stats.tsx
Normal file
223
src/components/dashboard/ProfileSection/Stats.tsx
Normal file
|
@ -0,0 +1,223 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import type { Event, Log, User } from "../../../schemas/pocketbase";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import type { EventAttendee } from "../../../schemas/pocketbase";
|
||||
import { Update } from "../../../scripts/pocketbase/Update";
|
||||
|
||||
// Extended User interface with points property
|
||||
interface ExtendedUser extends User {
|
||||
points?: number;
|
||||
member_type?: string;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const [eventsAttended, setEventsAttended] = useState(0);
|
||||
const [loyaltyPoints, setLoyaltyPoints] = useState(0);
|
||||
const [pointsChange, setPointsChange] = useState("No activity");
|
||||
const [quarterlyPoints, setQuarterlyPoints] = useState(0); // Points earned this quarter
|
||||
const [membershipStatus, setMembershipStatus] = useState("Member");
|
||||
const [memberSince, setMemberSince] = useState<string | null>(null);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<ExtendedUser | null>(null);
|
||||
const [pointsEarned, setPointsEarned] = useState(0);
|
||||
const [attendancePercentage, setAttendancePercentage] = useState(0);
|
||||
|
||||
// Helper function to get the start date of the current quarter
|
||||
const getCurrentQuarterStartDate = (): Date => {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
let quarterStartMonth = 0;
|
||||
|
||||
// Determine the start month of the current quarter
|
||||
if (currentMonth >= 0 && currentMonth <= 2) {
|
||||
quarterStartMonth = 0; // Q1: Jan-Mar
|
||||
} else if (currentMonth >= 3 && currentMonth <= 5) {
|
||||
quarterStartMonth = 3; // Q2: Apr-Jun
|
||||
} else if (currentMonth >= 6 && currentMonth <= 8) {
|
||||
quarterStartMonth = 6; // Q3: Jul-Sep
|
||||
} else {
|
||||
quarterStartMonth = 9; // Q4: Oct-Dec
|
||||
}
|
||||
|
||||
return new Date(now.getFullYear(), quarterStartMonth, 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
const get = Get.getInstance();
|
||||
const currentUser = auth.getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
setError("User not logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = currentUser.id;
|
||||
|
||||
// Get user data
|
||||
const userData = await get.getOne<ExtendedUser>("users", userId);
|
||||
if (!userData) {
|
||||
setError("Failed to load user data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set user data
|
||||
setUser(userData);
|
||||
|
||||
// Get events attended by the user
|
||||
const attendedEvents = await get.getList<EventAttendee>(
|
||||
"event_attendees",
|
||||
1,
|
||||
1000,
|
||||
`user="${userId}"`
|
||||
);
|
||||
|
||||
setEventsAttended(attendedEvents.totalItems);
|
||||
|
||||
// Get user points - either from the user record or calculate from attendees
|
||||
let totalPoints = 0;
|
||||
|
||||
// Calculate quarterly points
|
||||
const quarterStartDate = getCurrentQuarterStartDate();
|
||||
let pointsThisQuarter = 0;
|
||||
|
||||
// If user has points field, use that for total points
|
||||
if (currentUser && currentUser.points !== undefined) {
|
||||
totalPoints = currentUser.points;
|
||||
|
||||
// Still need to calculate quarterly points from attendees
|
||||
attendedEvents.items.forEach(attendee => {
|
||||
const checkinDate = new Date(attendee.time_checked_in);
|
||||
if (checkinDate >= quarterStartDate) {
|
||||
pointsThisQuarter += attendee.points_earned || 0;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Calculate both total and quarterly points from attendees
|
||||
attendedEvents.items.forEach(attendee => {
|
||||
const points = attendee.points_earned || 0;
|
||||
totalPoints += points;
|
||||
|
||||
const checkinDate = new Date(attendee.time_checked_in);
|
||||
if (checkinDate >= quarterStartDate) {
|
||||
pointsThisQuarter += points;
|
||||
}
|
||||
});
|
||||
|
||||
// Update the user record with calculated points if needed
|
||||
if (currentUser) {
|
||||
try {
|
||||
const update = Update.getInstance();
|
||||
await update.updateFields(Collections.USERS, currentUser.id, {
|
||||
points: totalPoints
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating user points:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPointsEarned(totalPoints);
|
||||
setLoyaltyPoints(totalPoints);
|
||||
setQuarterlyPoints(pointsThisQuarter);
|
||||
|
||||
// Get current quarter name
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
let quarterName = "";
|
||||
|
||||
if (currentMonth >= 0 && currentMonth <= 2) {
|
||||
quarterName = "Q1";
|
||||
} else if (currentMonth >= 3 && currentMonth <= 5) {
|
||||
quarterName = "Q2";
|
||||
} else if (currentMonth >= 6 && currentMonth <= 8) {
|
||||
quarterName = "Q3";
|
||||
} else {
|
||||
quarterName = "Q4";
|
||||
}
|
||||
|
||||
setPointsChange(`${pointsThisQuarter} pts in ${quarterName}`);
|
||||
|
||||
// Get all events to calculate percentage
|
||||
const allEvents = await get.getList<Event>("events", 1, 1000);
|
||||
if (allEvents.totalItems > 0) {
|
||||
const percentage = (attendedEvents.totalItems / allEvents.totalItems) * 100;
|
||||
setAttendancePercentage(Math.round(percentage));
|
||||
} else {
|
||||
setAttendancePercentage(0);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
setError("Failed to load stats");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200">
|
||||
<div className="stat">
|
||||
<div className="stat-title skeleton h-4 w-32 mb-2"></div>
|
||||
<div className="stat-value skeleton h-8 w-16"></div>
|
||||
<div className="stat-desc mt-1">
|
||||
<div className="skeleton h-4 w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform">
|
||||
<div className="stat">
|
||||
<div className="stat-title font-medium opacity-80">Events Attended</div>
|
||||
<div className="stat-value text-primary">{eventsAttended}</div>
|
||||
<div className="stat-desc flex items-center gap-2 mt-1">
|
||||
<div className="badge badge-primary badge-sm">Since joining</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform">
|
||||
<div className="stat">
|
||||
<div className="stat-title font-medium opacity-80">Loyalty Points</div>
|
||||
<div className="stat-value text-secondary">{loyaltyPoints}</div>
|
||||
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="badge badge-secondary badge-sm">{quarterlyPoints} pts this quarter</div>
|
||||
<div className="text-xs opacity-70">Total points</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-accent transition-all duration-300 hover:-translate-y-1 transform">
|
||||
<div className="stat">
|
||||
<div className="stat-title font-medium opacity-80">Upcoming Events</div>
|
||||
<div className="stat-value text-accent">{upcomingEvents}</div>
|
||||
<div className="stat-desc flex items-center gap-2 mt-1">
|
||||
<div className="badge badge-accent badge-sm">Available to attend</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
56
src/components/dashboard/ReimbursementSection.astro
Normal file
56
src/components/dashboard/ReimbursementSection.astro
Normal file
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
import ReimbursementForm from "./reimbursement/ReimbursementForm";
|
||||
import ReimbursementList from "./reimbursement/ReimbursementList";
|
||||
---
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">Reimbursement</h2>
|
||||
<p class="opacity-70">Manage your reimbursement requests</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs tabs-boxed">
|
||||
<button class="tab tab-active" data-tab="list">My Requests</button>
|
||||
<button class="tab" data-tab="form">New Request</button>
|
||||
</div>
|
||||
|
||||
<div id="reimbursementContent">
|
||||
<div id="listTab" class="tab-content block">
|
||||
<ReimbursementList client:load />
|
||||
</div>
|
||||
<div id="formTab" class="tab-content hidden">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<ReimbursementForm client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching logic
|
||||
const tabs = document.querySelectorAll(".tab");
|
||||
const tabContents = document.querySelectorAll(".tab-content");
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
const targetTab = tab.getAttribute("data-tab");
|
||||
|
||||
// Update tab states
|
||||
tabs.forEach((t) => t.classList.remove("tab-active"));
|
||||
tab.classList.add("tab-active");
|
||||
|
||||
// Update content visibility
|
||||
tabContents.forEach((content) => {
|
||||
if (content.id === `${targetTab}Tab`) {
|
||||
content.classList.remove("hidden");
|
||||
content.classList.add("block");
|
||||
} else {
|
||||
content.classList.remove("block");
|
||||
content.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
102
src/components/dashboard/SettingsSection.astro
Normal file
102
src/components/dashboard/SettingsSection.astro
Normal file
|
@ -0,0 +1,102 @@
|
|||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import UserProfileSettings from "./SettingsSection/UserProfileSettings";
|
||||
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
|
||||
import NotificationSettings from "./SettingsSection/NotificationSettings";
|
||||
import DisplaySettings from "./SettingsSection/DisplaySettings";
|
||||
---
|
||||
|
||||
<div id="settings-section" class="">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">Settings</h2>
|
||||
<p class="opacity-70">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Settings Card -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title flex items-center gap-3">
|
||||
<div class="badge badge-primary p-3">
|
||||
<Icon name="heroicons:user" class="h-5 w-5" />
|
||||
</div>
|
||||
Profile Information
|
||||
</h3>
|
||||
<p class="text-sm opacity-70 mb-4">
|
||||
Update your personal information and profile details
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<UserProfileSettings client:load />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Security Settings Card -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title flex items-center gap-3">
|
||||
<div class="badge badge-primary p-3">
|
||||
<Icon name="heroicons:lock-closed" class="h-5 w-5" />
|
||||
</div>
|
||||
Account Security
|
||||
</h3>
|
||||
<p class="text-sm opacity-70 mb-4">
|
||||
Manage your account security settings and authentication options
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<AccountSecuritySettings client:load />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Settings Card -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6 relative group"
|
||||
>
|
||||
<!-- Coming Soon Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-base-300 bg-opacity-90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10 rounded-xl"
|
||||
>
|
||||
<div class="text-center">
|
||||
<h4 class="text-xl font-bold">Coming Soon</h4>
|
||||
<p class="text-sm opacity-70">
|
||||
Notification settings will be available in a future update
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<h3 class="card-title flex items-center gap-3">
|
||||
<div class="badge badge-primary p-3">
|
||||
<Icon name="heroicons:bell" class="h-5 w-5" />
|
||||
</div>
|
||||
Notification Preferences
|
||||
</h3>
|
||||
<p class="text-sm opacity-70 mb-4">
|
||||
Customize how and when you receive notifications
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<NotificationSettings client:load />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Settings Card -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title flex items-center gap-3">
|
||||
<div class="badge badge-primary p-3">
|
||||
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
|
||||
</div>
|
||||
Display Settings
|
||||
</h3>
|
||||
<p class="text-sm opacity-70 mb-4">
|
||||
Customize your dashboard appearance and display preferences
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<DisplaySettings client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,163 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { SendLog } from '../../../scripts/pocketbase/SendLog';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function AccountSecuritySettings() {
|
||||
const auth = Authentication.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sessionInfo, setSessionInfo] = useState({
|
||||
lastLogin: '',
|
||||
browser: '',
|
||||
device: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const authenticated = auth.isAuthenticated();
|
||||
setIsAuthenticated(authenticated);
|
||||
|
||||
if (authenticated) {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user) {
|
||||
// Get last login time
|
||||
const lastLogin = user.last_login || user.updated;
|
||||
|
||||
// Get browser and device info
|
||||
const userAgent = navigator.userAgent;
|
||||
const browser = detectBrowser(userAgent);
|
||||
const device = detectDevice(userAgent);
|
||||
|
||||
setSessionInfo({
|
||||
lastLogin: formatDate(lastLogin),
|
||||
browser,
|
||||
device,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logger.send('logout', 'auth', 'User manually logged out from settings page');
|
||||
await auth.logout();
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
toast.error('Failed to log out. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const detectBrowser = (userAgent: string): string => {
|
||||
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
|
||||
if (userAgent.indexOf('Safari') > -1) return 'Safari';
|
||||
if (userAgent.indexOf('Firefox') > -1) return 'Firefox';
|
||||
if (userAgent.indexOf('MSIE') > -1 || userAgent.indexOf('Trident') > -1) return 'Internet Explorer';
|
||||
if (userAgent.indexOf('Edge') > -1) return 'Edge';
|
||||
return 'Unknown Browser';
|
||||
};
|
||||
|
||||
const detectDevice = (userAgent: string): string => {
|
||||
if (/Android/i.test(userAgent)) return 'Android Device';
|
||||
if (/iPhone|iPad|iPod/i.test(userAgent)) return 'iOS Device';
|
||||
if (/Windows/i.test(userAgent)) return 'Windows Device';
|
||||
if (/Mac/i.test(userAgent)) return 'Mac Device';
|
||||
if (/Linux/i.test(userAgent)) return 'Linux Device';
|
||||
return 'Unknown Device';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
if (!dateString) return 'Unknown';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="p-4 text-error bg-error bg-opacity-10 rounded-lg">
|
||||
<span>You must be logged in to access this page.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
{/* Current Session Information */}
|
||||
<div className="bg-base-200 p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-lg mb-2">Current Session</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm opacity-70">Last Login</p>
|
||||
<p className="font-medium">{sessionInfo.lastLogin}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm opacity-70">Browser</p>
|
||||
<p className="font-medium">{sessionInfo.browser}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm opacity-70">Device</p>
|
||||
<p className="font-medium">{sessionInfo.device}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Options */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Authentication Options</h4>
|
||||
<p className="text-sm opacity-70 mb-4">
|
||||
IEEE UCSD uses Single Sign-On (SSO) for authentication.
|
||||
Password management is handled through your IEEEUCSD account.
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-info p-3 bg-info bg-opacity-10 rounded-lg">
|
||||
To change your password, please use the "Forgot Password" option on the login page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account Actions */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-error btn-outline w-full md:w-auto"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
|
||||
If you need to delete your account or have other account-related issues,
|
||||
please contact an IEEE UCSD administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
385
src/components/dashboard/SettingsSection/DisplaySettings.tsx
Normal file
385
src/components/dashboard/SettingsSection/DisplaySettings.tsx
Normal file
|
@ -0,0 +1,385 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
// Default display preferences
|
||||
const DEFAULT_DISPLAY_PREFERENCES = {
|
||||
theme: 'dark',
|
||||
fontSize: 'medium'
|
||||
};
|
||||
|
||||
// Default accessibility settings
|
||||
const DEFAULT_ACCESSIBILITY_SETTINGS = {
|
||||
colorBlindMode: false,
|
||||
reducedMotion: false
|
||||
};
|
||||
|
||||
export default function DisplaySettings() {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const [theme, setTheme] = useState(DEFAULT_DISPLAY_PREFERENCES.theme);
|
||||
const [fontSize, setFontSize] = useState(DEFAULT_DISPLAY_PREFERENCES.fontSize);
|
||||
const [colorBlindMode, setColorBlindMode] = useState(DEFAULT_ACCESSIBILITY_SETTINGS.colorBlindMode);
|
||||
const [reducedMotion, setReducedMotion] = useState(DEFAULT_ACCESSIBILITY_SETTINGS.reducedMotion);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Load saved preferences on component mount
|
||||
useEffect(() => {
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
// First check localStorage for immediate UI updates
|
||||
const savedTheme = localStorage.getItem('theme') || DEFAULT_DISPLAY_PREFERENCES.theme;
|
||||
// Ensure theme is either light or dark
|
||||
const validTheme = ['light', 'dark'].includes(savedTheme) ? savedTheme : DEFAULT_DISPLAY_PREFERENCES.theme;
|
||||
const savedFontSize = localStorage.getItem('fontSize') || DEFAULT_DISPLAY_PREFERENCES.fontSize;
|
||||
const savedColorBlindMode = localStorage.getItem('colorBlindMode') === 'true';
|
||||
const savedReducedMotion = localStorage.getItem('reducedMotion') === 'true';
|
||||
|
||||
setTheme(validTheme);
|
||||
setFontSize(savedFontSize);
|
||||
setColorBlindMode(savedColorBlindMode);
|
||||
setReducedMotion(savedReducedMotion);
|
||||
|
||||
// Apply theme to document
|
||||
document.documentElement.setAttribute('data-theme', validTheme);
|
||||
|
||||
// Apply font size
|
||||
applyFontSize(savedFontSize);
|
||||
|
||||
// Apply accessibility settings
|
||||
if (savedColorBlindMode) {
|
||||
document.documentElement.classList.add('color-blind-mode');
|
||||
}
|
||||
|
||||
if (savedReducedMotion) {
|
||||
document.documentElement.classList.add('reduced-motion');
|
||||
}
|
||||
|
||||
// Then check if user has saved preferences in their profile
|
||||
const user = auth.getCurrentUser();
|
||||
if (user) {
|
||||
let needsDisplayPrefsUpdate = false;
|
||||
let needsAccessibilityUpdate = false;
|
||||
|
||||
// Check and handle display preferences
|
||||
if (user.display_preferences && typeof user.display_preferences === 'string' && user.display_preferences.trim() !== '') {
|
||||
try {
|
||||
const userPrefs = JSON.parse(user.display_preferences);
|
||||
|
||||
// Only update if values exist and are different from localStorage
|
||||
if (userPrefs.theme && ['light', 'dark'].includes(userPrefs.theme) && userPrefs.theme !== validTheme) {
|
||||
setTheme(userPrefs.theme);
|
||||
localStorage.setItem('theme', userPrefs.theme);
|
||||
document.documentElement.setAttribute('data-theme', userPrefs.theme);
|
||||
} else if (!['light', 'dark'].includes(userPrefs.theme)) {
|
||||
// If theme is not valid, mark for update
|
||||
needsDisplayPrefsUpdate = true;
|
||||
}
|
||||
|
||||
if (userPrefs.fontSize && userPrefs.fontSize !== savedFontSize) {
|
||||
setFontSize(userPrefs.fontSize);
|
||||
localStorage.setItem('fontSize', userPrefs.fontSize);
|
||||
applyFontSize(userPrefs.fontSize);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing display preferences:', e);
|
||||
needsDisplayPrefsUpdate = true;
|
||||
}
|
||||
} else {
|
||||
needsDisplayPrefsUpdate = true;
|
||||
}
|
||||
|
||||
// Check and handle accessibility settings
|
||||
if (user.accessibility_settings && typeof user.accessibility_settings === 'string' && user.accessibility_settings.trim() !== '') {
|
||||
try {
|
||||
const accessibilityPrefs = JSON.parse(user.accessibility_settings);
|
||||
|
||||
if (typeof accessibilityPrefs.colorBlindMode === 'boolean' &&
|
||||
accessibilityPrefs.colorBlindMode !== savedColorBlindMode) {
|
||||
setColorBlindMode(accessibilityPrefs.colorBlindMode);
|
||||
localStorage.setItem('colorBlindMode', accessibilityPrefs.colorBlindMode.toString());
|
||||
|
||||
if (accessibilityPrefs.colorBlindMode) {
|
||||
document.documentElement.classList.add('color-blind-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('color-blind-mode');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof accessibilityPrefs.reducedMotion === 'boolean' &&
|
||||
accessibilityPrefs.reducedMotion !== savedReducedMotion) {
|
||||
setReducedMotion(accessibilityPrefs.reducedMotion);
|
||||
localStorage.setItem('reducedMotion', accessibilityPrefs.reducedMotion.toString());
|
||||
|
||||
if (accessibilityPrefs.reducedMotion) {
|
||||
document.documentElement.classList.add('reduced-motion');
|
||||
} else {
|
||||
document.documentElement.classList.remove('reduced-motion');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing accessibility settings:', e);
|
||||
needsAccessibilityUpdate = true;
|
||||
}
|
||||
} else {
|
||||
needsAccessibilityUpdate = true;
|
||||
}
|
||||
|
||||
// Initialize default settings if needed
|
||||
if (needsDisplayPrefsUpdate || needsAccessibilityUpdate) {
|
||||
await initializeDefaultSettings(user.id, needsDisplayPrefsUpdate, needsAccessibilityUpdate);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading preferences:', error);
|
||||
toast.error('Failed to load display preferences');
|
||||
}
|
||||
};
|
||||
|
||||
loadPreferences();
|
||||
}, []);
|
||||
|
||||
// Initialize default settings if not set
|
||||
const initializeDefaultSettings = async (userId: string, updateDisplayPrefs: boolean, updateAccessibility: boolean) => {
|
||||
try {
|
||||
const updateData: any = {};
|
||||
|
||||
if (updateDisplayPrefs) {
|
||||
updateData.display_preferences = JSON.stringify({
|
||||
theme,
|
||||
fontSize
|
||||
});
|
||||
}
|
||||
|
||||
if (updateAccessibility) {
|
||||
updateData.accessibility_settings = JSON.stringify({
|
||||
colorBlindMode,
|
||||
reducedMotion
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await update.updateFields(Collections.USERS, userId, updateData);
|
||||
console.log('Initialized default display and accessibility settings');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing default settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply font size to document
|
||||
const applyFontSize = (size: string) => {
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
// Remove existing font size classes
|
||||
htmlElement.classList.remove('text-sm', 'text-base', 'text-lg', 'text-xl');
|
||||
|
||||
// Add new font size class
|
||||
switch (size) {
|
||||
case 'small':
|
||||
htmlElement.classList.add('text-sm');
|
||||
break;
|
||||
case 'medium':
|
||||
htmlElement.classList.add('text-base');
|
||||
break;
|
||||
case 'large':
|
||||
htmlElement.classList.add('text-lg');
|
||||
break;
|
||||
case 'extra-large':
|
||||
htmlElement.classList.add('text-xl');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle theme change
|
||||
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTheme = e.target.value;
|
||||
setTheme(newTheme);
|
||||
|
||||
// Apply theme to document
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
// Handle font size change
|
||||
const handleFontSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newSize = e.target.value;
|
||||
setFontSize(newSize);
|
||||
|
||||
// Apply font size
|
||||
applyFontSize(newSize);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('fontSize', newSize);
|
||||
};
|
||||
|
||||
// Handle color blind mode toggle
|
||||
const handleColorBlindModeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const enabled = e.target.checked;
|
||||
setColorBlindMode(enabled);
|
||||
|
||||
// Apply to document
|
||||
if (enabled) {
|
||||
document.documentElement.classList.add('color-blind-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('color-blind-mode');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('colorBlindMode', enabled.toString());
|
||||
};
|
||||
|
||||
// Handle reduced motion toggle
|
||||
const handleReducedMotionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const enabled = e.target.checked;
|
||||
setReducedMotion(enabled);
|
||||
|
||||
// Apply to document
|
||||
if (enabled) {
|
||||
document.documentElement.classList.add('reduced-motion');
|
||||
} else {
|
||||
document.documentElement.classList.remove('reduced-motion');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('reducedMotion', enabled.toString());
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const user = auth.getCurrentUser();
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
// Save display preferences to user record
|
||||
const displayPreferences = {
|
||||
theme,
|
||||
fontSize
|
||||
};
|
||||
|
||||
// Save accessibility settings to user record
|
||||
const accessibilitySettings = {
|
||||
colorBlindMode,
|
||||
reducedMotion
|
||||
};
|
||||
|
||||
// Update user record
|
||||
await update.updateFields(
|
||||
Collections.USERS,
|
||||
user.id,
|
||||
{
|
||||
display_preferences: JSON.stringify(displayPreferences),
|
||||
accessibility_settings: JSON.stringify(accessibilitySettings)
|
||||
}
|
||||
);
|
||||
|
||||
// Show success message
|
||||
toast.success('Display settings saved successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error saving display settings:', error);
|
||||
toast.error('Failed to save display settings to your profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Theme Settings */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Theme</h4>
|
||||
<div className="form-control w-full max-w-xs">
|
||||
<select
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
className="select select-bordered"
|
||||
>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Select your preferred theme</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size Settings */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Font Size</h4>
|
||||
<div className="form-control w-full max-w-xs">
|
||||
<select
|
||||
value={fontSize}
|
||||
onChange={handleFontSizeChange}
|
||||
className="select select-bordered"
|
||||
>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
<option value="extra-large">Extra Large</option>
|
||||
</select>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Select your preferred font size</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accessibility Settings */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={colorBlindMode}
|
||||
onChange={handleColorBlindModeChange}
|
||||
className="toggle toggle-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Color Blind Mode</span>
|
||||
<p className="text-xs opacity-70">Enhances color contrast and uses color-blind friendly palettes</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control mt-2">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reducedMotion}
|
||||
onChange={handleReducedMotionChange}
|
||||
className="toggle toggle-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Reduced Motion</span>
|
||||
<p className="text-xs opacity-70">Minimizes animations and transitions</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-info">
|
||||
These settings are saved to your browser and your IEEE UCSD account. They will be applied whenever you log in.
|
||||
</p>
|
||||
|
||||
<div className="form-control">
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,233 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
// Default notification preferences
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||
emailNotifications: true,
|
||||
eventReminders: true,
|
||||
eventUpdates: true,
|
||||
reimbursementUpdates: true,
|
||||
officerAnnouncements: true,
|
||||
marketingEmails: false
|
||||
};
|
||||
|
||||
export default function NotificationSettings() {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Notification preferences
|
||||
const [preferences, setPreferences] = useState(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user) {
|
||||
// If user has notification_preferences, parse and use them
|
||||
// Otherwise use defaults
|
||||
if (user.notification_preferences && typeof user.notification_preferences === 'string' && user.notification_preferences.trim() !== '') {
|
||||
try {
|
||||
const savedPrefs = JSON.parse(user.notification_preferences);
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
...savedPrefs
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error parsing notification preferences:', e);
|
||||
// Initialize with defaults and save to user profile
|
||||
await initializeDefaultPreferences(user.id);
|
||||
}
|
||||
} else {
|
||||
// Initialize with defaults and save to user profile
|
||||
await initializeDefaultPreferences(user.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading notification preferences:', error);
|
||||
toast.error('Failed to load notification preferences');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPreferences();
|
||||
}, []);
|
||||
|
||||
// Initialize default preferences if not set
|
||||
const initializeDefaultPreferences = async (userId: string) => {
|
||||
try {
|
||||
await update.updateFields(
|
||||
Collections.USERS,
|
||||
userId,
|
||||
{ notification_preferences: JSON.stringify(DEFAULT_NOTIFICATION_PREFERENCES) }
|
||||
);
|
||||
setPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
console.log('Initialized default notification preferences');
|
||||
} catch (error) {
|
||||
console.error('Error initializing default notification preferences:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, checked } = e.target;
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const user = auth.getCurrentUser();
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
// Save preferences as JSON string
|
||||
await update.updateFields(
|
||||
Collections.USERS,
|
||||
user.id,
|
||||
{ notification_preferences: JSON.stringify(preferences) }
|
||||
);
|
||||
|
||||
toast.success('Notification preferences saved successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
toast.error('Failed to save notification preferences');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="emailNotifications"
|
||||
className="toggle toggle-primary"
|
||||
checked={preferences.emailNotifications}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Email Notifications</span>
|
||||
<p className="text-xs opacity-70">Receive notifications via email</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="eventReminders"
|
||||
className="toggle toggle-primary"
|
||||
checked={preferences.eventReminders}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Event Reminders</span>
|
||||
<p className="text-xs opacity-70">Receive reminders about upcoming events</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="eventUpdates"
|
||||
className="toggle toggle-primary"
|
||||
checked={preferences.eventUpdates}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Event Updates</span>
|
||||
<p className="text-xs opacity-70">Receive updates about events you've registered for</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="reimbursementUpdates"
|
||||
className="toggle toggle-primary"
|
||||
checked={preferences.reimbursementUpdates}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Reimbursement Updates</span>
|
||||
<p className="text-xs opacity-70">Receive updates about your reimbursement requests</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="officerAnnouncements"
|
||||
className="toggle toggle-primary"
|
||||
checked={preferences.officerAnnouncements}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Officer Announcements</span>
|
||||
<p className="text-xs opacity-70">Receive important announcements from IEEE UCSD officers</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="marketingEmails"
|
||||
className="toggle toggle-primary"
|
||||
checked={preferences.marketingEmails}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Marketing Emails</span>
|
||||
<p className="text-xs opacity-70">Receive promotional emails about IEEE UCSD events and opportunities</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-info mt-6 mb-6">
|
||||
Note: Some critical notifications about your account cannot be disabled.
|
||||
</p>
|
||||
|
||||
<div className="form-control">
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
247
src/components/dashboard/SettingsSection/UserProfileSettings.tsx
Normal file
247
src/components/dashboard/SettingsSection/UserProfileSettings.tsx
Normal file
|
@ -0,0 +1,247 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { Collections, type User } from '../../../schemas/pocketbase/schema';
|
||||
import allMajors from '../../../data/allUCSDMajors.txt?raw';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function UserProfileSettings() {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
major: '',
|
||||
graduation_year: '',
|
||||
zelle_information: '',
|
||||
pid: '',
|
||||
member_id: ''
|
||||
});
|
||||
|
||||
// Parse the majors list from the text file and sort alphabetically
|
||||
const majorsList = allMajors
|
||||
.split('\n')
|
||||
.filter(major => major.trim() !== '')
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
useEffect(() => {
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (currentUser) {
|
||||
setUser(currentUser);
|
||||
setFormData({
|
||||
name: currentUser.name || '',
|
||||
email: currentUser.email || '',
|
||||
major: currentUser.major || '',
|
||||
graduation_year: currentUser.graduation_year?.toString() || '',
|
||||
zelle_information: currentUser.zelle_information || '',
|
||||
pid: currentUser.pid || '',
|
||||
member_id: currentUser.member_id || ''
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
toast.error('Failed to load user data. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUserData();
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
const updateData: Partial<User> = {
|
||||
name: formData.name,
|
||||
major: formData.major || undefined,
|
||||
zelle_information: formData.zelle_information || undefined,
|
||||
pid: formData.pid || undefined,
|
||||
member_id: formData.member_id || undefined
|
||||
};
|
||||
|
||||
// Only include graduation_year if it's a valid number
|
||||
if (formData.graduation_year && !isNaN(Number(formData.graduation_year))) {
|
||||
updateData.graduation_year = Number(formData.graduation_year);
|
||||
}
|
||||
|
||||
await update.updateFields(Collections.USERS, user.id, updateData);
|
||||
|
||||
// Update local user state
|
||||
setUser(prev => prev ? { ...prev, ...updateData } : null);
|
||||
|
||||
toast.success('Profile updated successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
toast.error('Failed to update profile. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
<div>
|
||||
<span>You must be logged in to access this page.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Full Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
className="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Email Address</span>
|
||||
<span className="label-text-alt text-info">Cannot be changed</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
className="input input-bordered w-full"
|
||||
disabled
|
||||
/>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Email changes must be processed by an administrator</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">PID</span>
|
||||
<span className="label-text-alt text-info">UCSD Student ID</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="pid"
|
||||
value={formData.pid}
|
||||
onChange={handleInputChange}
|
||||
className="input input-bordered w-full"
|
||||
placeholder="A12345678"
|
||||
pattern="[A-Za-z][0-9]{8}"
|
||||
title="PID format: A12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">IEEE Member ID</span>
|
||||
<span className="label-text-alt text-info">Optional</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="member_id"
|
||||
value={formData.member_id}
|
||||
onChange={handleInputChange}
|
||||
className="input input-bordered w-full"
|
||||
placeholder="IEEE Membership Number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Major</span>
|
||||
</label>
|
||||
<select
|
||||
name="major"
|
||||
value={formData.major}
|
||||
onChange={handleInputChange}
|
||||
className="select select-bordered w-full"
|
||||
>
|
||||
<option value="">Select a major</option>
|
||||
{majorsList.map((major, index) => (
|
||||
<option key={index} value={major}>
|
||||
{major}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Graduation Year</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="graduation_year"
|
||||
value={formData.graduation_year}
|
||||
onChange={handleInputChange}
|
||||
className="input input-bordered w-full"
|
||||
min="2000"
|
||||
max="2100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Zelle Information (for reimbursements)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="zelle_information"
|
||||
value={formData.zelle_information}
|
||||
onChange={handleInputChange}
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Email or phone number associated with your Zelle account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
99
src/components/dashboard/SponsorAnalytics.astro
Normal file
99
src/components/dashboard/SponsorAnalytics.astro
Normal file
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
// Sponsor Analytics Component
|
||||
---
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Analytics Dashboard</h2>
|
||||
|
||||
<!-- Metrics Overview -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
<div class="stat bg-base-200 rounded-box p-4">
|
||||
<div class="stat-title">Resume Downloads</div>
|
||||
<div class="stat-value text-primary">89</div>
|
||||
<div class="stat-desc">↗︎ 14 (30 days)</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box p-4">
|
||||
<div class="stat-title">Event Attendance</div>
|
||||
<div class="stat-value">45</div>
|
||||
<div class="stat-desc">↘︎ 5 (30 days)</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box p-4">
|
||||
<div class="stat-title">Student Interactions</div>
|
||||
<div class="stat-value text-secondary">124</div>
|
||||
<div class="stat-desc">↗︎ 32 (30 days)</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box p-4">
|
||||
<div class="stat-title">Workshop Engagement</div>
|
||||
<div class="stat-value">92%</div>
|
||||
<div class="stat-desc">↗︎ 8% (30 days)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Analytics -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Event Performance -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Event Performance</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Attendance</th>
|
||||
<th>Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Tech Talk</td>
|
||||
<td>32</td>
|
||||
<td>4.8/5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Workshop</td>
|
||||
<td>28</td>
|
||||
<td>4.6/5</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume Analytics -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Resume Analytics</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Major</th>
|
||||
<th>Downloads</th>
|
||||
<th>Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Computer Science</td>
|
||||
<td>45</td>
|
||||
<td>↗︎</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Electrical Engineering</td>
|
||||
<td>32</td>
|
||||
<td>↗︎</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
78
src/components/dashboard/SponsorDashboard.astro
Normal file
78
src/components/dashboard/SponsorDashboard.astro
Normal file
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
// Sponsor Dashboard Component
|
||||
---
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Sponsor Dashboard</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Sponsorship Status -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Sponsorship Status</h3>
|
||||
<p class="text-primary font-semibold">Active</p>
|
||||
<p class="text-sm opacity-70">Valid until: Dec 31, 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partnership Level -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Partnership Level</h3>
|
||||
<p class="text-primary font-semibold">Platinum</p>
|
||||
<p class="text-sm opacity-70">All benefits included</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Quick Actions</h3>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
>Contact Us</button
|
||||
>
|
||||
<button class="btn btn-outline btn-sm"
|
||||
>View Contract</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-xl font-semibold mb-4">Recent Activity</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Activity</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>2024-01-15</td>
|
||||
<td>Resume Book Access</td>
|
||||
<td
|
||||
><span class="badge badge-success"
|
||||
>Completed</span
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2024-01-10</td>
|
||||
<td>Workshop Scheduling</td>
|
||||
<td
|
||||
><span class="badge badge-warning">Pending</span
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
442
src/components/dashboard/reimbursement/ReceiptForm.tsx
Normal file
442
src/components/dashboard/reimbursement/ReceiptForm.tsx
Normal file
|
@ -0,0 +1,442 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import FilePreview from '../universal/FilePreview';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
||||
|
||||
interface ReceiptFormData {
|
||||
file: File;
|
||||
itemized_expenses: ItemizedExpense[];
|
||||
tax: number;
|
||||
date: string;
|
||||
location_name: string;
|
||||
location_address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface ReceiptFormProps {
|
||||
onSubmit: (data: ReceiptFormData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EXPENSE_CATEGORIES = [
|
||||
'Travel',
|
||||
'Meals',
|
||||
'Supplies',
|
||||
'Equipment',
|
||||
'Software',
|
||||
'Event Expenses',
|
||||
'Other'
|
||||
];
|
||||
|
||||
// Add these animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [itemizedExpenses, setItemizedExpenses] = useState<ItemizedExpense[]>([
|
||||
{ description: '', amount: 0, category: '' }
|
||||
]);
|
||||
const [tax, setTax] = useState<number>(0);
|
||||
const [date, setDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||
const [locationName, setLocationName] = useState<string>('');
|
||||
const [locationAddress, setLocationAddress] = useState<string>('');
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const selectedFile = e.target.files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!selectedFile.type.match('image/*') && selectedFile.type !== 'application/pdf') {
|
||||
toast.error('Only images and PDF files are allowed');
|
||||
setError('Only images and PDF files are allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB limit)
|
||||
if (selectedFile.size > 5 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 5MB');
|
||||
setError('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setPreviewUrl(URL.createObjectURL(selectedFile));
|
||||
setError('');
|
||||
toast.success('File uploaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const addExpenseItem = () => {
|
||||
setItemizedExpenses([...itemizedExpenses, { description: '', amount: 0, category: '' }]);
|
||||
};
|
||||
|
||||
const removeExpenseItem = (index: number) => {
|
||||
if (itemizedExpenses.length === 1) return;
|
||||
setItemizedExpenses(itemizedExpenses.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleExpenseItemChange = (index: number, field: keyof ItemizedExpense, value: string | number) => {
|
||||
const newItems = [...itemizedExpenses];
|
||||
newItems[index] = {
|
||||
...newItems[index],
|
||||
[field]: value
|
||||
};
|
||||
setItemizedExpenses(newItems);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!file) {
|
||||
setError('Please upload a receipt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!locationName.trim()) {
|
||||
setError('Location name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!locationAddress.trim()) {
|
||||
setError('Location address is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemizedExpenses.some(item => !item.description || !item.category || item.amount <= 0)) {
|
||||
setError('All expense items must be filled out completely');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
file: file,
|
||||
itemized_expenses: itemizedExpenses,
|
||||
tax,
|
||||
date,
|
||||
location_name: locationName,
|
||||
location_address: locationAddress,
|
||||
notes
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-2 gap-6 h-full"
|
||||
>
|
||||
{/* Left side - Form */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="alert alert-error shadow-lg"
|
||||
>
|
||||
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
||||
<span>{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* File Upload */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Upload Receipt</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-base-content/50">
|
||||
<Icon icon="heroicons:cloud-arrow-up" className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Date */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Date</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Location Name */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Location Name</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={locationName}
|
||||
onChange={(e) => setLocationName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Location Address */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Location Address</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={locationAddress}
|
||||
onChange={(e) => setLocationAddress(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Notes */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Notes</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Itemized Expenses */}
|
||||
<motion.div variants={itemVariants} className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-lg font-medium">Itemized Expenses</label>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
|
||||
onClick={addExpenseItem}
|
||||
>
|
||||
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
||||
Add Item
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{itemizedExpenses.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
|
||||
>
|
||||
<div className="card-body p-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Description</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
value={item.description}
|
||||
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Category</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={item.category}
|
||||
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select category</option>
|
||||
{EXPENSE_CATEGORIES.map(category => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Amount ($)</span>
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered"
|
||||
value={item.amount}
|
||||
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
{itemizedExpenses.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm btn-error"
|
||||
onClick={() => removeExpenseItem(index)}
|
||||
>
|
||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Tax */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Tax Amount ($)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={tax}
|
||||
onChange={(e) => setTax(Number(e.target.value))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Total */}
|
||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-base-content/70">
|
||||
<span>Subtotal:</span>
|
||||
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-base-content/70">
|
||||
<span>Tax:</span>
|
||||
<span className="font-mono">${tax.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="divider my-1"></div>
|
||||
<div className="flex justify-between items-center font-medium text-lg">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<motion.div variants={itemVariants} className="flex justify-end gap-3 mt-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="button"
|
||||
className="btn btn-ghost hover:btn-error transition-all duration-300"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="submit"
|
||||
className="btn btn-primary shadow-md hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
Add Receipt
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{/* Right side - Preview */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="border-l border-base-300 pl-6"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{previewUrl ? (
|
||||
<motion.div
|
||||
key="preview"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<FilePreview
|
||||
url={previewUrl}
|
||||
filename={file?.name || ''}
|
||||
isModal={false}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="placeholder"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-center justify-center h-full text-base-content/70"
|
||||
>
|
||||
<div className="text-center">
|
||||
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
||||
<p className="text-lg">Upload a receipt to preview</p>
|
||||
<p className="text-sm text-base-content/50 mt-2">Supported formats: Images, PDF</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
652
src/components/dashboard/reimbursement/ReimbursementForm.tsx
Normal file
652
src/components/dashboard/reimbursement/ReimbursementForm.tsx
Normal file
|
@ -0,0 +1,652 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import ReceiptForm from './ReceiptForm';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import FilePreview from '../universal/FilePreview';
|
||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||
|
||||
interface ReceiptFormData {
|
||||
file: File;
|
||||
itemized_expenses: ItemizedExpense[];
|
||||
tax: number;
|
||||
date: string;
|
||||
location_name: string;
|
||||
location_address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Extended Reimbursement interface with form-specific fields
|
||||
interface ReimbursementRequest extends Partial<Omit<Reimbursement, 'receipts'>> {
|
||||
title: string;
|
||||
total_amount: number;
|
||||
date_of_purchase: string;
|
||||
payment_method: string;
|
||||
status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress';
|
||||
additional_info: string;
|
||||
receipts: string[];
|
||||
department: 'internal' | 'external' | 'projects' | 'events' | 'other';
|
||||
}
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
'Personal Credit Card',
|
||||
'Personal Debit Card',
|
||||
'Cash',
|
||||
'Personal Check',
|
||||
'Other'
|
||||
];
|
||||
|
||||
const DEPARTMENTS = [
|
||||
'internal',
|
||||
'external',
|
||||
'projects',
|
||||
'events',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
const DEPARTMENT_LABELS = {
|
||||
internal: 'Internal',
|
||||
external: 'External',
|
||||
projects: 'Projects',
|
||||
events: 'Events',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
// Add these animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function ReimbursementForm() {
|
||||
const [request, setRequest] = useState<ReimbursementRequest>({
|
||||
title: '',
|
||||
total_amount: 0,
|
||||
date_of_purchase: new Date().toISOString().split('T')[0],
|
||||
payment_method: '',
|
||||
status: 'submitted',
|
||||
additional_info: '',
|
||||
receipts: [],
|
||||
department: 'internal'
|
||||
});
|
||||
|
||||
const [receipts, setReceipts] = useState<(ReceiptFormData & { id: string })[]>([]);
|
||||
const [showReceiptForm, setShowReceiptForm] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showReceiptDetails, setShowReceiptDetails] = useState(false);
|
||||
const [selectedReceiptDetails, setSelectedReceiptDetails] = useState<ReceiptFormData | null>(null);
|
||||
const [hasZelleInfo, setHasZelleInfo] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
checkZelleInformation();
|
||||
}, []);
|
||||
|
||||
const checkZelleInformation = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const pb = auth.getPocketBase();
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const user = await pb.collection('users').getOne(userId);
|
||||
setHasZelleInfo(!!user.zelle_information);
|
||||
} catch (error) {
|
||||
console.error('Error checking Zelle information:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
||||
<div className="loading loading-spinner loading-lg text-primary"></div>
|
||||
<p className="mt-4 text-base-content/70">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasZelleInfo === false) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-2xl mx-auto text-center py-12"
|
||||
>
|
||||
<div className="card bg-base-200 p-8">
|
||||
<Icon icon="heroicons:exclamation-triangle" className="h-16 w-16 mx-auto text-warning" />
|
||||
<h2 className="text-2xl font-bold mt-6">Zelle Information Required</h2>
|
||||
<p className="mt-4 text-base-content/70">
|
||||
Before submitting a reimbursement request, you need to provide your Zelle information.
|
||||
This is required for processing your reimbursement payments.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<button
|
||||
className="btn btn-primary gap-2"
|
||||
onClick={() => {
|
||||
const profileBtn = document.querySelector('[data-section="settings"]') as HTMLButtonElement;
|
||||
if (profileBtn) profileBtn.click();
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:user-circle" className="h-5 w-5" />
|
||||
Update Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAddReceipt = async (receiptData: ReceiptFormData) => {
|
||||
try {
|
||||
const pb = auth.getPocketBase();
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
if (!userId) {
|
||||
toast.error('User not authenticated');
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// Create receipt record
|
||||
const formData = new FormData();
|
||||
formData.append('file', receiptData.file);
|
||||
formData.append('created_by', userId);
|
||||
formData.append('itemized_expenses', JSON.stringify(receiptData.itemized_expenses));
|
||||
formData.append('tax', receiptData.tax.toString());
|
||||
formData.append('date', new Date(receiptData.date).toISOString());
|
||||
formData.append('location_name', receiptData.location_name);
|
||||
formData.append('location_address', receiptData.location_address);
|
||||
formData.append('notes', receiptData.notes);
|
||||
|
||||
const response = await pb.collection('receipts').create(formData);
|
||||
|
||||
// Sync the receipts collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.RECEIPTS);
|
||||
|
||||
// Add receipt to state
|
||||
setReceipts(prev => [...prev, { ...receiptData, id: response.id }]);
|
||||
|
||||
// Update total amount
|
||||
const totalAmount = receiptData.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receiptData.tax;
|
||||
setRequest(prev => ({
|
||||
...prev,
|
||||
total_amount: prev.total_amount + totalAmount,
|
||||
receipts: [...prev.receipts, response.id]
|
||||
}));
|
||||
|
||||
setShowReceiptForm(false);
|
||||
toast.success('Receipt added successfully');
|
||||
} catch (error) {
|
||||
console.error('Error creating receipt:', error);
|
||||
toast.error('Failed to add receipt');
|
||||
setError('Failed to add receipt. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (!request.title.trim()) {
|
||||
toast.error('Title is required');
|
||||
setError('Title is required');
|
||||
return;
|
||||
}
|
||||
if (!request.payment_method) {
|
||||
toast.error('Payment method is required');
|
||||
setError('Payment method is required');
|
||||
return;
|
||||
}
|
||||
if (receipts.length === 0) {
|
||||
toast.error('At least one receipt is required');
|
||||
setError('At least one receipt is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const pb = auth.getPocketBase();
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// Create reimbursement record
|
||||
const formData = new FormData();
|
||||
formData.append('title', request.title);
|
||||
formData.append('total_amount', request.total_amount.toString());
|
||||
formData.append('date_of_purchase', new Date(request.date_of_purchase).toISOString());
|
||||
formData.append('payment_method', request.payment_method);
|
||||
formData.append('status', 'submitted');
|
||||
formData.append('submitted_by', userId);
|
||||
formData.append('additional_info', request.additional_info);
|
||||
formData.append('receipts', JSON.stringify(request.receipts));
|
||||
formData.append('department', request.department);
|
||||
|
||||
await pb.collection('reimbursement').create(formData);
|
||||
|
||||
// Sync the reimbursements collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
|
||||
|
||||
// Reset form
|
||||
setRequest({
|
||||
title: '',
|
||||
total_amount: 0,
|
||||
date_of_purchase: new Date().toISOString().split('T')[0],
|
||||
payment_method: '',
|
||||
status: 'submitted',
|
||||
additional_info: '',
|
||||
receipts: [],
|
||||
department: 'internal'
|
||||
});
|
||||
setReceipts([]);
|
||||
setError('');
|
||||
|
||||
toast.success('🎉 Reimbursement request submitted successfully! Check "My Requests" to view it.', {
|
||||
duration: 5000,
|
||||
position: 'top-center',
|
||||
style: {
|
||||
background: '#10B981',
|
||||
color: '#FFFFFF',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting reimbursement request:', error);
|
||||
toast.error('Failed to submit reimbursement request. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="alert alert-error shadow-lg"
|
||||
>
|
||||
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
||||
<span>{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Title */}
|
||||
<div className="form-control md:col-span-2">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Title</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={request.title}
|
||||
onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date of Purchase */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Date of Purchase</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={request.date_of_purchase}
|
||||
onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Payment Method</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered focus:select-primary transition-all duration-300"
|
||||
value={request.payment_method}
|
||||
onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select payment method</option>
|
||||
{PAYMENT_METHODS.map(method => (
|
||||
<option key={method} value={method}>{method}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Department */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Department</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered focus:select-primary transition-all duration-300"
|
||||
value={request.department}
|
||||
onChange={(e) => setRequest(prev => ({ ...prev, department: e.target.value as typeof DEPARTMENTS[number] }))}
|
||||
required
|
||||
>
|
||||
{DEPARTMENTS.map(dept => (
|
||||
<option key={dept} value={dept}>{DEPARTMENT_LABELS[dept]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="form-control md:col-span-2">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Additional Information</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
|
||||
value={request.additional_info}
|
||||
onChange={(e) => setRequest(prev => ({ ...prev, additional_info: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Receipts */}
|
||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Receipts</h3>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
|
||||
onClick={() => setShowReceiptForm(true)}
|
||||
>
|
||||
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
||||
Add Receipt
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{receipts.length > 0 ? (
|
||||
<motion.div layout className="grid gap-4">
|
||||
<AnimatePresence>
|
||||
{receipts.map((receipt, index) => (
|
||||
<motion.div
|
||||
key={receipt.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="card-body p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{receipt.location_name}</h3>
|
||||
<p className="text-sm text-base-content/70">{receipt.location_address}</p>
|
||||
<p className="text-sm mt-2">
|
||||
Total: <span className="font-mono font-medium text-primary">${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm gap-2"
|
||||
onClick={() => {
|
||||
setSelectedReceiptDetails(receipt);
|
||||
setShowReceiptDetails(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
||||
View Details
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-12 bg-base-100 rounded-lg"
|
||||
>
|
||||
<Icon icon="heroicons:receipt" className="h-16 w-16 mx-auto text-base-content/30" />
|
||||
<h3 className="mt-4 text-lg font-medium">No receipts added</h3>
|
||||
<p className="text-base-content/70 mt-2">Add receipts to continue</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{receipts.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-base-100 rounded-lg">
|
||||
<div className="flex justify-between items-center text-lg font-medium">
|
||||
<span>Total Amount:</span>
|
||||
<span className="font-mono text-primary">${request.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="mt-8"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
type="submit"
|
||||
className="btn btn-primary w-full h-12 shadow-md hover:shadow-lg transition-all duration-300 text-lg"
|
||||
disabled={isSubmitting || receipts.length === 0}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="heroicons:paper-airplane" className="h-5 w-5" />
|
||||
Submit Reimbursement Request
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</form>
|
||||
|
||||
{/* Receipt Form Modal */}
|
||||
<AnimatePresence>
|
||||
{showReceiptForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="modal modal-open"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="modal-box max-w-5xl bg-base-100/95 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Add Receipt</h3>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="btn btn-ghost btn-sm btn-circle"
|
||||
onClick={() => setShowReceiptForm(false)}
|
||||
>
|
||||
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
|
||||
</motion.button>
|
||||
</div>
|
||||
<ReceiptForm
|
||||
onSubmit={handleAddReceipt}
|
||||
onCancel={() => setShowReceiptForm(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Receipt Details Modal */}
|
||||
<AnimatePresence>
|
||||
{showReceiptDetails && selectedReceiptDetails && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="modal modal-open"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="modal-box max-w-4xl bg-base-100/95 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Receipt Details
|
||||
</h3>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="btn btn-ghost btn-sm btn-circle"
|
||||
onClick={() => {
|
||||
setShowReceiptDetails(false);
|
||||
setSelectedReceiptDetails(null);
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Location</label>
|
||||
<p className="mt-1">{selectedReceiptDetails.location_name}</p>
|
||||
<p className="text-sm text-base-content/70">{selectedReceiptDetails.location_address}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Date</label>
|
||||
<p className="mt-1">{new Date(selectedReceiptDetails.date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
{selectedReceiptDetails.notes && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<p className="mt-1">{selectedReceiptDetails.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Itemized Expenses</label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedReceiptDetails.itemized_expenses.map((item, index) => (
|
||||
<div key={index} className="card bg-base-200 p-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium">Description</label>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Category</label>
|
||||
<p>{item.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Amount</label>
|
||||
<p>${item.amount.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Tax</label>
|
||||
<p className="mt-1">${selectedReceiptDetails.tax.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Total</label>
|
||||
<p className="mt-1">${(selectedReceiptDetails.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceiptDetails.tax).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-l border-base-300 pl-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Receipt Image</h3>
|
||||
</div>
|
||||
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
|
||||
<FilePreview
|
||||
url={URL.createObjectURL(selectedReceiptDetails.file)}
|
||||
filename={selectedReceiptDetails.file.name}
|
||||
isModal={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
848
src/components/dashboard/reimbursement/ReimbursementList.tsx
Normal file
848
src/components/dashboard/reimbursement/ReimbursementList.tsx
Normal file
|
@ -0,0 +1,848 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||
import FilePreview from '../universal/FilePreview';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface AuditNote {
|
||||
note: string;
|
||||
auditor_id: string;
|
||||
timestamp: string;
|
||||
is_private: boolean;
|
||||
}
|
||||
|
||||
// Extended Reimbursement interface with component-specific properties
|
||||
interface ReimbursementRequest extends Omit<Reimbursement, 'audit_notes'> {
|
||||
audit_notes: AuditNote[] | null;
|
||||
}
|
||||
|
||||
// Extended Receipt interface with component-specific properties
|
||||
interface ReceiptDetails extends Omit<Receipt, 'itemized_expenses' | 'audited_by'> {
|
||||
file: string;
|
||||
itemized_expenses: ItemizedExpense[];
|
||||
audited_by: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
submitted: 'badge-primary',
|
||||
under_review: 'badge-warning',
|
||||
approved: 'badge-success',
|
||||
rejected: 'badge-error',
|
||||
paid: 'badge-success',
|
||||
in_progress: 'badge-info'
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
submitted: 'Submitted',
|
||||
under_review: 'Under Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
paid: 'Paid',
|
||||
in_progress: 'In Progress'
|
||||
};
|
||||
|
||||
const DEPARTMENT_LABELS = {
|
||||
internal: 'Internal',
|
||||
external: 'External',
|
||||
projects: 'Projects',
|
||||
events: 'Events',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
// Add this after the STATUS_LABELS constant
|
||||
const STATUS_ORDER = ['submitted', 'under_review', 'approved', 'rejected', 'in_progress', 'paid'] as const;
|
||||
|
||||
const STATUS_ICONS = {
|
||||
submitted: 'heroicons:paper-airplane',
|
||||
under_review: 'heroicons:eye',
|
||||
approved: 'heroicons:check-circle',
|
||||
rejected: 'heroicons:x-circle',
|
||||
in_progress: 'heroicons:clock',
|
||||
paid: 'heroicons:banknotes'
|
||||
} as const;
|
||||
|
||||
// Add these animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
when: "beforeChildren",
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function ReimbursementList() {
|
||||
const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewFilename, setPreviewFilename] = useState('');
|
||||
const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null);
|
||||
const [receiptDetailsMap, setReceiptDetailsMap] = useState<Record<string, ReceiptDetails>>({});
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
const fileManager = FileManager.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Component mounted');
|
||||
fetchReimbursements();
|
||||
}, []);
|
||||
|
||||
// Add effect to monitor requests state
|
||||
useEffect(() => {
|
||||
console.log('Requests state updated:', requests);
|
||||
console.log('Number of requests:', requests.length);
|
||||
}, [requests]);
|
||||
|
||||
// Add a useEffect to log preview URL and filename changes
|
||||
useEffect(() => {
|
||||
console.log('Preview URL changed:', previewUrl);
|
||||
console.log('Preview filename changed:', previewFilename);
|
||||
}, [previewUrl, previewFilename]);
|
||||
|
||||
// Add a useEffect to log when the preview modal is shown/hidden
|
||||
useEffect(() => {
|
||||
console.log('Show preview changed:', showPreview);
|
||||
if (showPreview) {
|
||||
console.log('Selected receipt:', selectedReceipt);
|
||||
}
|
||||
}, [showPreview, selectedReceipt]);
|
||||
|
||||
const fetchReimbursements = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const pb = auth.getPocketBase();
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Sync reimbursements collection
|
||||
await dataSync.syncCollection(
|
||||
Collections.REIMBURSEMENTS,
|
||||
`submitted_by="${userId}"`,
|
||||
'-created',
|
||||
'audit_notes'
|
||||
);
|
||||
|
||||
// Get reimbursements from IndexedDB
|
||||
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
||||
Collections.REIMBURSEMENTS,
|
||||
false, // Don't force sync again
|
||||
`submitted_by="${userId}"`,
|
||||
'-created'
|
||||
);
|
||||
|
||||
console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
|
||||
|
||||
// Process the records
|
||||
const processedRecords = reimbursementRecords.map(record => {
|
||||
// Process audit notes if they exist
|
||||
let auditNotes = null;
|
||||
if (record.audit_notes) {
|
||||
try {
|
||||
// If it's a string, parse it
|
||||
if (typeof record.audit_notes === 'string') {
|
||||
auditNotes = JSON.parse(record.audit_notes);
|
||||
} else {
|
||||
// Otherwise use it directly
|
||||
auditNotes = record.audit_notes;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing audit notes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
audit_notes: auditNotes
|
||||
};
|
||||
});
|
||||
|
||||
setRequests(processedRecords);
|
||||
|
||||
// Fetch receipt details for each reimbursement
|
||||
for (const record of processedRecords) {
|
||||
if (record.receipts && record.receipts.length > 0) {
|
||||
for (const receiptId of record.receipts) {
|
||||
try {
|
||||
// Get receipt from IndexedDB
|
||||
const receiptRecord = await dataSync.getItem<ReceiptDetails>(
|
||||
Collections.RECEIPTS,
|
||||
receiptId
|
||||
);
|
||||
|
||||
if (receiptRecord) {
|
||||
// Process itemized expenses
|
||||
let itemizedExpenses: ItemizedExpense[] = [];
|
||||
if (receiptRecord.itemized_expenses) {
|
||||
try {
|
||||
if (typeof receiptRecord.itemized_expenses === 'string') {
|
||||
itemizedExpenses = JSON.parse(receiptRecord.itemized_expenses);
|
||||
} else {
|
||||
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing itemized expenses:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Add receipt to state
|
||||
setReceiptDetailsMap(prevMap => ({
|
||||
...prevMap,
|
||||
[receiptId]: {
|
||||
id: receiptRecord.id,
|
||||
file: receiptRecord.file,
|
||||
created_by: receiptRecord.created_by,
|
||||
date: receiptRecord.date,
|
||||
location_name: receiptRecord.location_name,
|
||||
location_address: receiptRecord.location_address,
|
||||
notes: receiptRecord.notes,
|
||||
tax: receiptRecord.tax,
|
||||
created: receiptRecord.created,
|
||||
updated: receiptRecord.updated,
|
||||
itemized_expenses: itemizedExpenses,
|
||||
audited_by: receiptRecord.audited_by || []
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error fetching receipt ${receiptId}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching reimbursements:', err);
|
||||
setError('Failed to load reimbursements. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
|
||||
try {
|
||||
console.log('Previewing file for receipt ID:', receiptId);
|
||||
const pb = auth.getPocketBase();
|
||||
const fileManager = FileManager.getInstance();
|
||||
|
||||
// Set the selected request
|
||||
setSelectedRequest(request);
|
||||
|
||||
// Check if we already have the receipt details in our map
|
||||
if (receiptDetailsMap[receiptId]) {
|
||||
console.log('Using cached receipt details');
|
||||
// Use the cached receipt details
|
||||
setSelectedReceipt(receiptDetailsMap[receiptId]);
|
||||
|
||||
// Check if the receipt has a file
|
||||
if (!receiptDetailsMap[receiptId].file) {
|
||||
console.error('Receipt has no file attached');
|
||||
toast.error('This receipt has no file attached');
|
||||
setPreviewUrl('');
|
||||
setPreviewFilename('');
|
||||
setShowPreview(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the file URL with token for protected files
|
||||
console.log('Getting file URL with token');
|
||||
const url = await fileManager.getFileUrlWithToken(
|
||||
'receipts',
|
||||
receiptId,
|
||||
receiptDetailsMap[receiptId].file,
|
||||
true // Use token for protected files
|
||||
);
|
||||
|
||||
// Check if the URL is empty
|
||||
if (!url) {
|
||||
console.error('Failed to get file URL: Empty URL returned');
|
||||
toast.error('Failed to load receipt: Could not generate file URL');
|
||||
// Still show the preview modal but with empty URL to display the error message
|
||||
setPreviewUrl('');
|
||||
setPreviewFilename(receiptDetailsMap[receiptId].file || '');
|
||||
setShowPreview(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Got URL:', url.substring(0, 50) + '...');
|
||||
|
||||
// Set the preview URL and filename
|
||||
setPreviewUrl(url);
|
||||
setPreviewFilename(receiptDetailsMap[receiptId].file);
|
||||
|
||||
// Show the preview modal
|
||||
setShowPreview(true);
|
||||
|
||||
// Log the current state
|
||||
console.log('Current state after setting:', {
|
||||
previewUrl: url,
|
||||
previewFilename: receiptDetailsMap[receiptId].file,
|
||||
showPreview: true
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If not in the map, get the receipt record using its ID
|
||||
console.log('Fetching receipt details from server');
|
||||
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
|
||||
$autoCancel: false
|
||||
});
|
||||
|
||||
if (receiptRecord) {
|
||||
console.log('Receipt record found:', receiptRecord.id);
|
||||
console.log('Receipt file:', receiptRecord.file);
|
||||
|
||||
// Check if the receipt has a file
|
||||
if (!receiptRecord.file) {
|
||||
console.error('Receipt has no file attached');
|
||||
toast.error('This receipt has no file attached');
|
||||
setPreviewUrl('');
|
||||
setPreviewFilename('');
|
||||
setShowPreview(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the itemized expenses if it's a string
|
||||
const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string'
|
||||
? JSON.parse(receiptRecord.itemized_expenses)
|
||||
: receiptRecord.itemized_expenses;
|
||||
|
||||
const receiptDetails: ReceiptDetails = {
|
||||
id: receiptRecord.id,
|
||||
file: receiptRecord.file,
|
||||
created_by: receiptRecord.created_by,
|
||||
itemized_expenses: itemizedExpenses,
|
||||
tax: receiptRecord.tax,
|
||||
date: receiptRecord.date,
|
||||
location_name: receiptRecord.location_name,
|
||||
location_address: receiptRecord.location_address,
|
||||
notes: receiptRecord.notes || '',
|
||||
audited_by: receiptRecord.audited_by || [],
|
||||
created: receiptRecord.created,
|
||||
updated: receiptRecord.updated
|
||||
};
|
||||
|
||||
// Add to the map for future use
|
||||
setReceiptDetailsMap(prevMap => ({
|
||||
...prevMap,
|
||||
[receiptId]: receiptDetails
|
||||
}));
|
||||
|
||||
setSelectedReceipt(receiptDetails);
|
||||
|
||||
// Get the file URL with token for protected files
|
||||
console.log('Getting file URL with token for new receipt');
|
||||
const url = await fileManager.getFileUrlWithToken(
|
||||
'receipts',
|
||||
receiptRecord.id,
|
||||
receiptRecord.file,
|
||||
true // Use token for protected files
|
||||
);
|
||||
|
||||
// Check if the URL is empty
|
||||
if (!url) {
|
||||
console.error('Failed to get file URL: Empty URL returned');
|
||||
toast.error('Failed to load receipt: Could not generate file URL');
|
||||
// Still show the preview modal but with empty URL to display the error message
|
||||
setPreviewUrl('');
|
||||
setPreviewFilename(receiptRecord.file || '');
|
||||
setShowPreview(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Got URL:', url.substring(0, 50) + '...');
|
||||
|
||||
// Set the preview URL and filename
|
||||
setPreviewUrl(url);
|
||||
setPreviewFilename(receiptRecord.file);
|
||||
|
||||
// Show the preview modal
|
||||
setShowPreview(true);
|
||||
|
||||
// Log the current state
|
||||
console.log('Current state after setting:', {
|
||||
previewUrl: url,
|
||||
previewFilename: receiptRecord.file,
|
||||
showPreview: true
|
||||
});
|
||||
} else {
|
||||
throw new Error('Receipt not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading receipt:', error);
|
||||
toast.error('Failed to load receipt. Please try again.');
|
||||
// Show the preview modal with empty URL to display the error message
|
||||
setPreviewUrl('');
|
||||
setPreviewFilename('');
|
||||
setShowPreview(true);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
console.log('Rendering loading state');
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center min-h-[400px] p-8"
|
||||
>
|
||||
<div className="loading loading-spinner loading-lg text-primary mb-4"></div>
|
||||
<p className="text-base-content/70 animate-pulse">Loading your reimbursements...</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log('Rendering error state:', error);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="alert alert-error shadow-lg max-w-2xl mx-auto"
|
||||
>
|
||||
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
|
||||
<span>{error}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Rendering main component. Requests:', requests);
|
||||
console.log('Requests length:', requests.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
{requests.length === 0 ? (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="text-center py-16 bg-base-200/50 backdrop-blur-sm rounded-2xl border-2 border-dashed border-base-300"
|
||||
>
|
||||
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto text-base-content/30" />
|
||||
<h3 className="mt-6 text-xl font-medium">No reimbursement requests</h3>
|
||||
<p className="text-base-content/70 mt-2">Create a new request to get started</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
layout
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{requests.map((request, index) => {
|
||||
console.log('Rendering request:', request);
|
||||
return (
|
||||
<motion.div
|
||||
key={request.id}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
layout
|
||||
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 border border-base-200 hover:border-primary shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="card-body p-5">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="card-title text-lg font-bold truncate">{request.title}</h3>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="badge badge-outline badge-lg font-mono">
|
||||
${request.total_amount.toFixed(2)}
|
||||
</div>
|
||||
<div className="badge badge-ghost badge-lg gap-1">
|
||||
<Icon icon="heroicons:calendar" className="h-4 w-4" />
|
||||
{formatDate(request.date_of_purchase)}
|
||||
</div>
|
||||
{request.audit_notes && request.audit_notes.filter(note => !note.is_private).length > 0 && (
|
||||
<div className="badge badge-ghost badge-lg gap-1">
|
||||
<Icon icon="heroicons:chat-bubble-left-right" className="h-4 w-4" />
|
||||
{request.audit_notes.filter(note => !note.is_private).length} Notes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn btn-primary btn-sm gap-2 shadow-sm hover:shadow-md transition-all duration-300"
|
||||
onClick={() => setSelectedRequest(request)}
|
||||
>
|
||||
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
||||
View Details
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between w-full relative py-2">
|
||||
<div className="absolute left-0 right-0 top-1/2 h-0.5 bg-base-300 -translate-y-[1.0rem]" />
|
||||
{STATUS_ORDER.map((status, index) => {
|
||||
if (status === 'rejected' && request.status !== 'rejected') return null;
|
||||
if (status === 'approved' && request.status === 'rejected') return null;
|
||||
|
||||
const isActive = STATUS_ORDER.indexOf(request.status) >= STATUS_ORDER.indexOf(status);
|
||||
const isCurrent = request.status === status;
|
||||
|
||||
return (
|
||||
<div key={status} className="relative flex flex-col items-center gap-2 z-10">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${isCurrent
|
||||
? status === 'rejected'
|
||||
? 'bg-error text-error-content ring-2 ring-error/20'
|
||||
: status === 'paid'
|
||||
? 'bg-success text-success-content ring-2 ring-success/20'
|
||||
: status === 'in_progress'
|
||||
? 'bg-warning text-warning-content ring-2 ring-warning/20'
|
||||
: 'bg-primary text-primary-content ring-2 ring-primary/20'
|
||||
: isActive
|
||||
? status === 'rejected'
|
||||
? 'bg-error/20 text-error'
|
||||
: status === 'paid'
|
||||
? 'bg-success/20 text-success'
|
||||
: 'bg-primary/20 text-primary'
|
||||
: 'bg-base-300 text-base-content/40'
|
||||
}`}>
|
||||
<Icon icon={STATUS_ICONS[status]} className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium whitespace-nowrap mt-1 ${isCurrent
|
||||
? status === 'rejected'
|
||||
? 'text-error'
|
||||
: status === 'paid'
|
||||
? 'text-success'
|
||||
: status === 'in_progress'
|
||||
? 'text-warning'
|
||||
: 'text-primary'
|
||||
: isActive
|
||||
? 'text-base-content'
|
||||
: 'text-base-content/40'
|
||||
}`}>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedRequest && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="modal modal-open"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="modal-box max-w-3xl bg-base-100/95 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
{selectedRequest.title}
|
||||
</h3>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="btn btn-ghost btn-sm btn-circle"
|
||||
onClick={() => setSelectedRequest(null)}
|
||||
>
|
||||
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Status</label>
|
||||
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} badge-lg gap-1 mt-1`}>
|
||||
<Icon icon={STATUS_ICONS[selectedRequest.status]} className="h-4 w-4" />
|
||||
{STATUS_LABELS[selectedRequest.status]}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Department</label>
|
||||
<div className="badge badge-outline badge-lg mt-1">
|
||||
{DEPARTMENT_LABELS[selectedRequest.department]}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Total Amount</label>
|
||||
<p className="mt-1 text-xl font-mono font-bold text-primary">
|
||||
${selectedRequest.total_amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Date of Purchase</label>
|
||||
<p className="mt-1 font-medium">{formatDate(selectedRequest.date_of_purchase)}</p>
|
||||
</div>
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm col-span-2">
|
||||
<label className="text-sm font-medium text-base-content/70">Payment Method</label>
|
||||
<p className="mt-1 font-medium">{selectedRequest.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRequest.additional_info && (
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Additional Information</label>
|
||||
<p className="mt-2 whitespace-pre-wrap">{selectedRequest.additional_info}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.audit_notes && selectedRequest.audit_notes.filter(note => !note.is_private).length > 0 && (
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm border-l-4 border-primary">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon icon="heroicons:chat-bubble-left-right" className="h-5 w-5 text-primary" />
|
||||
<label className="text-base font-medium">Public Notes</label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{selectedRequest.audit_notes
|
||||
.filter(note => !note.is_private)
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.map((note, index) => (
|
||||
<div key={index} className="card bg-base-100 p-4 hover:bg-base-200 transition-colors duration-200">
|
||||
<p className="whitespace-pre-wrap text-base">{note.note}</p>
|
||||
<div className="flex justify-between items-center mt-3 text-sm text-base-content/70">
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="heroicons:clock" className="h-4 w-4" />
|
||||
{formatDate(note.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70 mb-2">Receipts</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{(selectedRequest.receipts || []).map((receiptId, index) => (
|
||||
<motion.button
|
||||
key={receiptId || index}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn btn-outline btn-sm normal-case gap-2 hover:shadow-md transition-all duration-300"
|
||||
onClick={() => handlePreviewFile(selectedRequest, receiptId)}
|
||||
>
|
||||
<Icon icon="heroicons:document" className="h-4 w-4" />
|
||||
Receipt #{index + 1}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider before:bg-base-300 after:bg-base-300"></div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Submitted At</label>
|
||||
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
|
||||
</div>
|
||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||
<label className="text-sm font-medium text-base-content/70">Last Updated</label>
|
||||
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* File Preview Modal */}
|
||||
<AnimatePresence>
|
||||
{showPreview && selectedReceipt && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="modal modal-open"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="modal-box max-w-7xl bg-base-100/95 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Receipt Details
|
||||
</h3>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className="btn btn-ghost btn-sm btn-circle"
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
setSelectedReceipt(null);
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-6">
|
||||
{/* Receipt Details */}
|
||||
<div className="col-span-2 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Location</label>
|
||||
<p className="mt-1">{selectedReceipt.location_name}</p>
|
||||
<p className="text-sm text-base-content/70">{selectedReceipt.location_address}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Date</label>
|
||||
<p className="mt-1">{formatDate(selectedReceipt.date)}</p>
|
||||
</div>
|
||||
|
||||
{selectedReceipt.notes && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<p className="mt-1">{selectedReceipt.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Itemized Expenses</label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedReceipt.itemized_expenses.map((item, index) => (
|
||||
<div key={index} className="card bg-base-200 p-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium">Description</label>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Category</label>
|
||||
<p>{item.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Amount</label>
|
||||
<p>${item.amount.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Tax</label>
|
||||
<p className="mt-1">${selectedReceipt.tax.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Total</label>
|
||||
<p className="mt-1">${(selectedReceipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceipt.tax).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Preview */}
|
||||
<div className="col-span-3 border-l border-base-300 pl-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Receipt Image</h3>
|
||||
<motion.a
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-sm btn-outline gap-2 hover:shadow-md transition-all duration-300"
|
||||
>
|
||||
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
|
||||
View Full Size
|
||||
</motion.a>
|
||||
</div>
|
||||
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
|
||||
{previewUrl ? (
|
||||
<FilePreview
|
||||
url={previewUrl}
|
||||
filename={previewFilename}
|
||||
isModal={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center space-y-4">
|
||||
<div className="bg-warning/20 p-4 rounded-full">
|
||||
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Receipt Image Not Available</h3>
|
||||
<p className="text-base-content/70 max-w-md">
|
||||
The receipt image could not be loaded. This might be due to permission issues or the file may not exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load diff
596
src/components/dashboard/universal/FilePreview.tsx
Normal file
596
src/components/dashboard/universal/FilePreview.tsx
Normal file
|
@ -0,0 +1,596 @@
|
|||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { Icon } from "@iconify/react";
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
|
||||
// Image component with fallback handling
|
||||
interface ImageWithFallbackProps {
|
||||
url: string;
|
||||
filename: string;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
|
||||
const [imgSrc, setImgSrc] = useState<string>(url);
|
||||
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
|
||||
|
||||
// Clean up object URL when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isObjectUrl && imgSrc !== url) {
|
||||
URL.revokeObjectURL(imgSrc);
|
||||
}
|
||||
};
|
||||
}, [imgSrc, url, isObjectUrl]);
|
||||
|
||||
const handleError = async () => {
|
||||
console.error('Image failed to load:', url);
|
||||
|
||||
try {
|
||||
// Try to fetch the image as a blob and create an object URL
|
||||
console.log('Trying to fetch image as blob:', url);
|
||||
const response = await fetch(url, { mode: 'cors' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
console.log('Created object URL:', objectUrl);
|
||||
|
||||
// Update the image source with the object URL
|
||||
setImgSrc(objectUrl);
|
||||
setIsObjectUrl(true);
|
||||
} catch (fetchError) {
|
||||
console.error('Error fetching image as blob:', fetchError);
|
||||
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
|
||||
|
||||
// Log additional details
|
||||
console.log('Image URL that failed:', url);
|
||||
console.log('Current auth status:',
|
||||
Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={filename}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Cache for file content
|
||||
const contentCache = new Map<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface FilePreviewProps {
|
||||
url?: string;
|
||||
filename?: string;
|
||||
isModal?: boolean;
|
||||
}
|
||||
|
||||
export default function FilePreview({ url: initialUrl = '', filename: initialFilename = '', isModal = false }: FilePreviewProps) {
|
||||
// Constants
|
||||
const CHUNK_SIZE = 50;
|
||||
const INITIAL_LINES_TO_SHOW = 20;
|
||||
|
||||
// Consolidate state management with useRef for latest values
|
||||
const latestPropsRef = useRef({ url: initialUrl, filename: initialFilename });
|
||||
const loadingRef = useRef(false); // Add a ref to track loading state
|
||||
const [state, setState] = useState({
|
||||
url: initialUrl,
|
||||
filename: initialFilename,
|
||||
content: null as string | 'image' | 'video' | 'pdf' | null,
|
||||
error: null as string | null,
|
||||
loading: false,
|
||||
fileType: null as string | null,
|
||||
isVisible: false,
|
||||
visibleLines: INITIAL_LINES_TO_SHOW
|
||||
});
|
||||
|
||||
// Memoize the truncated filename
|
||||
const truncatedFilename = useMemo(() => {
|
||||
if (!state.filename) return '';
|
||||
const maxLength = 40;
|
||||
if (state.filename.length <= maxLength) return state.filename;
|
||||
const extension = state.filename.split('.').pop();
|
||||
const name = state.filename.substring(0, state.filename.lastIndexOf('.'));
|
||||
const truncatedName = name.substring(0, maxLength - 3 - (extension?.length || 0));
|
||||
return `${truncatedName}...${extension ? `.${extension}` : ''}`;
|
||||
}, [state.filename]);
|
||||
|
||||
// Update ref when props change
|
||||
useEffect(() => {
|
||||
latestPropsRef.current = { url: initialUrl, filename: initialFilename };
|
||||
loadingRef.current = false; // Reset loading ref
|
||||
// Clear state when URL changes
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
url: initialUrl,
|
||||
filename: initialFilename,
|
||||
content: null,
|
||||
error: null,
|
||||
fileType: null,
|
||||
loading: false
|
||||
}));
|
||||
}, [initialUrl, initialFilename]);
|
||||
|
||||
// Single effect for modal event handling
|
||||
useEffect(() => {
|
||||
if (isModal) {
|
||||
const handleStateChange = (event: CustomEvent<{ url: string; filename: string }>) => {
|
||||
const { url: newUrl, filename: newFilename } = event.detail;
|
||||
|
||||
// Force clear cache for PDFs to prevent stale content
|
||||
if (newUrl.endsWith('.pdf')) {
|
||||
contentCache.delete(`${newUrl}_${newFilename}`);
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
url: newUrl,
|
||||
filename: newFilename,
|
||||
content: null,
|
||||
error: null,
|
||||
fileType: null,
|
||||
loading: true
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('filePreviewStateChange', handleStateChange as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('filePreviewStateChange', handleStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [isModal]);
|
||||
|
||||
// Consolidated content loading effect
|
||||
const loadContent = useCallback(async () => {
|
||||
if (!state.url) {
|
||||
setState(prev => ({ ...prev, error: 'No file URL provided', loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate loading
|
||||
if (loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingRef.current = true;
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Special handling for PDFs
|
||||
if (state.url.endsWith('.pdf')) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
content: 'pdf',
|
||||
fileType: 'application/pdf',
|
||||
loading: false
|
||||
}));
|
||||
loadingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Rest of your existing loadContent logic
|
||||
// ... existing content loading code ...
|
||||
} catch (err) {
|
||||
console.error('Error loading content:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Failed to load file',
|
||||
loading: false
|
||||
}));
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}, [state.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.url || (!state.isVisible && isModal)) return;
|
||||
loadContent();
|
||||
}, [state.url, state.isVisible, isModal, loadContent]);
|
||||
|
||||
// Intersection observer effect
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setState(prev => ({ ...prev, isVisible: entry.isIntersecting }));
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const previewElement = document.querySelector('.file-preview-container');
|
||||
if (previewElement) {
|
||||
observer.observe(previewElement);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(state.url);
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = state.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
} catch (err) {
|
||||
console.error('Error downloading file:', err);
|
||||
alert('Failed to download file. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const getLanguageFromFilename = (filename: string): string => {
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return 'javascript';
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'typescript';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'html':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'yml':
|
||||
case 'yaml':
|
||||
return 'yaml';
|
||||
case 'csv':
|
||||
return 'csv';
|
||||
case 'txt':
|
||||
return 'plaintext';
|
||||
default:
|
||||
// If no extension or unrecognized extension, default to plaintext
|
||||
return 'plaintext';
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = useCallback((csvContent: string) => {
|
||||
const lines = csvContent.split('\n').map(line =>
|
||||
line.split(',').map(cell =>
|
||||
cell.trim().replace(/^["'](.*)["']$/, '$1')
|
||||
)
|
||||
);
|
||||
const headers = lines[0];
|
||||
const dataRows = lines.slice(1).filter(row => row.some(cell => cell.length > 0)); // Skip empty rows
|
||||
|
||||
// Remove the truncation message if it exists
|
||||
const lastRow = dataRows[dataRows.length - 1];
|
||||
if (lastRow && lastRow[0] && lastRow[0].includes('Content truncated')) {
|
||||
dataRows.pop();
|
||||
}
|
||||
|
||||
return { headers, rows: dataRows };
|
||||
}, []);
|
||||
|
||||
const renderCSVTable = useCallback((csvContent: string) => {
|
||||
const { headers, rows } = parseCSV(csvContent);
|
||||
const totalRows = rows.length;
|
||||
const rowsToShow = Math.min(state.visibleLines, totalRows);
|
||||
const displayedRows = rows.slice(0, rowsToShow);
|
||||
|
||||
return `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr class="bg-base-200">
|
||||
${headers.map(header => `<th class="px-4 py-2 text-left font-medium">${header}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${displayedRows.map((row, rowIndex) => `
|
||||
<tr class="${rowIndex % 2 === 0 ? 'bg-base-100' : 'bg-base-200/50'}">
|
||||
${row.map(cell => `<td class="px-4 py-2 border-t border-base-300">${cell}</td>`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
${rowsToShow < totalRows ? `
|
||||
<tr>
|
||||
<td colspan="${headers.length}" class="px-4 py-3 text-base-content/70 bg-base-200/30 border-t border-base-300">
|
||||
... ${totalRows - rowsToShow} more rows
|
||||
</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}, [state.visibleLines]);
|
||||
|
||||
const formatCodeWithLineNumbers = useCallback((code: string, language: string) => {
|
||||
try {
|
||||
// Use highlight.js to highlight the code
|
||||
const highlighted = hljs.highlight(code, { language }).value;
|
||||
const lines = highlighted.split('\n');
|
||||
|
||||
return lines.map((line, i) =>
|
||||
`<div class="code-line">
|
||||
<span class="line-number">${i + 1}</span>
|
||||
<span class="line-content">${line || ' '}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
} catch (error) {
|
||||
console.warn(`Failed to highlight code as ${language}, falling back to plaintext`);
|
||||
const plaintext = hljs.highlight(code, { language: 'plaintext' }).value;
|
||||
const lines = plaintext.split('\n');
|
||||
|
||||
return lines.map((line, i) =>
|
||||
`<div class="code-line">
|
||||
<span class="line-number">${i + 1}</span>
|
||||
<span class="line-content">${line || ' '}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const highlightCode = useCallback((code: string, language: string) => {
|
||||
// Skip highlighting for CSV
|
||||
if (language === 'csv') {
|
||||
return code;
|
||||
}
|
||||
|
||||
return code; // Just return the code, formatting is handled in formatCodeWithLineNumbers
|
||||
}, []);
|
||||
|
||||
const handleShowMore = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
visibleLines: Math.min(prev.visibleLines + CHUNK_SIZE, (prev.content as string).split('\n').length)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleShowLess = useCallback(() => {
|
||||
setState(prev => ({ ...prev, visibleLines: INITIAL_LINES_TO_SHOW }));
|
||||
}, []);
|
||||
|
||||
// Update the Try Again button handler
|
||||
const handleTryAgain = useCallback(() => {
|
||||
loadingRef.current = false; // Reset loading ref
|
||||
loadContent();
|
||||
}, [loadContent]);
|
||||
|
||||
// If URL is empty, show a message
|
||||
if (!state.url) {
|
||||
return (
|
||||
<div className="file-preview-container bg-base-100 rounded-lg shadow-md overflow-hidden">
|
||||
<div className="p-6 flex flex-col items-center justify-center text-center">
|
||||
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">No File URL Provided</h3>
|
||||
<p className="text-base-content/70">Please check if the file exists or if you have the necessary permissions.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-preview-container space-y-4">
|
||||
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="truncate font-medium" title={state.filename}>{truncatedFilename}</span>
|
||||
{state.fileType && (
|
||||
<span className="badge badge-sm whitespace-nowrap">{state.fileType.split('/')[1]}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="mdi:download" className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
|
||||
{!state.loading && !state.error && state.content === null && (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.loading && (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.error && (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-base-200 rounded-lg text-center space-y-4">
|
||||
<div className="bg-warning/20 p-4 rounded-full">
|
||||
<Icon icon="mdi:alert" className="h-12 w-12 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Preview Unavailable</h3>
|
||||
<p className="text-base-content/70 max-w-md">{state.error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="btn btn-warning btn-sm gap-2 mt-4"
|
||||
>
|
||||
<Icon icon="mdi:download" className="h-4 w-4" />
|
||||
Download File Instead
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline btn-error mt-4"
|
||||
onClick={handleTryAgain}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.content === 'image' && (
|
||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||
<ImageWithFallback
|
||||
url={state.url}
|
||||
filename={state.filename}
|
||||
onError={(message) => setState(prev => ({ ...prev, error: message }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.content === 'video' && (
|
||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||
<video
|
||||
controls
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
preload="metadata"
|
||||
onError={(e) => {
|
||||
console.error('Video failed to load:', e);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Failed to load video. This might be due to permission issues or the file may not exist.'
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<source src={state.url} type={state.fileType || 'video/mp4'} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.content === 'pdf' && (
|
||||
<div className="w-full h-[600px] bg-base-200 p-4 rounded-b-lg">
|
||||
<div className="w-full h-full rounded-lg overflow-hidden">
|
||||
{/* Use object tag instead of iframe for better PDF support */}
|
||||
<object
|
||||
data={state.url}
|
||||
type="application/pdf"
|
||||
className="w-full h-full rounded-lg"
|
||||
onError={(e) => {
|
||||
console.error('PDF object failed to load:', e);
|
||||
|
||||
// Create a fallback div with a download link
|
||||
const obj = e.target as HTMLObjectElement;
|
||||
const container = obj.parentElement;
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center h-full bg-base-200 p-6 text-center">
|
||||
<div class="bg-warning/20 p-4 rounded-full mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">PDF Preview Unavailable</h3>
|
||||
<p class="text-base-content/70 mb-4">The PDF cannot be displayed in the browser due to security restrictions.</p>
|
||||
<a href="${state.url}" download="${state.filename}" class="btn btn-primary gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download PDF Instead
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full bg-base-200 p-6 text-center">
|
||||
<div className="bg-warning/20 p-4 rounded-full mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">PDF Preview Unavailable</h3>
|
||||
<p className="text-base-content/70 mb-4">Your browser cannot display this PDF or it failed to load.</p>
|
||||
<a href={state.url} download={state.filename} className="btn btn-primary gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download PDF Instead
|
||||
</a>
|
||||
</div>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
|
||||
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
||||
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
||||
{state.filename.toLowerCase().endsWith('.csv') ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: renderCSVTable(state.content) }} />
|
||||
) : (
|
||||
<>
|
||||
<div className="file-preview-code-container text-sm">
|
||||
<style>
|
||||
{`
|
||||
.file-preview-code-container {
|
||||
font-family: monospace;
|
||||
}
|
||||
.file-preview-code-container .code-line {
|
||||
display: flex;
|
||||
white-space: pre;
|
||||
}
|
||||
.file-preview-code-container .line-number {
|
||||
user-select: none;
|
||||
text-align: right;
|
||||
color: rgba(115, 115, 115, 0.6);
|
||||
min-width: 40px;
|
||||
padding-right: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
.file-preview-code-container .line-content {
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatCodeWithLineNumbers(
|
||||
state.content.split('\n').slice(0, state.visibleLines).join('\n'),
|
||||
getLanguageFromFilename(state.filename)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state.content.split('\n').length > state.visibleLines && (
|
||||
<div className="flex justify-center p-2 border-t border-base-300 bg-base-200/50">
|
||||
{state.visibleLines < state.content.split('\n').length && (
|
||||
<button
|
||||
className="btn btn-sm btn-ghost gap-1"
|
||||
onClick={handleShowMore}
|
||||
>
|
||||
<Icon icon="mdi:chevron-down" className="h-4 w-4" />
|
||||
Show {Math.min(CHUNK_SIZE, state.content.split('\n').length - state.visibleLines)} more lines
|
||||
</button>
|
||||
)}
|
||||
{state.visibleLines > INITIAL_LINES_TO_SHOW && (
|
||||
<button
|
||||
className="btn btn-sm btn-ghost gap-1 ml-2"
|
||||
onClick={handleShowLess}
|
||||
>
|
||||
<Icon icon="mdi:chevron-up" className="h-4 w-4" />
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
30
src/components/dashboard/universal/ToastProvider.tsx
Normal file
30
src/components/dashboard/universal/ToastProvider.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Centralized toast provider to ensure consistent rendering
|
||||
export default function ToastProvider() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
},
|
||||
success: {
|
||||
style: {
|
||||
background: 'green',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
style: {
|
||||
background: 'red',
|
||||
},
|
||||
duration: 2000,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -5,11 +5,19 @@ import { LiaDotCircle } from "react-icons/lia";
|
|||
---
|
||||
|
||||
<div class="w-full md:pt-[5vw] pt-[10vw] flex justify-center relative">
|
||||
<div class="w-[45%] rounded-[2vw] aspect-[2/1] relative">
|
||||
<div
|
||||
id="event-skeleton"
|
||||
class="skeleton absolute inset-0 rounded-[2vw] z-0"
|
||||
>
|
||||
</div>
|
||||
<Image
|
||||
id="event-image"
|
||||
src={eventbg}
|
||||
alt="Event Page Background"
|
||||
class="md:w-[45%] w-[80%] rounded-[2vw] aspect-[2/1] object-cover"
|
||||
class="w-full h-full rounded-[2vw] object-cover absolute top-0 left-0 z-1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute -bottom-[6%] md:left-[20%] left-[10%] flex items-center md:text-[3vw] text-[6vw] py-[1.5%] px-[3%] text-white bg-ieee-black rounded-[2vw]"
|
||||
>
|
||||
|
@ -17,3 +25,16 @@ import { LiaDotCircle } from "react-icons/lia";
|
|||
<p>EVENTS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const image = document.getElementById("event-image");
|
||||
const skeleton = document.getElementById("event-skeleton");
|
||||
|
||||
if (image && skeleton) {
|
||||
image.onload = () => {
|
||||
skeleton.style.display = "none";
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -6,7 +6,9 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
|||
import { Image } from "astro:assets";
|
||||
---
|
||||
|
||||
<div class="md:flex md:gap-[1.5vw] md:h-[30vw] md:w-auto w-[70vw] mt-[20%] md:mt-0 ml-[5%] md:ml-0">
|
||||
<div
|
||||
class="md:flex md:gap-[1.5vw] md:h-[30vw] md:w-auto w-[70vw] mt-[20%] md:mt-0 ml-[5%] md:ml-0"
|
||||
>
|
||||
{
|
||||
Object.entries(annualProjects).map(([title, project], index) => (
|
||||
<a
|
||||
|
@ -15,6 +17,7 @@ import { Image } from "astro:assets";
|
|||
data-project={index + 1}
|
||||
target={title === "Supercomputing" ? "_blank" : "_self"}
|
||||
>
|
||||
<div class="skeleton absolute inset-0 rounded-[1.5vw] z-0" />
|
||||
<Image
|
||||
src={project.image}
|
||||
alt={`${title} Project`}
|
||||
|
@ -41,7 +44,10 @@ import { Image } from "astro:assets";
|
|||
</div>
|
||||
|
||||
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]">
|
||||
<FaGear data-inview className="in-view:rotate-[500deg] duration-[3000ms] group-hover:rotate-[750deg]" />
|
||||
<FaGear
|
||||
data-inview
|
||||
className="in-view:rotate-[500deg] duration-[3000ms] group-hover:rotate-[750deg]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
|
@ -120,4 +126,33 @@ import { Image } from "astro:assets";
|
|||
initializeProjectCards();
|
||||
|
||||
document.addEventListener("astro:page-load", initializeProjectCards);
|
||||
|
||||
// Image loading handler
|
||||
function handleImageLoading() {
|
||||
const projectImages = document.querySelectorAll(".project-card img");
|
||||
|
||||
projectImages.forEach((image, index) => {
|
||||
// Ensure the image is fully loaded, even if it's already in cache
|
||||
if (image.complete) {
|
||||
image.style.opacity = "1";
|
||||
const skeleton = image.previousElementSibling;
|
||||
if (skeleton && skeleton.classList.contains("skeleton")) {
|
||||
skeleton.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
image.addEventListener("load", () => {
|
||||
image.style.opacity = "1";
|
||||
const skeleton = image.previousElementSibling;
|
||||
if (skeleton && skeleton.classList.contains("skeleton")) {
|
||||
skeleton.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure images are loaded after a short delay
|
||||
setTimeout(handleImageLoading, 100);
|
||||
handleImageLoading();
|
||||
document.addEventListener("astro:page-load", handleImageLoading);
|
||||
</script>
|
||||
|
|
|
@ -11,12 +11,19 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
|||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||
<p>Quarterly Project</p>
|
||||
</div>
|
||||
<div class="w-[70vw] aspect-[2.5/1] relative">
|
||||
<div
|
||||
id="qp-skeleton"
|
||||
class="skeleton absolute inset-0 rounded-full z-0"
|
||||
>
|
||||
<Image
|
||||
src={qp}
|
||||
alt="qp showcase photo"
|
||||
class="md:opacity-0 md:w-[70vw] w-[85vw] md:aspect-[2.5/1] aspect-[2.5/1.2] rounded-full relative in-view:animate-fade-down"
|
||||
data-inview
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
data-inview
|
||||
href="/projects/quarterly"
|
||||
|
@ -28,7 +35,8 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
|||
/>
|
||||
</Link>
|
||||
<div
|
||||
data-inview class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
|
||||
data-inview
|
||||
class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
|
||||
>
|
||||
<p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p>
|
||||
<p>
|
||||
|
@ -39,3 +47,16 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const image = document.getElementById("qp-image");
|
||||
const skeleton = document.getElementById("qp-skeleton");
|
||||
|
||||
if (image) {
|
||||
image.onload = () => {
|
||||
skeleton.style.display = "none";
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
120
src/config/dashboard.yaml
Normal file
120
src/config/dashboard.yaml
Normal file
|
@ -0,0 +1,120 @@
|
|||
sections:
|
||||
# Base Menu (accessible to all except sponsors)
|
||||
profile:
|
||||
title: "Dashboard"
|
||||
icon: "heroicons:home"
|
||||
role: "none"
|
||||
component: "ProfileSection"
|
||||
class: "text-primary hover:text-primary-focus"
|
||||
|
||||
events:
|
||||
title: "Events"
|
||||
icon: "heroicons:calendar"
|
||||
role: "none"
|
||||
component: "EventsSection"
|
||||
class: "text-secondary hover:text-secondary-focus"
|
||||
|
||||
reimbursement:
|
||||
title: "Reimbursement"
|
||||
icon: "heroicons:credit-card"
|
||||
role: "none"
|
||||
component: "ReimbursementSection"
|
||||
class: "text-accent hover:text-accent-focus"
|
||||
|
||||
# Officer Menu
|
||||
eventManagement:
|
||||
title: "Event Management"
|
||||
icon: "heroicons:cog-6-tooth"
|
||||
role: "general"
|
||||
component: "Officer_EventManagement"
|
||||
class: "text-info hover:text-info-focus"
|
||||
|
||||
reimbursementManagement:
|
||||
title: "Reimbursement Management"
|
||||
icon: "heroicons:credit-card"
|
||||
role: "executive"
|
||||
component: "Officer_ReimbursementManagement"
|
||||
class: "text-info hover:text-info-focus"
|
||||
|
||||
eventRequestManagement:
|
||||
title: "Event Request Management"
|
||||
icon: "heroicons:document-text"
|
||||
role: "executive"
|
||||
component: "Officer_EventRequestManagement"
|
||||
class: "text-info hover:text-info-focus"
|
||||
|
||||
eventRequestForm:
|
||||
title: "Event Request Form"
|
||||
icon: "heroicons:document-text"
|
||||
role: "general"
|
||||
component: "Officer_EventRequestForm"
|
||||
class: "text-info hover:text-info-focus"
|
||||
|
||||
# Sponsor Menu
|
||||
sponsorDashboard:
|
||||
title: "Sponsor Dashboard"
|
||||
icon: "heroicons:briefcase"
|
||||
role: "sponsor"
|
||||
component: "SponsorDashboard"
|
||||
class: "text-warning hover:text-warning-focus"
|
||||
|
||||
sponsorAnalytics:
|
||||
title: "Analytics"
|
||||
icon: "heroicons:chart-bar"
|
||||
role: "sponsor"
|
||||
component: "SponsorAnalytics"
|
||||
class: "text-warning hover:text-warning-focus"
|
||||
|
||||
# Administrator Menu
|
||||
adminDashboard:
|
||||
title: "Admin Dashboard"
|
||||
icon: "heroicons:shield-check"
|
||||
role: "administrator"
|
||||
component: "AdminDashboard"
|
||||
class: "text-error hover:text-error-focus"
|
||||
|
||||
# Settings (accessible to all except sponsors)
|
||||
settings:
|
||||
title: "Settings"
|
||||
icon: "heroicons:cog-6-tooth"
|
||||
role: "none"
|
||||
component: "SettingsSection"
|
||||
class: "text-neutral hover:text-neutral-focus"
|
||||
|
||||
logout:
|
||||
title: "Logout"
|
||||
icon: "heroicons:arrow-left-on-rectangle"
|
||||
role: "none"
|
||||
class: "text-error hover:text-error-focus"
|
||||
|
||||
# Menu Categories
|
||||
categories:
|
||||
main:
|
||||
title: "Main Menu"
|
||||
sections: ["profile", "events", "reimbursement"]
|
||||
role: "none"
|
||||
|
||||
officer:
|
||||
title: "Officer Menu"
|
||||
sections: ["eventManagement", "eventRequestForm"]
|
||||
role: "general"
|
||||
|
||||
executive:
|
||||
title: "Executive Menu"
|
||||
sections: ["reimbursementManagement", "eventRequestManagement"]
|
||||
role: "executive"
|
||||
|
||||
admin:
|
||||
title: "Admin Menu"
|
||||
sections: ["adminDashboard"]
|
||||
role: "administrator"
|
||||
|
||||
sponsor:
|
||||
title: "Sponsor Portal"
|
||||
sections: ["sponsorDashboard", "sponsorAnalytics"]
|
||||
role: "sponsor"
|
||||
|
||||
account:
|
||||
title: "Account"
|
||||
sections: ["settings", "logout"]
|
||||
role: "none"
|
5
src/config/pocketbaseConfig.yml
Normal file
5
src/config/pocketbaseConfig.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
api:
|
||||
baseUrl: https://pocketbase.ieeeucsd.org
|
||||
oauth2:
|
||||
redirectPath: /oauth2-redirect
|
||||
providerName: oidc
|
14
src/config/reimbursement.yaml
Normal file
14
src/config/reimbursement.yaml
Normal file
|
@ -0,0 +1,14 @@
|
|||
status:
|
||||
- "submitted"
|
||||
- "under_review"
|
||||
- "approved"
|
||||
- "rejected"
|
||||
- "paid"
|
||||
- "in_progress"
|
||||
|
||||
department:
|
||||
- "internal"
|
||||
- "external"
|
||||
- "projects"
|
||||
- "events"
|
||||
- "other"
|
165
src/data/allUCSDMajors.txt
Normal file
165
src/data/allUCSDMajors.txt
Normal file
|
@ -0,0 +1,165 @@
|
|||
Astronomy & Astrophysics
|
||||
Anthropology
|
||||
Bioengineering
|
||||
Biology with Specialization in Bioinformatics
|
||||
Ecology, Behavior and Evolution
|
||||
General Biology
|
||||
Human Biology
|
||||
Microbiology
|
||||
Molecular and Cell Biology
|
||||
Neurobiology / Physiology and Neuroscience
|
||||
Biochemistry and Cell Biology
|
||||
Biochemistry
|
||||
Chemistry
|
||||
Environmental Chemistry
|
||||
Molecular Synthesis
|
||||
Pharmacological Chemistry
|
||||
Chinese Studies
|
||||
Cinematic Arts
|
||||
Classical Studies
|
||||
Cognitive Science
|
||||
Cognitive Science – Clinical Aspects of Cognition
|
||||
Cognitive Science – Design and Interaction
|
||||
Cognitive Science – Language and Culture
|
||||
Cognitive Science – Machine Learning and Neural Computation
|
||||
Cognitive Science – Neuroscience
|
||||
Cognitive and Behavioral Neuroscience
|
||||
Communication
|
||||
Media Industries and Communication
|
||||
Computer Engineering
|
||||
Computer Science
|
||||
Computer Science – Bioinformatics
|
||||
Critical Gender Studies
|
||||
Dance
|
||||
Data Science
|
||||
Business Economics
|
||||
Economics
|
||||
Economics and Mathematics – Joint Major
|
||||
Economics-Public Policy
|
||||
Education Sciences
|
||||
Electrical Engineering
|
||||
Electrical Engineering and Society
|
||||
Engineering Physics
|
||||
Environmental Systems (Earth Sciences)
|
||||
Environmental Systems (Ecology, Behavior & Evolution)
|
||||
Environmental Systems (Environmental Chemistry)
|
||||
Environmental Systems (Environmental Policy)
|
||||
Ethnic Studies
|
||||
German Studies
|
||||
Global Health
|
||||
Global South Studies
|
||||
Public Health
|
||||
Public Health – Biostatistics
|
||||
Public Health – Climate and Environmental Sciences
|
||||
Public Health – Community Health Sciences
|
||||
Public Health – Epidemiology
|
||||
Public Health – Health Policy and Management Sciences
|
||||
Public Health – Medicine Sciences
|
||||
History
|
||||
Human Developmental Sciences
|
||||
Human Developmental Sciences – Equity and Diversity
|
||||
Human Developmental Sciences – Healthy Aging
|
||||
International Studies – Anthropology
|
||||
International Studies – Economics
|
||||
International Studies – Economics (Joint BA/MIA)
|
||||
International Studies – History
|
||||
International Studies – International Business
|
||||
International Studies – International Business (Joint BA/MIA)
|
||||
International Studies – Linguistics
|
||||
International Studies – Literature
|
||||
International Studies – Philosophy
|
||||
International Studies – Political Science
|
||||
International Studies – Political Science (Joint BA/MIA)
|
||||
International Studies – Sociology
|
||||
Italian Studies
|
||||
Japanese Studies
|
||||
Jewish Studies
|
||||
Latin American Studies
|
||||
Latin American Studies – Mexico
|
||||
Latin American Studies – Migration and Border Studies
|
||||
Linguistics
|
||||
Linguistics – Cognition and Language
|
||||
Linguistics – Language and Society
|
||||
Linguistics: Language Studies
|
||||
Linguistics – Speech and Language Sciences
|
||||
Literary Arts
|
||||
Literatures in English
|
||||
Spanish Literature
|
||||
World Literature and Culture
|
||||
Mathematical Biology
|
||||
Mathematics (Applied)
|
||||
Mathematics
|
||||
Mathematics – Applied Science
|
||||
Mathematics – Computer Science
|
||||
Mathematics – Secondary Education
|
||||
Probability and Statistics
|
||||
Aerospace Engineering
|
||||
Aerospace Engineering – Aerothermodynamics
|
||||
Aerospace Engineering – Astrodynamics and Space Applications
|
||||
Aerospace Engineering – Flight Dynamics and Controls
|
||||
Mechanical Engineering
|
||||
Mechanical Engineering – Controls and Robotics
|
||||
Mechanical Engineering – Fluid Mechanics and Thermal Systems
|
||||
Mechanical Engineering – Materials Science and Engineering
|
||||
Mechanical Engineering – Mechanics of Materials
|
||||
Mechanical Engineering – Renewable Energy and Environmental Flows
|
||||
Music
|
||||
Music Humanities
|
||||
Interdisciplinary Computing and the Arts
|
||||
Chemical Engineering
|
||||
NanoEngineering
|
||||
Philosophy
|
||||
General Physics
|
||||
General Physics/Secondary Education
|
||||
Physics
|
||||
Physics – Astrophysics
|
||||
Physics – Biophysics
|
||||
Physics – Computational Physics
|
||||
Physics – Earth Sciences
|
||||
Physics – Materials Physics
|
||||
Political Science
|
||||
Political Science – American Politics
|
||||
Political Science – Comparative Politics
|
||||
Political Science – Data Analytics
|
||||
Political Science – International Affairs
|
||||
Political Science – International Relations
|
||||
Political Science – Political Theory
|
||||
Political Science – Public Law
|
||||
Political Science – Public Policy
|
||||
Political Science – Race, Ethnicity, and Politics
|
||||
Psychology
|
||||
Psychology – Clinical Psychology
|
||||
Psychology – Cognitive Psychology
|
||||
Psychology – Developmental Psychology
|
||||
Psychology – Human Health
|
||||
Psychology – Sensation and Perception
|
||||
Psychology – Social Psychology
|
||||
Business Psychology
|
||||
Study of Religion
|
||||
Russian, East European & Eurasian Studies
|
||||
Geosciences
|
||||
Marine Biology
|
||||
Oceanic and Atmospheric Sciences
|
||||
Sociology
|
||||
Sociology – International Studies
|
||||
Sociology – American Studies
|
||||
Sociology – Science and Medicine
|
||||
Sociology – Economy and Society
|
||||
Sociology – Culture and Communication
|
||||
Sociology – Social Inequality
|
||||
Sociology – Law and Society
|
||||
Structural Engineering
|
||||
Structural Engineering – Aerospace Structures
|
||||
Structural Engineering – Civil Structures
|
||||
Structural Engineering – Geotechnical Engineering
|
||||
Structural Engineering – Structural Health Monitoring/Non-Destructive Evaluation
|
||||
Theatre
|
||||
Undeclared – Humanities/Arts
|
||||
Undeclared – Physical Sciences
|
||||
Undeclared – Social Sciences
|
||||
Urban Studies and Planning
|
||||
Real Estate and Development
|
||||
Art History/Criticism
|
||||
Media
|
||||
Speculative Design
|
||||
Studio
|
|
@ -97,13 +97,6 @@
|
|||
"email": "Erduarte@ucsd.edu",
|
||||
"type": ["Executives", "Events"]
|
||||
},
|
||||
{
|
||||
"name": "Sin Yin Yang",
|
||||
"position": "Events Coordinator",
|
||||
"picture": "https://placehold.co/800x800?text=SIN%20YIN%20YANG",
|
||||
"email": "siy015@ucsd.edu",
|
||||
"type": ["Executives", "Events"]
|
||||
},
|
||||
{
|
||||
"name": "Rafaella Gomes",
|
||||
"position": "Project Space Chair",
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
"path": "/find"
|
||||
},
|
||||
{
|
||||
"name": "Online Store",
|
||||
"path": "/online-store"
|
||||
"name": "Dashboard",
|
||||
"path": "/dashboard"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -5,13 +5,27 @@ import InView from "../components/core/InView.astro";
|
|||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" class="w-full h-full m-0 bg-ieee-black">
|
||||
<html lang="en" data-theme="dark" class="w-full h-full m-0 bg-ieee-black">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro Basics</title>
|
||||
<title>IEEEUCSD</title>
|
||||
<script
|
||||
src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"
|
||||
></script>
|
||||
<script is:inline>
|
||||
// Set default theme to dark if not already set
|
||||
if (!localStorage.getItem("theme")) {
|
||||
localStorage.setItem("theme", "dark");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
// Apply saved theme
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<InView />
|
||||
<body class="w-full h-full m-0 bg-ieee-black">
|
||||
|
|
5
src/lib/pocketbase.ts
Normal file
5
src/lib/pocketbase.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase(import.meta.env.PUBLIC_POCKETBASE_URL);
|
||||
|
||||
export default pb;
|
889
src/pages/dashboard.astro
Normal file
889
src/pages/dashboard.astro
Normal file
|
@ -0,0 +1,889 @@
|
|||
---
|
||||
import yaml from "js-yaml";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Authentication } from "../scripts/pocketbase/Authentication";
|
||||
import { Get } from "../scripts/pocketbase/Get";
|
||||
import { SendLog } from "../scripts/pocketbase/SendLog";
|
||||
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
||||
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||
import ToastProvider from "../components/dashboard/universal/ToastProvider";
|
||||
|
||||
const title = "Dashboard";
|
||||
|
||||
// Load and parse dashboard config
|
||||
const configPath = path.join(process.cwd(), "src", "config", "dashboard.yaml");
|
||||
const dashboardConfig = yaml.load(fs.readFileSync(configPath, "utf8")) as any;
|
||||
|
||||
// Dynamically import all dashboard components
|
||||
const components = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.values(dashboardConfig.sections)
|
||||
.filter((section: any) => section.component) // Only process sections with components
|
||||
.map(async (section: any) => {
|
||||
const component = await import(
|
||||
`../components/dashboard/${section.component}.astro`
|
||||
);
|
||||
console.log(`Loaded component: ${section.component}`); // Debug log
|
||||
return [section.component, component.default];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
console.log("Available components:", Object.keys(components)); // Debug log
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title} | IEEE UCSD</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script
|
||||
src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js"
|
||||
></script>
|
||||
</head>
|
||||
<body class="bg-base-200">
|
||||
<div class="flex h-screen">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="bg-base-100 w-80 flex flex-col shadow-xl border-r border-base-200 transition-all duration-300 fixed xl:relative h-full z-50 -translate-x-full xl:translate-x-0"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="p-6 border-b border-base-200">
|
||||
<div class="flex items-center justify-center">
|
||||
<span
|
||||
class="text-4xl font-bold text-[#06659d] select-none tracking-wide"
|
||||
>IEEEUCSD</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="p-6 border-b border-base-200">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
id="userProfileSkeleton"
|
||||
class="flex items-center gap-4"
|
||||
>
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-base-300 animate-pulse"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="h-5 w-20 bg-base-300 animate-pulse rounded"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signed Out State -->
|
||||
<div
|
||||
id="userProfileSignedOut"
|
||||
class="flex items-center gap-4 hidden"
|
||||
>
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-base-300 text-base-content/30 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="heroicons:user" class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-medium text-lg text-base-content/70"
|
||||
>
|
||||
Signed Out
|
||||
</h3>
|
||||
<div class="badge badge-outline mt-1 opacity-50">
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actual Profile -->
|
||||
<div
|
||||
id="userProfileSummary"
|
||||
class="flex items-center gap-4 hidden"
|
||||
>
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-[#06659d] text-white ring ring-base-200 ring-offset-base-100 ring-offset-2 inline-flex items-center justify-center"
|
||||
>
|
||||
<span
|
||||
class="text-xl font-semibold select-none inline-flex items-center justify-center w-full h-full"
|
||||
id="userInitials">?</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-lg" id="userName">
|
||||
Loading...
|
||||
</h3>
|
||||
<div
|
||||
class="badge badge-outline mt-1 border-[#06659d] text-[#06659d]"
|
||||
id="userRole"
|
||||
>
|
||||
Member
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto scrollbar-hide py-6">
|
||||
<ul class="menu gap-2 px-4 text-base-content/80">
|
||||
<!-- Loading Skeleton -->
|
||||
<div id="menuLoadingSkeleton">
|
||||
{
|
||||
[1, 2, 3].map((group) => (
|
||||
<>
|
||||
<li class="menu-title font-medium opacity-70">
|
||||
<div class="h-4 w-24 bg-base-300 animate-pulse rounded" />
|
||||
</li>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<li>
|
||||
<div class="flex items-center gap-4 py-2">
|
||||
<div class="h-5 w-5 bg-base-300 animate-pulse rounded" />
|
||||
<div class="h-4 w-32 bg-base-300 animate-pulse rounded" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actual Menu -->
|
||||
<div id="actualMenu" class="hidden">
|
||||
{
|
||||
Object.entries(dashboardConfig.categories).map(
|
||||
([categoryKey, category]: [
|
||||
string,
|
||||
any,
|
||||
]) => (
|
||||
<>
|
||||
<li
|
||||
class={`menu-title font-medium opacity-70 ${
|
||||
category.role &&
|
||||
category.role !== "none"
|
||||
? "hidden"
|
||||
: ""
|
||||
}`}
|
||||
data-role-required={
|
||||
category.role || "none"
|
||||
}
|
||||
>
|
||||
<span>{category.title}</span>
|
||||
</li>
|
||||
{category.sections.map(
|
||||
(sectionKey: string) => {
|
||||
const section =
|
||||
dashboardConfig
|
||||
.sections[
|
||||
sectionKey
|
||||
];
|
||||
return (
|
||||
<li
|
||||
class={
|
||||
section.role &&
|
||||
section.role !==
|
||||
"none"
|
||||
? "hidden"
|
||||
: ""
|
||||
}
|
||||
data-role-required={
|
||||
section.role
|
||||
}
|
||||
>
|
||||
<button
|
||||
class={`dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 ${section.class || ""}`}
|
||||
data-section={
|
||||
sectionKey
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
section.icon
|
||||
}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{section.title}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main
|
||||
class="flex-1 overflow-x-hidden overflow-y-auto bg-base-200 w-full xl:w-[calc(100%-20rem)]"
|
||||
>
|
||||
<!-- Mobile Header -->
|
||||
<header
|
||||
class="bg-base-100 p-4 shadow-md xl:hidden sticky top-0 z-40"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
id="mobileSidebarToggle"
|
||||
class="btn btn-square btn-ghost"
|
||||
>
|
||||
<Icon name="heroicons:bars-3" class="h-6 w-6" />
|
||||
</button>
|
||||
<h1 class="text-xl font-bold">IEEE UCSD</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="p-4 md:p-6 max-w-[1600px] mx-auto">
|
||||
<!-- Loading State -->
|
||||
<div id="pageLoadingState" class="w-full">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-4 sm:p-8"
|
||||
>
|
||||
<div class="loading loading-spinner loading-lg">
|
||||
</div>
|
||||
<p class="mt-4 opacity-70">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="pageErrorState" class="hidden w-full">
|
||||
<div class="alert alert-error mx-2 sm:mx-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Failed to load dashboard content</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Authenticated State -->
|
||||
<div id="notAuthenticatedState" class="hidden w-full">
|
||||
<div class="card bg-base-100 shadow-xl mx-2 sm:mx-0">
|
||||
<div
|
||||
class="card-body items-center text-center p-4 sm:p-8"
|
||||
>
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 sm:h-16 sm:w-16 opacity-30"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="card-title text-xl sm:text-2xl mb-2">
|
||||
Sign in to Access Dashboard
|
||||
</h2>
|
||||
<p
|
||||
class="opacity-70 mb-4 sm:mb-6 text-sm sm:text-base"
|
||||
>
|
||||
Please sign in with your IEEE UCSD account
|
||||
to access the dashboard.
|
||||
</p>
|
||||
<button
|
||||
class="login-button btn btn-primary btn-lg gap-2 w-full sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1zm7.707 3.293a1 1 0 010 1.414L9.414 9H17a1 1 0 110 2H9.414l1.293 1.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="whitespace-nowrap"
|
||||
>Sign in with IEEEUCSD SSO</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="mainContent" class="space-y-4 sm:space-y-6">
|
||||
{
|
||||
Object.entries(dashboardConfig.sections).map(
|
||||
([sectionKey, section]: [string, any]) => {
|
||||
// Skip if no component is defined
|
||||
if (!section.component) return null;
|
||||
|
||||
const Component =
|
||||
components[section.component];
|
||||
return (
|
||||
<div
|
||||
id={`${sectionKey}Section`}
|
||||
class={`dashboard-section hidden ${
|
||||
section.role &&
|
||||
section.role !== "none"
|
||||
? "role-restricted"
|
||||
: ""
|
||||
}`}
|
||||
data-role-required={section.role}
|
||||
>
|
||||
<Component />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Centralized Toast Provider -->
|
||||
<ToastProvider client:load />
|
||||
|
||||
<script>
|
||||
import { Authentication } from "../scripts/pocketbase/Authentication";
|
||||
import { Get } from "../scripts/pocketbase/Get";
|
||||
import { SendLog } from "../scripts/pocketbase/SendLog";
|
||||
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
||||
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
const get = Get.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
|
||||
// Initialize page state
|
||||
const pageLoadingState =
|
||||
document.getElementById("pageLoadingState");
|
||||
const pageErrorState = document.getElementById("pageErrorState");
|
||||
const notAuthenticatedState = document.getElementById(
|
||||
"notAuthenticatedState"
|
||||
);
|
||||
const mainContent = document.getElementById("mainContent");
|
||||
const sidebar = document.querySelector("aside");
|
||||
|
||||
// User profile elements
|
||||
const userInitials = document.getElementById("userInitials");
|
||||
const userName = document.getElementById("userName");
|
||||
const userRole = document.getElementById("userRole");
|
||||
|
||||
// Function to update section visibility based on role
|
||||
const updateSectionVisibility = (officerStatus: OfficerStatus) => {
|
||||
// Special handling for sponsor role
|
||||
if (officerStatus === "sponsor") {
|
||||
// Hide all sections first
|
||||
document
|
||||
.querySelectorAll("[data-role-required]")
|
||||
.forEach((element) => {
|
||||
element.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Only show sponsor sections
|
||||
document
|
||||
.querySelectorAll('[data-role-required="sponsor"]')
|
||||
.forEach((element) => {
|
||||
element.classList.remove("hidden");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-sponsor roles, handle normally
|
||||
document
|
||||
.querySelectorAll("[data-role-required]")
|
||||
.forEach((element) => {
|
||||
const requiredRole = element.getAttribute(
|
||||
"data-role-required"
|
||||
) as OfficerStatus;
|
||||
|
||||
// Skip elements that don't have a role requirement
|
||||
if (!requiredRole || requiredRole === "none") {
|
||||
element.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has permission for this role
|
||||
const hasPermission = hasAccess(
|
||||
officerStatus,
|
||||
requiredRole
|
||||
);
|
||||
|
||||
// Only show elements if user has permission
|
||||
element.classList.toggle("hidden", !hasPermission);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle navigation
|
||||
const handleNavigation = () => {
|
||||
const navButtons =
|
||||
document.querySelectorAll(".dashboard-nav-btn");
|
||||
const sections =
|
||||
document.querySelectorAll(".dashboard-section");
|
||||
const mainContentDiv = document.getElementById("mainContent");
|
||||
|
||||
// Ensure mainContent is visible
|
||||
if (mainContentDiv) {
|
||||
mainContentDiv.classList.remove("hidden");
|
||||
}
|
||||
|
||||
navButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const sectionKey = button.getAttribute("data-section");
|
||||
|
||||
// Handle logout button
|
||||
if (sectionKey === "logout") {
|
||||
auth.logout();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove active class from all buttons
|
||||
navButtons.forEach((btn) => {
|
||||
btn.classList.remove("active", "bg-base-200");
|
||||
});
|
||||
|
||||
// Add active class to clicked button
|
||||
button.classList.add("active", "bg-base-200");
|
||||
|
||||
// Hide all sections
|
||||
sections.forEach((section) => {
|
||||
section.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Show selected section
|
||||
const sectionId = `${sectionKey}Section`;
|
||||
const targetSection =
|
||||
document.getElementById(sectionId);
|
||||
if (targetSection) {
|
||||
targetSection.classList.remove("hidden");
|
||||
console.log(`Showing section: ${sectionId}`); // Debug log
|
||||
}
|
||||
|
||||
// Close mobile sidebar if needed
|
||||
if (window.innerWidth < 1024 && sidebar) {
|
||||
sidebar.classList.add("-translate-x-full");
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
const overlay =
|
||||
document.getElementById("sidebarOverlay");
|
||||
overlay?.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Display user profile information and handle role-based access
|
||||
const updateUserProfile = async (user: { id: string }) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Use fields from the User interface in the schema
|
||||
const extendedUser = await get.getOne("users", user.id, {
|
||||
fields: [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"verified",
|
||||
"avatar",
|
||||
"pid",
|
||||
"member_id",
|
||||
"graduation_year",
|
||||
"major",
|
||||
],
|
||||
});
|
||||
|
||||
const displayName = extendedUser.name || "Unknown User";
|
||||
// Default role is Member
|
||||
let displayRole = "Member";
|
||||
|
||||
// Map the officer type from the database to our OfficerStatus type
|
||||
let officerStatus: OfficerStatus = "";
|
||||
|
||||
// Get the officer record for this user if it exists
|
||||
// Use fields from the Officer interface in the schema
|
||||
const officerRecords = await get.getList(
|
||||
"officers",
|
||||
1,
|
||||
50,
|
||||
`user="${user.id}"`,
|
||||
"",
|
||||
{
|
||||
fields: ["id", "type", "role"],
|
||||
}
|
||||
);
|
||||
|
||||
if (officerRecords && officerRecords.items.length > 0) {
|
||||
const officerType = officerRecords.items[0].type;
|
||||
const officerRole = officerRecords.items[0].role;
|
||||
|
||||
// Use the role field from the officer's collection
|
||||
if (officerRole) {
|
||||
displayRole = officerRole;
|
||||
}
|
||||
|
||||
// Map the officer type to our OfficerStatus
|
||||
switch (officerType) {
|
||||
case OfficerTypes.ADMINISTRATOR:
|
||||
officerStatus = "administrator";
|
||||
break;
|
||||
case OfficerTypes.EXECUTIVE:
|
||||
officerStatus = "executive";
|
||||
break;
|
||||
case OfficerTypes.GENERAL:
|
||||
officerStatus = "general";
|
||||
break;
|
||||
case OfficerTypes.HONORARY:
|
||||
officerStatus = "honorary";
|
||||
break;
|
||||
case OfficerTypes.PAST:
|
||||
officerStatus = "past";
|
||||
break;
|
||||
default:
|
||||
officerStatus = "none";
|
||||
}
|
||||
} else {
|
||||
// Check if user is a sponsor by querying the sponsors collection
|
||||
const sponsorRecords = await get.getList(
|
||||
"sponsors",
|
||||
1,
|
||||
1,
|
||||
`user="${user.id}"`,
|
||||
"",
|
||||
{
|
||||
fields: ["id", "company"],
|
||||
}
|
||||
);
|
||||
|
||||
if (sponsorRecords && sponsorRecords.items.length > 0) {
|
||||
officerStatus = "sponsor";
|
||||
displayRole = "Sponsor";
|
||||
} else {
|
||||
officerStatus = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const initials = (extendedUser.name || "U")
|
||||
.split(" ")
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
// Update profile display
|
||||
if (userName) userName.textContent = displayName;
|
||||
if (userRole) userRole.textContent = displayRole;
|
||||
if (userInitials) userInitials.textContent = initials;
|
||||
|
||||
// Update section visibility based on role
|
||||
updateSectionVisibility(officerStatus);
|
||||
} catch (error) {
|
||||
console.error("Error fetching user profile:", error);
|
||||
const fallbackValues = {
|
||||
name: "Unknown User",
|
||||
role: "Member",
|
||||
initials: "?",
|
||||
};
|
||||
|
||||
if (userName) userName.textContent = fallbackValues.name;
|
||||
if (userRole) userRole.textContent = fallbackValues.role;
|
||||
if (userInitials)
|
||||
userInitials.textContent = fallbackValues.initials;
|
||||
|
||||
updateSectionVisibility("" as OfficerStatus);
|
||||
}
|
||||
};
|
||||
|
||||
// Mobile sidebar toggle
|
||||
const mobileSidebarToggle = document.getElementById(
|
||||
"mobileSidebarToggle"
|
||||
);
|
||||
if (mobileSidebarToggle && sidebar) {
|
||||
const toggleSidebar = () => {
|
||||
const isOpen =
|
||||
!sidebar.classList.contains("-translate-x-full");
|
||||
|
||||
if (isOpen) {
|
||||
sidebar.classList.add("-translate-x-full");
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
const overlay =
|
||||
document.getElementById("sidebarOverlay");
|
||||
overlay?.remove();
|
||||
} else {
|
||||
sidebar.classList.remove("-translate-x-full");
|
||||
document.body.classList.add("overflow-hidden");
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "sidebarOverlay";
|
||||
overlay.className =
|
||||
"fixed inset-0 bg-black bg-opacity-50 z-40 xl:hidden";
|
||||
overlay.addEventListener("click", toggleSidebar);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
};
|
||||
|
||||
mobileSidebarToggle.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
|
||||
// Function to initialize the page
|
||||
const initializePage = async () => {
|
||||
try {
|
||||
// Initialize auth sync for IndexedDB
|
||||
await initAuthSync();
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!auth.isAuthenticated()) {
|
||||
console.log("User not authenticated");
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.add("hidden");
|
||||
if (notAuthenticatedState)
|
||||
notAuthenticatedState.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.remove("hidden");
|
||||
if (pageErrorState) pageErrorState.classList.add("hidden");
|
||||
if (notAuthenticatedState)
|
||||
notAuthenticatedState.classList.add("hidden");
|
||||
|
||||
// Show loading states
|
||||
const userProfileSkeleton = document.getElementById(
|
||||
"userProfileSkeleton"
|
||||
);
|
||||
const userProfileSignedOut = document.getElementById(
|
||||
"userProfileSignedOut"
|
||||
);
|
||||
const userProfileSummary =
|
||||
document.getElementById("userProfileSummary");
|
||||
const menuLoadingSkeleton = document.getElementById(
|
||||
"menuLoadingSkeleton"
|
||||
);
|
||||
const actualMenu = document.getElementById("actualMenu");
|
||||
|
||||
if (userProfileSkeleton)
|
||||
userProfileSkeleton.classList.remove("hidden");
|
||||
if (userProfileSummary)
|
||||
userProfileSummary.classList.add("hidden");
|
||||
if (userProfileSignedOut)
|
||||
userProfileSignedOut.classList.add("hidden");
|
||||
if (menuLoadingSkeleton)
|
||||
menuLoadingSkeleton.classList.remove("hidden");
|
||||
if (actualMenu) actualMenu.classList.add("hidden");
|
||||
|
||||
const user = auth.getCurrentUser();
|
||||
await updateUserProfile(user);
|
||||
|
||||
// Show actual profile and hide skeleton
|
||||
if (userProfileSkeleton)
|
||||
userProfileSkeleton.classList.add("hidden");
|
||||
if (userProfileSummary)
|
||||
userProfileSummary.classList.remove("hidden");
|
||||
|
||||
// Hide all sections first
|
||||
document
|
||||
.querySelectorAll(".dashboard-section")
|
||||
.forEach((section) => {
|
||||
section.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Show appropriate default section based on role
|
||||
// Get the officer record for this user if it exists
|
||||
let officerStatus: OfficerStatus = "none";
|
||||
|
||||
try {
|
||||
const officerRecords = await get.getList(
|
||||
"officers",
|
||||
1,
|
||||
50,
|
||||
`user="${user.id}"`,
|
||||
"",
|
||||
{
|
||||
fields: ["id", "type", "role"],
|
||||
}
|
||||
);
|
||||
|
||||
if (officerRecords && officerRecords.items.length > 0) {
|
||||
const officerType = officerRecords.items[0].type;
|
||||
// We can also get the role here if needed for display elsewhere
|
||||
const officerRole = officerRecords.items[0].role;
|
||||
|
||||
// Map the officer type to our OfficerStatus
|
||||
switch (officerType) {
|
||||
case OfficerTypes.ADMINISTRATOR:
|
||||
officerStatus = "administrator";
|
||||
break;
|
||||
case OfficerTypes.EXECUTIVE:
|
||||
officerStatus = "executive";
|
||||
break;
|
||||
case OfficerTypes.GENERAL:
|
||||
officerStatus = "general";
|
||||
break;
|
||||
case OfficerTypes.HONORARY:
|
||||
officerStatus = "honorary";
|
||||
break;
|
||||
case OfficerTypes.PAST:
|
||||
officerStatus = "past";
|
||||
break;
|
||||
default:
|
||||
officerStatus = "none";
|
||||
}
|
||||
} else {
|
||||
// Check if user is a sponsor by querying the sponsors collection
|
||||
const sponsorRecords = await get.getList(
|
||||
"sponsors",
|
||||
1,
|
||||
1,
|
||||
`user="${user.id}"`,
|
||||
"",
|
||||
{
|
||||
fields: ["id", "company"],
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
sponsorRecords &&
|
||||
sponsorRecords.items.length > 0
|
||||
) {
|
||||
officerStatus = "sponsor";
|
||||
} else {
|
||||
officerStatus = "none";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error determining officer status:",
|
||||
error
|
||||
);
|
||||
officerStatus = "none";
|
||||
}
|
||||
|
||||
let defaultSection;
|
||||
let defaultButton;
|
||||
|
||||
if (officerStatus === "sponsor") {
|
||||
defaultSection = document.getElementById(
|
||||
"sponsorDashboardSection"
|
||||
);
|
||||
defaultButton = document.querySelector(
|
||||
'[data-section="sponsorDashboard"]'
|
||||
);
|
||||
} else if (officerStatus === "administrator") {
|
||||
defaultSection = document.getElementById(
|
||||
"adminDashboardSection"
|
||||
);
|
||||
defaultButton = document.querySelector(
|
||||
'[data-section="adminDashboard"]'
|
||||
);
|
||||
} else {
|
||||
defaultSection =
|
||||
document.getElementById("profileSection");
|
||||
defaultButton = document.querySelector(
|
||||
'[data-section="profile"]'
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultSection) {
|
||||
defaultSection.classList.remove("hidden");
|
||||
}
|
||||
if (defaultButton) {
|
||||
defaultButton.classList.add("active", "bg-base-200");
|
||||
}
|
||||
|
||||
// Initialize navigation
|
||||
handleNavigation();
|
||||
|
||||
// Show actual menu and hide skeleton
|
||||
if (menuLoadingSkeleton)
|
||||
menuLoadingSkeleton.classList.add("hidden");
|
||||
if (actualMenu) actualMenu.classList.remove("hidden");
|
||||
|
||||
// Show main content and hide loading
|
||||
if (mainContent) mainContent.classList.remove("hidden");
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.add("hidden");
|
||||
} catch (error) {
|
||||
console.error("Error initializing dashboard:", error);
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.add("hidden");
|
||||
if (pageErrorState)
|
||||
pageErrorState.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener("DOMContentLoaded", initializePage);
|
||||
|
||||
// Handle login button click
|
||||
document
|
||||
.querySelector(".login-button")
|
||||
?.addEventListener("click", async () => {
|
||||
try {
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.remove("hidden");
|
||||
if (notAuthenticatedState)
|
||||
notAuthenticatedState.classList.add("hidden");
|
||||
await auth.login();
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.add("hidden");
|
||||
if (pageErrorState)
|
||||
pageErrorState.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle logout button click
|
||||
document
|
||||
.getElementById("logoutButton")
|
||||
?.addEventListener("click", () => {
|
||||
auth.logout();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Handle responsive sidebar
|
||||
if (sidebar) {
|
||||
if (window.innerWidth < 1024) {
|
||||
sidebar.classList.add("-translate-x-full");
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
const overlay =
|
||||
document.getElementById("sidebarOverlay");
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
}
|
||||
sidebar.classList.remove("-translate-x-full");
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -21,12 +21,11 @@ import contactbroder from "../images/contactbroder.webp";
|
|||
/>
|
||||
<FindTitle />
|
||||
<div class="w-full flex justify-center">
|
||||
<Image
|
||||
src={event}
|
||||
alt="board group photos"
|
||||
class="w-[90%] md:w-3/4 rounded-full"
|
||||
/>
|
||||
<div class="skeleton w-3/4 rounded-full">
|
||||
<Image src={event} alt="board group photos" class="w-full rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Findus />
|
||||
<Social />
|
||||
</Layout>
|
||||
|
|
17
src/pages/oauth2-redirect.astro
Normal file
17
src/pages/oauth2-redirect.astro
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
const title = "Authenticating...";
|
||||
---
|
||||
|
||||
<main class="min-h-screen flex items-center justify-center">
|
||||
<div id="content" class="text-center">
|
||||
<p class="text-2xl font-medium">Redirecting to dashboard...</p>
|
||||
<div class="mt-4">
|
||||
<div class="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
import { RedirectHandler } from "../scripts/auth/RedirectHandler";
|
||||
new RedirectHandler();
|
||||
</script>
|
7
src/pages/reimbursement.astro
Normal file
7
src/pages/reimbursement.astro
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<h1>Reimbursement</h1>
|
||||
</Layout>
|
46
src/schemas/pocketbase/README.md
Normal file
46
src/schemas/pocketbase/README.md
Normal file
|
@ -0,0 +1,46 @@
|
|||
# PocketBase Schema
|
||||
|
||||
This directory contains the schema definition for the PocketBase database used in the IEEE UCSD website.
|
||||
|
||||
## Overview
|
||||
|
||||
The `schema.ts` file defines TypeScript interfaces that represent the collections in the PocketBase database. These interfaces can be imported and used throughout the codebase to ensure type safety when working with PocketBase data.
|
||||
|
||||
## Collections
|
||||
|
||||
The following collections are defined in the schema:
|
||||
|
||||
- **Users**: User accounts in the system
|
||||
- **Events**: Events created in the system
|
||||
- **Event Requests**: Requests to create new events
|
||||
- **Logs**: System logs for user actions
|
||||
- **Officers**: Officer roles in the organization
|
||||
- **Reimbursements**: Reimbursement requests
|
||||
- **Receipts**: Receipt records for reimbursements
|
||||
- **Sponsors**: Sponsors of the organization
|
||||
|
||||
## Usage
|
||||
|
||||
To use these types in your code, import them from the schema file:
|
||||
|
||||
```typescript
|
||||
import { User, Event, Collections } from "../pocketbase/schema";
|
||||
|
||||
// Example: Get a user from PocketBase
|
||||
const getUser = async (userId: string): Promise<User> => {
|
||||
const pb = getPocketBase();
|
||||
return await pb.collection(Collections.USERS).getOne<User>(userId);
|
||||
};
|
||||
```
|
||||
|
||||
## Updating the Schema
|
||||
|
||||
When the PocketBase database schema changes, update the corresponding interfaces in `schema.ts` to reflect those changes. This ensures that the TypeScript types match the actual database structure.
|
||||
|
||||
## Collection Names
|
||||
|
||||
The `Collections` object provides constants for all collection names, which should be used when making API calls to PocketBase instead of hardcoding collection names as strings.
|
||||
|
||||
## Collection IDs
|
||||
|
||||
Each collection has its PocketBase collection ID documented in the schema file. These IDs are useful for reference and debugging purposes.
|
8
src/schemas/pocketbase/index.ts
Normal file
8
src/schemas/pocketbase/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* PocketBase Schema Index
|
||||
*
|
||||
* This file re-exports all types and constants from the schema file
|
||||
* for easier imports throughout the codebase.
|
||||
*/
|
||||
|
||||
export * from "./schema";
|
267
src/schemas/pocketbase/schema.ts
Normal file
267
src/schemas/pocketbase/schema.ts
Normal file
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* PocketBase Collections Schema
|
||||
*
|
||||
* This file documents the schema for all collections in the PocketBase database.
|
||||
* It is based on the interfaces found throughout the codebase.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base interface for all PocketBase records
|
||||
*/
|
||||
export interface BaseRecord {
|
||||
id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users Collection
|
||||
* Represents user accounts in the system
|
||||
* Collection ID: _pb_users_auth_
|
||||
*/
|
||||
export interface User extends BaseRecord {
|
||||
email: string;
|
||||
emailVisibility: boolean;
|
||||
verified: boolean;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
pid?: string;
|
||||
member_id?: string;
|
||||
graduation_year?: number;
|
||||
major?: string;
|
||||
zelle_information?: string;
|
||||
last_login?: string;
|
||||
points?: number; // Total points earned from events
|
||||
notification_preferences?: string; // JSON string of notification settings
|
||||
display_preferences?: string; // JSON string of display settings (theme, font size, etc.)
|
||||
accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Events Collection
|
||||
* Represents events created in the system
|
||||
* Collection ID: pbc_1687431684
|
||||
*/
|
||||
export interface Event extends BaseRecord {
|
||||
event_name: string;
|
||||
event_description: string;
|
||||
event_code: string;
|
||||
location: string;
|
||||
files: string[];
|
||||
points_to_reward: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
published: boolean;
|
||||
has_food: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendee Entry
|
||||
* Represents an attendee record for an event
|
||||
* This is stored as part of the Event record
|
||||
*/
|
||||
export interface AttendeeEntry {
|
||||
user_id: string;
|
||||
time_checked_in: string;
|
||||
food: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Attendees Collection
|
||||
* Represents attendees for events
|
||||
* Collection ID: pbc_537966730
|
||||
*/
|
||||
export interface EventAttendee extends BaseRecord {
|
||||
user: string; // Relation to User
|
||||
event: string; // Relation to Event
|
||||
food_ate: string;
|
||||
time_checked_in: string;
|
||||
points_earned: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Requests Collection
|
||||
* Represents requests to create new events
|
||||
* Collection ID: pbc_1475615553
|
||||
*/
|
||||
export interface EventRequest extends BaseRecord {
|
||||
name: string;
|
||||
location: string;
|
||||
start_date_time: string;
|
||||
end_date_time: string;
|
||||
event_description: string;
|
||||
flyers_needed: boolean;
|
||||
flyer_type?: string[]; // digital_with_social, digital_no_social, physical_with_advertising, physical_no_advertising, newsletter, other
|
||||
other_flyer_type?: string;
|
||||
flyer_advertising_start_date?: string;
|
||||
flyer_additional_requests?: string;
|
||||
photography_needed: boolean;
|
||||
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
|
||||
other_logos?: string[];
|
||||
advertising_format?: string;
|
||||
will_or_have_room_booking?: boolean;
|
||||
expected_attendance?: number;
|
||||
room_booking?: string;
|
||||
as_funding_required: boolean;
|
||||
food_drinks_being_served: boolean;
|
||||
itemized_invoice?: string; // JSON string
|
||||
invoice?: string;
|
||||
invoice_files?: string[]; // Array of invoice file IDs
|
||||
needs_graphics?: boolean;
|
||||
needs_as_funding?: boolean;
|
||||
status: "submitted" | "pending" | "completed" | "declined";
|
||||
requested_user?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs Collection
|
||||
* Represents system logs for user actions
|
||||
* Collection ID: pbc_3615662572
|
||||
*/
|
||||
export interface Log extends BaseRecord {
|
||||
user: string; // Relation to User
|
||||
type: string; // Standard types: "error", "update", "delete", "create", "login", "logout"
|
||||
part: string; // The specific part/section being logged
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Officers Collection
|
||||
* Represents officer roles in the organization
|
||||
* Collection ID: pbc_1036312343
|
||||
*/
|
||||
export interface Officer extends BaseRecord {
|
||||
user: string; // Relation to User
|
||||
role: string;
|
||||
type: "administrator" | "executive" | "general" | "honorary" | "past";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reimbursements Collection
|
||||
* Represents reimbursement requests
|
||||
* Collection ID: pbc_2573806534
|
||||
*/
|
||||
export interface Reimbursement extends BaseRecord {
|
||||
title: string;
|
||||
total_amount: number;
|
||||
date_of_purchase: string;
|
||||
payment_method: string;
|
||||
status:
|
||||
| "submitted"
|
||||
| "under_review"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "in_progress"
|
||||
| "paid";
|
||||
submitted_by: string; // Relation to User
|
||||
additional_info: string;
|
||||
receipts: string[]; // Array of Receipt IDs (Relations)
|
||||
department: "internal" | "external" | "projects" | "events" | "other";
|
||||
audit_notes?: string | null; // JSON string for user-submitted notes
|
||||
audit_logs?: string | null; // JSON string for system-generated logs
|
||||
}
|
||||
|
||||
/**
|
||||
* Receipts Collection
|
||||
* Represents receipt records for reimbursements
|
||||
* Collection ID: pbc_1571142587
|
||||
*/
|
||||
export interface Receipt extends BaseRecord {
|
||||
file: string; // Single file
|
||||
created_by: string; // Relation to User
|
||||
itemized_expenses: string; // JSON string of ItemizedExpense[]
|
||||
tax: number;
|
||||
date: string;
|
||||
location_name: string;
|
||||
location_address: string;
|
||||
notes: string;
|
||||
audited_by: string; // Relation to User
|
||||
}
|
||||
|
||||
/**
|
||||
* Sponsors Collection
|
||||
* Represents sponsors of the organization
|
||||
* Collection ID: pbc_3665759510
|
||||
*/
|
||||
export interface Sponsor extends BaseRecord {
|
||||
user: string; // Relation to User
|
||||
company: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Itemized Expense
|
||||
* Represents an individual expense item in a receipt
|
||||
* This is stored as part of the Receipt record as a JSON string
|
||||
*/
|
||||
export interface ItemizedExpense {
|
||||
description: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection Names
|
||||
* Constants for the collection names used in the PocketBase API
|
||||
*/
|
||||
export const Collections = {
|
||||
USERS: "users",
|
||||
EVENTS: "events",
|
||||
EVENT_REQUESTS: "event_request",
|
||||
EVENT_ATTENDEES: "event_attendees",
|
||||
LOGS: "logs",
|
||||
OFFICERS: "officers",
|
||||
REIMBURSEMENTS: "reimbursement",
|
||||
RECEIPTS: "receipts",
|
||||
SPONSORS: "sponsors",
|
||||
};
|
||||
|
||||
/**
|
||||
* Flyer Type Options
|
||||
* Constants for the flyer type options used in event requests
|
||||
*/
|
||||
export const FlyerTypes = {
|
||||
DIGITAL_WITH_SOCIAL: "digital_with_social",
|
||||
DIGITAL_NO_SOCIAL: "digital_no_social",
|
||||
PHYSICAL_WITH_ADVERTISING: "physical_with_advertising",
|
||||
PHYSICAL_NO_ADVERTISING: "physical_no_advertising",
|
||||
NEWSLETTER: "newsletter",
|
||||
OTHER: "other",
|
||||
};
|
||||
|
||||
/**
|
||||
* Logo Options
|
||||
* Constants for the logo options used in event requests
|
||||
*/
|
||||
export const LogoOptions = {
|
||||
IEEE: "IEEE",
|
||||
AS: "AS",
|
||||
HKN: "HKN",
|
||||
TESC: "TESC",
|
||||
PIB: "PIB",
|
||||
TNT: "TNT",
|
||||
SWE: "SWE",
|
||||
OTHER: "OTHER",
|
||||
};
|
||||
|
||||
/**
|
||||
* Event Request Status Options
|
||||
* Constants for the status options used in event requests
|
||||
*/
|
||||
export const EventRequestStatus = {
|
||||
SUBMITTED: "submitted",
|
||||
PENDING: "pending",
|
||||
COMPLETED: "completed",
|
||||
DECLINED: "declined",
|
||||
};
|
||||
|
||||
/**
|
||||
* Officer Type Options
|
||||
* Constants for the officer type options
|
||||
*/
|
||||
export const OfficerTypes = {
|
||||
ADMINISTRATOR: "administrator",
|
||||
EXECUTIVE: "executive",
|
||||
GENERAL: "general",
|
||||
HONORARY: "honorary",
|
||||
PAST: "past",
|
||||
};
|
118
src/scripts/auth/RedirectHandler.ts
Normal file
118
src/scripts/auth/RedirectHandler.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import PocketBase from "pocketbase";
|
||||
|
||||
export class RedirectHandler {
|
||||
private pb: PocketBase;
|
||||
private contentEl: HTMLElement;
|
||||
private params: URLSearchParams;
|
||||
private provider: any;
|
||||
|
||||
constructor() {
|
||||
this.pb = new PocketBase("https://pocketbase.ieeeucsd.org");
|
||||
this.contentEl = this.getContentElement();
|
||||
this.params = new URLSearchParams(window.location.search);
|
||||
this.provider = this.getStoredProvider();
|
||||
this.handleRedirect();
|
||||
}
|
||||
|
||||
private getContentElement(): HTMLElement {
|
||||
const contentEl = document.getElementById("content");
|
||||
if (!contentEl) {
|
||||
throw new Error("Content element not found");
|
||||
}
|
||||
return contentEl;
|
||||
}
|
||||
|
||||
private getStoredProvider() {
|
||||
return JSON.parse(localStorage.getItem("provider") || "{}");
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
this.contentEl.innerHTML = `
|
||||
<p class='text-red-500 text-2xl font-medium mb-4'>${message}</p>
|
||||
<a href="/" class="btn btn-primary">
|
||||
Return to Home
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
private async handleRedirect() {
|
||||
const code = this.params.get("code");
|
||||
const state = this.params.get("state");
|
||||
|
||||
if (!code) {
|
||||
this.showError("No authorization code found in URL.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== this.provider.state) {
|
||||
this.showError("Invalid state parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authData = await this.pb
|
||||
.collection("users")
|
||||
.authWithOAuth2Code(
|
||||
"oidc",
|
||||
code,
|
||||
this.provider.codeVerifier,
|
||||
window.location.origin + "/oauth2-redirect",
|
||||
{ emailVisibility: false },
|
||||
);
|
||||
|
||||
console.log("Auth successful:", authData);
|
||||
this.contentEl.innerHTML = `
|
||||
<p class="text-3xl font-bold text-green-500 mb-4">Authentication Successful!</p>
|
||||
<p class="text-2xl font-medium">Initializing your data...</p>
|
||||
<div class="mt-4">
|
||||
<div class="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// Update last login before redirecting
|
||||
await this.pb.collection("users").update(authData.record.id, {
|
||||
last_login: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Initialize data sync
|
||||
await this.initializeDataSync();
|
||||
|
||||
// Clean up and redirect
|
||||
localStorage.removeItem("provider");
|
||||
window.location.href = "/dashboard";
|
||||
} catch (err) {
|
||||
console.error("Failed to update last login or sync data:", err);
|
||||
// Still redirect even if last_login update fails
|
||||
localStorage.removeItem("provider");
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Auth error:", err);
|
||||
this.showError(`Failed to complete authentication: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize data synchronization after successful authentication
|
||||
*/
|
||||
private async initializeDataSync(): Promise<void> {
|
||||
try {
|
||||
// Dynamically import the AuthSyncService to avoid circular dependencies
|
||||
const { AuthSyncService } = await import('../database/AuthSyncService');
|
||||
|
||||
// Get the instance and trigger a full sync
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
const syncResult = await authSync.handleLogin();
|
||||
|
||||
if (syncResult) {
|
||||
console.log('Initial data sync completed successfully');
|
||||
} else {
|
||||
console.warn('Initial data sync completed with issues');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize data sync:', error);
|
||||
// Continue with login process even if sync fails
|
||||
}
|
||||
}
|
||||
}
|
327
src/scripts/database/AuthSyncService.ts
Normal file
327
src/scripts/database/AuthSyncService.ts
Normal file
|
@ -0,0 +1,327 @@
|
|||
import { Authentication } from "../pocketbase/Authentication";
|
||||
import { DataSyncService } from "./DataSyncService";
|
||||
import { DexieService } from "./DexieService";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
import { SendLog } from "../pocketbase/SendLog";
|
||||
|
||||
// Check if we're in a browser environment
|
||||
const isBrowser =
|
||||
typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
|
||||
|
||||
/**
|
||||
* Service to handle data synchronization during authentication flows
|
||||
*/
|
||||
export class AuthSyncService {
|
||||
private static instance: AuthSyncService;
|
||||
private auth: Authentication;
|
||||
private dataSync: DataSyncService;
|
||||
private dexieService: DexieService;
|
||||
private logger: SendLog;
|
||||
private isSyncing: boolean = false;
|
||||
private syncErrors: Record<string, Error> = {};
|
||||
private syncQueue: string[] = [];
|
||||
private syncPromise: Promise<void> | null = null;
|
||||
|
||||
// Collections to sync on login
|
||||
private readonly collectionsToSync = [
|
||||
Collections.USERS,
|
||||
Collections.EVENTS,
|
||||
Collections.EVENT_REQUESTS,
|
||||
Collections.LOGS,
|
||||
Collections.OFFICERS,
|
||||
Collections.REIMBURSEMENTS,
|
||||
Collections.RECEIPTS,
|
||||
Collections.SPONSORS,
|
||||
];
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
this.dataSync = DataSyncService.getInstance();
|
||||
this.dexieService = DexieService.getInstance();
|
||||
this.logger = SendLog.getInstance();
|
||||
|
||||
// Listen for auth state changes only in browser
|
||||
if (isBrowser) {
|
||||
this.auth.onAuthStateChange(this.handleAuthStateChange.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of AuthSyncService
|
||||
*/
|
||||
public static getInstance(): AuthSyncService {
|
||||
if (!AuthSyncService.instance) {
|
||||
AuthSyncService.instance = new AuthSyncService();
|
||||
}
|
||||
return AuthSyncService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication state changes
|
||||
*/
|
||||
private async handleAuthStateChange(isAuthenticated: boolean): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// User just logged in
|
||||
await this.handleLogin();
|
||||
} else {
|
||||
// User just logged out
|
||||
await this.handleLogout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login by syncing user data
|
||||
*/
|
||||
public async handleLogin(): Promise<boolean> {
|
||||
if (!isBrowser) return true;
|
||||
|
||||
if (this.isSyncing) {
|
||||
console.log("Sync already in progress, queueing login sync");
|
||||
if (this.syncPromise) {
|
||||
this.syncPromise = this.syncPromise.then(() => this.performLoginSync());
|
||||
} else {
|
||||
this.syncPromise = this.performLoginSync();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this.syncPromise = this.performLoginSync();
|
||||
return this.syncPromise.then(
|
||||
() => Object.keys(this.syncErrors).length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual login sync
|
||||
*/
|
||||
private async performLoginSync(): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.log("Not authenticated, skipping login sync");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
this.syncErrors = {};
|
||||
|
||||
try {
|
||||
console.log("Starting login sync process...");
|
||||
|
||||
// Display sync notification if in browser environment
|
||||
this.showSyncNotification("Syncing your data...");
|
||||
|
||||
// Sync user-specific data first
|
||||
const userId = this.auth.getUserId();
|
||||
if (userId) {
|
||||
// First sync the current user's data
|
||||
await this.dataSync.syncCollection(
|
||||
Collections.USERS,
|
||||
`id = "${userId}"`,
|
||||
);
|
||||
|
||||
// Log the sync operation
|
||||
console.log("User data synchronized on login");
|
||||
}
|
||||
|
||||
// Sync all collections in parallel with conflict resolution
|
||||
await Promise.all(
|
||||
this.collectionsToSync.map(async (collection) => {
|
||||
try {
|
||||
await this.dataSync.syncCollection(collection);
|
||||
console.log(`Successfully synced ${collection}`);
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}:`, error);
|
||||
this.syncErrors[collection] = error as Error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// SECURITY FIX: Purge any event codes that might have been synced
|
||||
await this.dataSync.purgeEventCodes();
|
||||
|
||||
// Verify sync was successful
|
||||
const syncVerification = await this.verifySyncSuccess();
|
||||
|
||||
if (syncVerification.success) {
|
||||
console.log("Login sync completed successfully");
|
||||
this.showSyncNotification("Data sync complete!", "success");
|
||||
} else {
|
||||
console.warn(
|
||||
"Login sync completed with issues:",
|
||||
syncVerification.errors,
|
||||
);
|
||||
this.showSyncNotification("Some data could not be synced", "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during login sync:", error);
|
||||
this.showSyncNotification("Failed to sync data", "error");
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
|
||||
// Process any queued sync operations
|
||||
if (this.syncQueue.length > 0) {
|
||||
const nextSync = this.syncQueue.shift();
|
||||
if (nextSync === "login") {
|
||||
this.handleLogin();
|
||||
} else if (nextSync === "logout") {
|
||||
this.handleLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout by clearing user data
|
||||
*/
|
||||
public async handleLogout(): Promise<boolean> {
|
||||
if (!isBrowser) return true;
|
||||
|
||||
if (this.isSyncing) {
|
||||
console.log("Sync already in progress, queueing logout cleanup");
|
||||
this.syncQueue.push("logout");
|
||||
return true;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
console.log("Starting logout cleanup process...");
|
||||
|
||||
// Ensure any pending changes are synced before logout
|
||||
await this.syncPendingChanges();
|
||||
|
||||
// Clear all data from IndexedDB
|
||||
await this.dexieService.clearAllData();
|
||||
|
||||
console.log("Logout cleanup completed successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error during logout cleanup:", error);
|
||||
return false;
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
|
||||
// Process any queued sync operations
|
||||
if (this.syncQueue.length > 0) {
|
||||
const nextSync = this.syncQueue.shift();
|
||||
if (nextSync === "login") {
|
||||
this.handleLogin();
|
||||
} else if (nextSync === "logout") {
|
||||
this.handleLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync any pending changes before logout
|
||||
*/
|
||||
private async syncPendingChanges(): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
// This would be implemented if we had offline capabilities
|
||||
// For now, we just log that we would sync pending changes
|
||||
console.log("Checking for pending changes to sync before logout...");
|
||||
// In a real implementation, this would sync any offline changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that sync was successful by checking data in IndexedDB
|
||||
*/
|
||||
private async verifySyncSuccess(): Promise<{
|
||||
success: boolean;
|
||||
errors: Record<string, string>;
|
||||
}> {
|
||||
if (!isBrowser) return { success: true, errors: {} };
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// Check each collection that had errors
|
||||
for (const [collection, error] of Object.entries(this.syncErrors)) {
|
||||
errors[collection] = error.message;
|
||||
}
|
||||
|
||||
// Check if user data was synced properly
|
||||
const userId = this.auth.getUserId();
|
||||
if (userId) {
|
||||
try {
|
||||
const user = await this.dataSync.getItem(
|
||||
Collections.USERS,
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
if (!user) {
|
||||
errors["user_verification"] =
|
||||
"User data not found in IndexedDB after sync";
|
||||
}
|
||||
} catch (error) {
|
||||
errors["user_verification"] =
|
||||
`Error verifying user data: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification to the user about sync status
|
||||
*/
|
||||
private showSyncNotification(
|
||||
message: string,
|
||||
type: "info" | "success" | "warning" | "error" = "info",
|
||||
): void {
|
||||
// Only run in browser environment
|
||||
if (!isBrowser) return;
|
||||
|
||||
// Check if toast function exists (from react-hot-toast or similar)
|
||||
if (typeof window.toast === "function") {
|
||||
window.toast(message, { type });
|
||||
} else {
|
||||
// Fallback to console
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a sync of all collections
|
||||
*/
|
||||
public async forceSyncAll(): Promise<boolean> {
|
||||
if (this.isSyncing) {
|
||||
console.log("Sync already in progress, queueing full sync");
|
||||
this.syncQueue.push("login"); // Reuse login sync logic
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.handleLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sync is currently in progress
|
||||
*/
|
||||
public isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any errors from the last sync operation
|
||||
*/
|
||||
public getSyncErrors(): Record<string, Error> {
|
||||
return { ...this.syncErrors };
|
||||
}
|
||||
}
|
||||
|
||||
// Add toast type to window for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
toast?: (
|
||||
message: string,
|
||||
options?: { type: "info" | "success" | "warning" | "error" },
|
||||
) => void;
|
||||
}
|
||||
}
|
775
src/scripts/database/DataSyncService.ts
Normal file
775
src/scripts/database/DataSyncService.ts
Normal file
|
@ -0,0 +1,775 @@
|
|||
import { DexieService } from "./DexieService";
|
||||
import { Get } from "../pocketbase/Get";
|
||||
import { Update } from "../pocketbase/Update";
|
||||
import { Authentication } from "../pocketbase/Authentication";
|
||||
import { Collections, type BaseRecord } from "../../schemas/pocketbase/schema";
|
||||
import type Dexie from "dexie";
|
||||
|
||||
// Check if we're in a browser environment
|
||||
const isBrowser =
|
||||
typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
|
||||
|
||||
// Interface for tracking offline changes
|
||||
interface OfflineChange {
|
||||
id: string;
|
||||
collection: string;
|
||||
recordId: string;
|
||||
operation: "create" | "update" | "delete";
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
synced: boolean;
|
||||
syncAttempts: number;
|
||||
}
|
||||
|
||||
export class DataSyncService {
|
||||
private static instance: DataSyncService;
|
||||
private dexieService: DexieService;
|
||||
private get: Get;
|
||||
private update: Update;
|
||||
private auth: Authentication;
|
||||
private syncInProgress: Record<string, boolean> = {};
|
||||
private offlineMode: boolean = false;
|
||||
private offlineChangesTable: Dexie.Table<OfflineChange, string> | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.dexieService = DexieService.getInstance();
|
||||
this.get = Get.getInstance();
|
||||
this.update = Update.getInstance();
|
||||
this.auth = Authentication.getInstance();
|
||||
|
||||
// Initialize offline changes table only in browser
|
||||
if (isBrowser) {
|
||||
this.initOfflineChangesTable();
|
||||
|
||||
// Check for network status
|
||||
window.addEventListener("online", this.handleOnline.bind(this));
|
||||
window.addEventListener("offline", this.handleOffline.bind(this));
|
||||
this.offlineMode = !navigator.onLine;
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DataSyncService {
|
||||
if (!DataSyncService.instance) {
|
||||
DataSyncService.instance = new DataSyncService();
|
||||
}
|
||||
return DataSyncService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the offline changes table
|
||||
*/
|
||||
private initOfflineChangesTable(): void {
|
||||
if (!isBrowser) return;
|
||||
|
||||
try {
|
||||
const db = this.dexieService.getDB();
|
||||
// Check if the table exists in the schema
|
||||
if ("offlineChanges" in db) {
|
||||
this.offlineChangesTable = db.offlineChanges as Dexie.Table<
|
||||
OfflineChange,
|
||||
string
|
||||
>;
|
||||
} else {
|
||||
console.warn("Offline changes table not found in schema");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing offline changes table:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle device coming online
|
||||
*/
|
||||
private async handleOnline(): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
console.log("Device is online, syncing pending changes...");
|
||||
this.offlineMode = false;
|
||||
await this.syncOfflineChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle device going offline
|
||||
*/
|
||||
private handleOffline(): void {
|
||||
if (!isBrowser) return;
|
||||
|
||||
console.log("Device is offline, enabling offline mode...");
|
||||
this.offlineMode = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a specific collection from PocketBase to IndexedDB
|
||||
*/
|
||||
public async syncCollection<T extends BaseRecord>(
|
||||
collection: string,
|
||||
filter: string = "",
|
||||
sort: string = "-created",
|
||||
expand: Record<string, any> | string[] | string = {},
|
||||
): Promise<T[]> {
|
||||
// Skip in non-browser environments
|
||||
if (!isBrowser) {
|
||||
console.log(`Skipping sync for ${collection} in non-browser environment`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Prevent multiple syncs of the same collection at the same time
|
||||
if (this.syncInProgress[collection]) {
|
||||
console.log(`Sync already in progress for ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.syncInProgress[collection] = true;
|
||||
|
||||
try {
|
||||
// Check if we're authenticated
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.log(`Not authenticated, skipping sync for ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we're offline
|
||||
if (this.offlineMode) {
|
||||
console.log(`Device is offline, using cached data for ${collection}`);
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
return table ? ((await table.toArray()) as T[]) : [];
|
||||
}
|
||||
|
||||
console.log(`Syncing ${collection}...`);
|
||||
|
||||
// Normalize expand parameter to be an array of strings
|
||||
let normalizedExpand: string[] | undefined;
|
||||
|
||||
if (expand) {
|
||||
if (typeof expand === "string") {
|
||||
// If expand is a string, convert it to an array
|
||||
normalizedExpand = [expand];
|
||||
} else if (Array.isArray(expand)) {
|
||||
// If expand is already an array, use it as is
|
||||
normalizedExpand = expand;
|
||||
} else if (typeof expand === "object") {
|
||||
// If expand is an object, extract the keys
|
||||
normalizedExpand = Object.keys(expand);
|
||||
}
|
||||
}
|
||||
|
||||
// Get data from PocketBase
|
||||
const items = await this.get.getAll<T>(collection, filter, sort, {
|
||||
expand: normalizedExpand,
|
||||
});
|
||||
console.log(`Fetched ${items.length} items from ${collection}`);
|
||||
|
||||
// Get the database table
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get existing items to handle conflicts
|
||||
const existingItems = await table.toArray();
|
||||
const existingItemsMap = new Map(
|
||||
existingItems.map((item) => [item.id, item]),
|
||||
);
|
||||
|
||||
// Handle conflicts and merge changes
|
||||
const itemsToStore = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const existingItem = existingItemsMap.get(item.id);
|
||||
|
||||
// SECURITY FIX: Remove event_code from events before storing in IndexedDB
|
||||
if (collection === Collections.EVENTS && 'event_code' in item) {
|
||||
// Keep the event_code but ensure files array is properly handled
|
||||
if ('files' in item && Array.isArray((item as any).files)) {
|
||||
// Ensure files array is properly stored
|
||||
console.log(`Event ${item.id} has ${(item as any).files.length} files`);
|
||||
} else {
|
||||
// Initialize empty files array if not present
|
||||
(item as any).files = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
// Check for conflicts (local changes vs server changes)
|
||||
const resolvedItem = await this.resolveConflict(
|
||||
collection,
|
||||
existingItem,
|
||||
item,
|
||||
);
|
||||
return resolvedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
// Store in IndexedDB
|
||||
await table.bulkPut(itemsToStore);
|
||||
|
||||
// Update last sync timestamp
|
||||
await this.dexieService.updateLastSync(collection);
|
||||
|
||||
return itemsToStore as T[];
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.syncInProgress[collection] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflicts between local and server data
|
||||
*/
|
||||
private async resolveConflict<T extends BaseRecord>(
|
||||
collection: string,
|
||||
localItem: T,
|
||||
serverItem: T,
|
||||
): Promise<T> {
|
||||
// For events, ensure we handle the files field properly
|
||||
if (collection === Collections.EVENTS) {
|
||||
// Ensure files array is properly handled
|
||||
if ('files' in serverItem && Array.isArray((serverItem as any).files)) {
|
||||
console.log(`Server event ${serverItem.id} has ${(serverItem as any).files.length} files`);
|
||||
} else {
|
||||
// Initialize empty files array if not present
|
||||
(serverItem as any).files = [];
|
||||
}
|
||||
|
||||
// If local item has files but server doesn't, preserve local files
|
||||
if ('files' in localItem && Array.isArray((localItem as any).files) &&
|
||||
(localItem as any).files.length > 0 &&
|
||||
(!('files' in serverItem) || !(serverItem as any).files.length)) {
|
||||
console.log(`Preserving local files for event ${localItem.id}`);
|
||||
(serverItem as any).files = (localItem as any).files;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are pending offline changes for this item
|
||||
const pendingChanges = await this.getPendingChangesForRecord(
|
||||
collection,
|
||||
localItem.id,
|
||||
);
|
||||
|
||||
if (pendingChanges.length > 0) {
|
||||
console.log(
|
||||
`Found ${pendingChanges.length} pending changes for ${collection}:${localItem.id}`,
|
||||
);
|
||||
|
||||
// Server-wins strategy by default, but preserve local changes that haven't been synced
|
||||
const mergedItem = { ...serverItem };
|
||||
|
||||
// Apply pending changes on top of server data
|
||||
for (const change of pendingChanges) {
|
||||
if (change.operation === "update" && change.data) {
|
||||
// Apply each field change individually
|
||||
Object.entries(change.data).forEach(([key, value]) => {
|
||||
// Special handling for files array
|
||||
if (key === 'files' && Array.isArray(value)) {
|
||||
// Merge files arrays, removing duplicates
|
||||
const existingFiles = Array.isArray((mergedItem as any)[key]) ? (mergedItem as any)[key] : [];
|
||||
const newFiles = value as string[];
|
||||
(mergedItem as any)[key] = [...new Set([...existingFiles, ...newFiles])];
|
||||
console.log(`Merged files for ${collection}:${localItem.id}`, (mergedItem as any)[key]);
|
||||
} else {
|
||||
(mergedItem as any)[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
// No pending changes, use server data
|
||||
return serverItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes for a specific record
|
||||
*/
|
||||
private async getPendingChangesForRecord(
|
||||
collection: string,
|
||||
recordId: string,
|
||||
): Promise<OfflineChange[]> {
|
||||
if (!this.offlineChangesTable) return [];
|
||||
|
||||
try {
|
||||
return await this.offlineChangesTable
|
||||
.where("collection")
|
||||
.equals(collection)
|
||||
.and((item) => item.recordId === recordId && !item.synced)
|
||||
.toArray();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error getting pending changes for ${collection}:${recordId}:`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all pending offline changes
|
||||
*/
|
||||
public async syncOfflineChanges(): Promise<boolean> {
|
||||
if (!this.offlineChangesTable || this.offlineMode) return false;
|
||||
|
||||
try {
|
||||
// Get all unsynced changes
|
||||
const pendingChanges = await this.offlineChangesTable
|
||||
.where("synced")
|
||||
.equals(0) // Use 0 instead of false for indexable type
|
||||
.toArray();
|
||||
|
||||
if (pendingChanges.length === 0) {
|
||||
console.log("No pending offline changes to sync");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Syncing ${pendingChanges.length} offline changes...`);
|
||||
|
||||
// Group changes by collection for more efficient processing
|
||||
const changesByCollection = pendingChanges.reduce(
|
||||
(groups, change) => {
|
||||
const key = change.collection;
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(change);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, OfflineChange[]>,
|
||||
);
|
||||
|
||||
// Process each collection's changes
|
||||
for (const [collection, changes] of Object.entries(changesByCollection)) {
|
||||
// First sync the collection to get latest data
|
||||
await this.syncCollection(collection);
|
||||
|
||||
// Then apply each change
|
||||
for (const change of changes) {
|
||||
try {
|
||||
if (change.operation === "update" && change.data) {
|
||||
await this.update.updateFields(
|
||||
collection,
|
||||
change.recordId,
|
||||
change.data,
|
||||
);
|
||||
|
||||
// Mark as synced
|
||||
await this.offlineChangesTable.update(change.id, {
|
||||
synced: true,
|
||||
syncAttempts: change.syncAttempts + 1,
|
||||
});
|
||||
}
|
||||
// Add support for create and delete operations as needed
|
||||
} catch (error) {
|
||||
console.error(`Error syncing change ${change.id}:`, error);
|
||||
|
||||
// Increment sync attempts
|
||||
await this.offlineChangesTable.update(change.id, {
|
||||
syncAttempts: change.syncAttempts + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sync again to ensure we have the latest data
|
||||
await this.syncCollection(collection);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error syncing offline changes:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an offline change
|
||||
*/
|
||||
public async recordOfflineChange(
|
||||
collection: string,
|
||||
recordId: string,
|
||||
operation: "create" | "update" | "delete",
|
||||
data?: any,
|
||||
): Promise<string | null> {
|
||||
if (!this.offlineChangesTable) return null;
|
||||
|
||||
try {
|
||||
const change: Omit<OfflineChange, "id"> = {
|
||||
collection,
|
||||
recordId,
|
||||
operation,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
synced: false,
|
||||
syncAttempts: 0,
|
||||
};
|
||||
|
||||
const id = await this.offlineChangesTable.add(change as OfflineChange);
|
||||
console.log(
|
||||
`Recorded offline change: ${operation} on ${collection}:${recordId}`,
|
||||
);
|
||||
|
||||
// Try to sync immediately if we're online
|
||||
if (!this.offlineMode) {
|
||||
this.syncOfflineChanges().catch((err) => {
|
||||
console.error("Error syncing after recording change:", err);
|
||||
});
|
||||
}
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error recording offline change for ${collection}:${recordId}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from IndexedDB, syncing from PocketBase if needed
|
||||
*/
|
||||
public async getData<T extends BaseRecord>(
|
||||
collection: string,
|
||||
forceSync: boolean = false,
|
||||
filter: string = "",
|
||||
sort: string = "-created",
|
||||
expand: Record<string, any> | string[] | string = {},
|
||||
): Promise<T[]> {
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we need to sync
|
||||
const lastSync = await this.dexieService.getLastSync(collection);
|
||||
const now = Date.now();
|
||||
const syncThreshold = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
|
||||
try {
|
||||
await this.syncCollection<T>(collection, filter, sort, expand);
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}, using cached data:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get data from IndexedDB
|
||||
let data = await table.toArray();
|
||||
|
||||
// Apply filter if provided
|
||||
if (filter) {
|
||||
// This is a simple implementation - in a real app, you'd want to parse the filter string
|
||||
// and apply it properly. This is just a basic example.
|
||||
data = data.filter((item: any) => {
|
||||
// Split filter by logical operators
|
||||
const conditions = filter.split(" && ");
|
||||
return conditions.every((condition) => {
|
||||
// Parse condition (very basic implementation)
|
||||
if (condition.includes("=")) {
|
||||
const [field, value] = condition.split("=");
|
||||
const cleanValue = value.replace(/"/g, "");
|
||||
return item[field] === cleanValue;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sort if provided
|
||||
if (sort) {
|
||||
const isDesc = sort.startsWith("-");
|
||||
const field = isDesc ? sort.substring(1) : sort;
|
||||
|
||||
data.sort((a: any, b: any) => {
|
||||
if (a[field] < b[field]) return isDesc ? 1 : -1;
|
||||
if (a[field] > b[field]) return isDesc ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY FIX: Remove event_code from events before returning them
|
||||
if (collection === Collections.EVENTS) {
|
||||
data = data.map((item: any) => {
|
||||
if ('event_code' in item) {
|
||||
const { event_code, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return data as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single item by ID
|
||||
*/
|
||||
public async getItem<T extends BaseRecord>(
|
||||
collection: string,
|
||||
id: string,
|
||||
forceSync: boolean = false,
|
||||
): Promise<T | undefined> {
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try to get from IndexedDB first
|
||||
let item = (await table.get(id)) as T | undefined;
|
||||
|
||||
// If not found or force sync, try to get from PocketBase
|
||||
if ((!item || forceSync) && !this.offlineMode) {
|
||||
try {
|
||||
const pbItem = await this.get.getOne<T>(collection, id);
|
||||
if (pbItem) {
|
||||
// For events, ensure we handle the files field properly
|
||||
if (collection === Collections.EVENTS) {
|
||||
// Ensure files array is properly handled
|
||||
if (!('files' in pbItem) || !Array.isArray((pbItem as any).files)) {
|
||||
(pbItem as any).files = [];
|
||||
}
|
||||
|
||||
// If we already have a local item with files, preserve them if server has none
|
||||
if (item && 'files' in item && Array.isArray((item as any).files) &&
|
||||
(item as any).files.length > 0 && !(pbItem as any).files.length) {
|
||||
console.log(`Preserving local files for event ${id}`);
|
||||
(pbItem as any).files = (item as any).files;
|
||||
}
|
||||
|
||||
await table.put(pbItem);
|
||||
item = pbItem;
|
||||
} else {
|
||||
await table.put(pbItem);
|
||||
item = pbItem;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${collection} item ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item and handle offline changes
|
||||
*/
|
||||
public async updateItem<T extends BaseRecord>(
|
||||
collection: string,
|
||||
id: string,
|
||||
data: Partial<T>,
|
||||
): Promise<T | undefined> {
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the current item
|
||||
const currentItem = (await table.get(id)) as T | undefined;
|
||||
if (!currentItem) {
|
||||
console.error(`Item ${id} not found in ${collection}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Special handling for files field in events
|
||||
if (collection === Collections.EVENTS && 'files' in data) {
|
||||
console.log(`Updating files for event ${id}`, (data as any).files);
|
||||
|
||||
// Ensure files is an array
|
||||
if (!Array.isArray((data as any).files)) {
|
||||
(data as any).files = [];
|
||||
}
|
||||
|
||||
// If we're updating files, make sure we're not losing any
|
||||
if ('files' in currentItem && Array.isArray((currentItem as any).files)) {
|
||||
// Merge files arrays, removing duplicates
|
||||
const existingFiles = (currentItem as any).files as string[];
|
||||
const newFiles = (data as any).files as string[];
|
||||
(data as any).files = [...new Set([...existingFiles, ...newFiles])];
|
||||
console.log(`Merged files for event ${id}`, (data as any).files);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the item in IndexedDB
|
||||
const updatedItem = {
|
||||
...currentItem,
|
||||
...data,
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
await table.put(updatedItem);
|
||||
|
||||
// If offline, record the change for later sync
|
||||
if (this.offlineMode) {
|
||||
await this.recordOfflineChange(collection, id, "update", data);
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
// If online, update in PocketBase
|
||||
try {
|
||||
const result = await this.update.updateFields(collection, id, data);
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
console.error(`Error updating ${collection} item ${id}:`, error);
|
||||
|
||||
// Record as offline change to retry later
|
||||
await this.recordOfflineChange(collection, id, "update", data);
|
||||
return updatedItem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
public async clearCache(): Promise<void> {
|
||||
await this.dexieService.clearAllData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is in offline mode
|
||||
*/
|
||||
public isOffline(): boolean {
|
||||
return this.offlineMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate Dexie table for a collection
|
||||
*/
|
||||
private getTableForCollection(
|
||||
collection: string,
|
||||
): Dexie.Table<any, string> | null {
|
||||
const db = this.dexieService.getDB();
|
||||
|
||||
switch (collection) {
|
||||
case Collections.USERS:
|
||||
return db.users;
|
||||
case Collections.EVENTS:
|
||||
return db.events;
|
||||
case Collections.EVENT_ATTENDEES:
|
||||
return db.eventAttendees;
|
||||
case Collections.EVENT_REQUESTS:
|
||||
return db.eventRequests;
|
||||
case Collections.LOGS:
|
||||
return db.logs;
|
||||
case Collections.OFFICERS:
|
||||
return db.officers;
|
||||
case Collections.REIMBURSEMENTS:
|
||||
return db.reimbursements;
|
||||
case Collections.RECEIPTS:
|
||||
return db.receipts;
|
||||
case Collections.SPONSORS:
|
||||
return db.sponsors;
|
||||
default:
|
||||
console.error(`Unknown collection: ${collection}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an event code in local storage for offline check-in
|
||||
* This is used when a user scans a QR code but is offline
|
||||
* @param eventCode The event code to store
|
||||
*/
|
||||
public async storeEventCode(eventCode: string): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
try {
|
||||
// Store in localStorage instead of IndexedDB for security
|
||||
localStorage.setItem('pending_event_code', eventCode);
|
||||
localStorage.setItem('pending_event_code_timestamp', Date.now().toString());
|
||||
console.log('Event code stored for offline check-in');
|
||||
} catch (error) {
|
||||
console.error('Error storing event code:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored event code from local storage
|
||||
*/
|
||||
public async clearEventCode(): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem('pending_event_code');
|
||||
localStorage.removeItem('pending_event_code_timestamp');
|
||||
console.log('Event code cleared');
|
||||
} catch (error) {
|
||||
console.error('Error clearing event code:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored event code from local storage
|
||||
* @returns The stored event code, or null if none exists
|
||||
*/
|
||||
public async getStoredEventCode(): Promise<{ code: string; timestamp: number } | null> {
|
||||
if (!isBrowser) return null;
|
||||
|
||||
try {
|
||||
const code = localStorage.getItem('pending_event_code');
|
||||
const timestamp = localStorage.getItem('pending_event_code_timestamp');
|
||||
|
||||
if (!code || !timestamp) return null;
|
||||
|
||||
return {
|
||||
code,
|
||||
timestamp: parseInt(timestamp)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting stored event code:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge event_code fields from events in IndexedDB for security
|
||||
* This should be called on login to ensure no event codes are stored
|
||||
*/
|
||||
public async purgeEventCodes(): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
try {
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(Collections.EVENTS);
|
||||
|
||||
if (!table) {
|
||||
console.error('Events table not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all events
|
||||
const events = await table.toArray();
|
||||
|
||||
// Remove event_code from each event
|
||||
const updatedEvents = events.map(event => {
|
||||
if ('event_code' in event) {
|
||||
const { event_code, ...rest } = event;
|
||||
return rest;
|
||||
}
|
||||
return event;
|
||||
});
|
||||
|
||||
// Clear the table and add the updated events
|
||||
await table.clear();
|
||||
await table.bulkAdd(updatedEvents);
|
||||
|
||||
console.log('Successfully purged event codes from IndexedDB');
|
||||
} catch (error) {
|
||||
console.error('Error purging event codes:', error);
|
||||
}
|
||||
}
|
||||
}
|
198
src/scripts/database/DexieService.ts
Normal file
198
src/scripts/database/DexieService.ts
Normal file
|
@ -0,0 +1,198 @@
|
|||
import Dexie from "dexie";
|
||||
import type {
|
||||
User,
|
||||
Event,
|
||||
EventRequest,
|
||||
Log,
|
||||
Officer,
|
||||
Reimbursement,
|
||||
Receipt,
|
||||
Sponsor,
|
||||
EventAttendee,
|
||||
} from "../../schemas/pocketbase/schema";
|
||||
|
||||
// Check if we're in a browser environment
|
||||
const isBrowser =
|
||||
typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
|
||||
|
||||
// Interface for tracking offline changes
|
||||
interface OfflineChange {
|
||||
id: string;
|
||||
collection: string;
|
||||
recordId: string;
|
||||
operation: "create" | "update" | "delete";
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
synced: boolean;
|
||||
syncAttempts: number;
|
||||
}
|
||||
|
||||
export class DashboardDatabase extends Dexie {
|
||||
users!: Dexie.Table<User, string>;
|
||||
events!: Dexie.Table<Event, string>;
|
||||
eventAttendees!: Dexie.Table<EventAttendee, string>;
|
||||
eventRequests!: Dexie.Table<EventRequest, string>;
|
||||
logs!: Dexie.Table<Log, string>;
|
||||
officers!: Dexie.Table<Officer, string>;
|
||||
reimbursements!: Dexie.Table<Reimbursement, string>;
|
||||
receipts!: Dexie.Table<Receipt, string>;
|
||||
sponsors!: Dexie.Table<Sponsor, string>;
|
||||
offlineChanges!: Dexie.Table<OfflineChange, string>;
|
||||
|
||||
// Store last sync timestamps
|
||||
syncInfo!: Dexie.Table<
|
||||
{ id: string; collection: string; lastSync: number },
|
||||
string
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
super("IEEEDashboardDB");
|
||||
|
||||
this.version(1).stores({
|
||||
users: "id, email, name",
|
||||
events: "id, event_name, event_code, start_date, end_date, published",
|
||||
eventRequests: "id, name, status, requested_user, created, updated",
|
||||
logs: "id, user, type, created",
|
||||
officers: "id, user, role, type",
|
||||
reimbursements: "id, title, status, submitted_by, created",
|
||||
receipts: "id, created_by, date",
|
||||
sponsors: "id, user, company",
|
||||
syncInfo: "id, collection, lastSync",
|
||||
});
|
||||
|
||||
// Add version 2 with offlineChanges table
|
||||
this.version(2).stores({
|
||||
offlineChanges:
|
||||
"id, collection, recordId, operation, timestamp, synced, syncAttempts",
|
||||
});
|
||||
|
||||
// Add version 3 with eventAttendees table and updated events table (no attendees field)
|
||||
this.version(3).stores({
|
||||
events: "id, event_name, event_code, start_date, end_date, published",
|
||||
eventAttendees: "id, user, event, time_checked_in",
|
||||
});
|
||||
|
||||
// Add version 4 with files field in events table
|
||||
this.version(4).stores({
|
||||
events: "id, event_name, event_code, start_date, end_date, published, files",
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the database with default values
|
||||
async initialize() {
|
||||
const collections = [
|
||||
"users",
|
||||
"events",
|
||||
"event_attendees",
|
||||
"event_request",
|
||||
"logs",
|
||||
"officers",
|
||||
"reimbursement",
|
||||
"receipts",
|
||||
"sponsors",
|
||||
];
|
||||
|
||||
for (const collection of collections) {
|
||||
const exists = await this.syncInfo.get(collection);
|
||||
if (!exists) {
|
||||
await this.syncInfo.put({
|
||||
id: collection,
|
||||
collection,
|
||||
lastSync: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock database for server-side rendering
|
||||
class MockDashboardDatabase {
|
||||
// Implement empty methods that won't fail during SSR
|
||||
async initialize() {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton pattern
|
||||
export class DexieService {
|
||||
private static instance: DexieService;
|
||||
private db: DashboardDatabase | MockDashboardDatabase;
|
||||
|
||||
private constructor() {
|
||||
if (isBrowser) {
|
||||
// Only initialize Dexie in browser environments
|
||||
this.db = new DashboardDatabase();
|
||||
this.db.initialize();
|
||||
} else {
|
||||
// Use a mock database in non-browser environments
|
||||
console.log("Running in Node.js environment, using mock database");
|
||||
this.db = new MockDashboardDatabase() as any;
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DexieService {
|
||||
if (!DexieService.instance) {
|
||||
DexieService.instance = new DexieService();
|
||||
}
|
||||
return DexieService.instance;
|
||||
}
|
||||
|
||||
// Get the database instance
|
||||
public getDB(): DashboardDatabase {
|
||||
if (!isBrowser) {
|
||||
console.warn(
|
||||
"Attempting to access IndexedDB in a non-browser environment",
|
||||
);
|
||||
}
|
||||
return this.db as DashboardDatabase;
|
||||
}
|
||||
|
||||
// Update the last sync timestamp for a collection
|
||||
public async updateLastSync(collection: string): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
await (this.db as DashboardDatabase).syncInfo.update(collection, {
|
||||
lastSync: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get the last sync timestamp for a collection
|
||||
public async getLastSync(collection: string): Promise<number> {
|
||||
if (!isBrowser) return 0;
|
||||
const info = await (this.db as DashboardDatabase).syncInfo.get(collection);
|
||||
return info?.lastSync || 0;
|
||||
}
|
||||
|
||||
// Clear all data (useful for logout)
|
||||
public async clearAllData(): Promise<void> {
|
||||
if (!isBrowser) return;
|
||||
|
||||
const db = this.db as DashboardDatabase;
|
||||
await db.users.clear();
|
||||
await db.events.clear();
|
||||
await db.eventAttendees.clear();
|
||||
await db.eventRequests.clear();
|
||||
await db.logs.clear();
|
||||
await db.officers.clear();
|
||||
await db.reimbursements.clear();
|
||||
await db.receipts.clear();
|
||||
await db.sponsors.clear();
|
||||
await db.offlineChanges.clear();
|
||||
|
||||
// Reset sync timestamps
|
||||
const collections = [
|
||||
"users",
|
||||
"events",
|
||||
"event_attendees",
|
||||
"event_request",
|
||||
"logs",
|
||||
"officers",
|
||||
"reimbursement",
|
||||
"receipts",
|
||||
"sponsors",
|
||||
];
|
||||
|
||||
for (const collection of collections) {
|
||||
await db.syncInfo.update(collection, { lastSync: 0 });
|
||||
}
|
||||
}
|
||||
}
|
35
src/scripts/database/initAuthSync.ts
Normal file
35
src/scripts/database/initAuthSync.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Authentication } from '../pocketbase/Authentication';
|
||||
|
||||
/**
|
||||
* Initialize authentication synchronization
|
||||
* This function should be called when the application starts
|
||||
* to ensure proper data synchronization during authentication flows
|
||||
*/
|
||||
export async function initAuthSync(): Promise<void> {
|
||||
try {
|
||||
// Get Authentication instance
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// This will trigger the lazy loading of AuthSyncService
|
||||
// through the onAuthStateChange mechanism
|
||||
auth.onAuthStateChange(() => {
|
||||
console.log('Auth sync initialized and listening for auth state changes');
|
||||
});
|
||||
|
||||
console.log('Auth sync initialization complete');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export a function to manually trigger a full sync
|
||||
export async function forceFullSync(): Promise<boolean> {
|
||||
try {
|
||||
const { AuthSyncService } = await import('./AuthSyncService');
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
return await authSync.forceSyncAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to force full sync:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
195
src/scripts/pocketbase/Authentication.ts
Normal file
195
src/scripts/pocketbase/Authentication.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
import PocketBase from "pocketbase";
|
||||
import yaml from "js-yaml";
|
||||
import configYaml from "../../config/pocketbaseConfig.yml?raw";
|
||||
|
||||
// Configuration type definitions
|
||||
interface Config {
|
||||
api: {
|
||||
baseUrl: string;
|
||||
oauth2: {
|
||||
redirectPath: string;
|
||||
providerName: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Parse YAML configuration
|
||||
const config = yaml.load(configYaml) as Config;
|
||||
|
||||
export class Authentication {
|
||||
private pb: PocketBase;
|
||||
private static instance: Authentication;
|
||||
private authChangeCallbacks: ((isValid: boolean) => void)[] = [];
|
||||
private isUpdating: boolean = false;
|
||||
private authSyncServiceInitialized: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
// Use the baseUrl from the config file
|
||||
this.pb = new PocketBase(config.api.baseUrl);
|
||||
|
||||
// Configure PocketBase client
|
||||
this.pb.autoCancellation(false); // Disable auto-cancellation globally
|
||||
|
||||
// Listen for auth state changes
|
||||
this.pb.authStore.onChange(() => {
|
||||
if (!this.isUpdating) {
|
||||
this.notifyAuthChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of Authentication
|
||||
*/
|
||||
public static getInstance(): Authentication {
|
||||
if (!Authentication.instance) {
|
||||
Authentication.instance = new Authentication();
|
||||
}
|
||||
return Authentication.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PocketBase instance
|
||||
*/
|
||||
public getPocketBase(): PocketBase {
|
||||
return this.pb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user login through OAuth2
|
||||
*/
|
||||
public async login(): Promise<void> {
|
||||
try {
|
||||
const authMethods = await this.pb.collection("users").listAuthMethods();
|
||||
const oidcProvider = authMethods.oauth2?.providers?.find(
|
||||
(p: { name: string }) => p.name === config.api.oauth2.providerName,
|
||||
);
|
||||
|
||||
if (!oidcProvider) {
|
||||
throw new Error("OIDC provider not found");
|
||||
}
|
||||
|
||||
localStorage.setItem("provider", JSON.stringify(oidcProvider));
|
||||
const redirectUrl =
|
||||
window.location.origin + config.api.oauth2.redirectPath;
|
||||
const authUrl = oidcProvider.authURL + encodeURIComponent(redirectUrl);
|
||||
window.location.href = authUrl;
|
||||
} catch (err) {
|
||||
console.error("Authentication error:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user logout
|
||||
*/
|
||||
public async logout(): Promise<void> {
|
||||
try {
|
||||
// Initialize AuthSyncService if needed (lazy loading)
|
||||
await this.initAuthSyncService();
|
||||
|
||||
// Get AuthSyncService instance
|
||||
const { AuthSyncService } = await import('../database/AuthSyncService');
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
|
||||
// Handle data cleanup before actual logout
|
||||
await authSync.handleLogout();
|
||||
|
||||
// Clear auth store
|
||||
this.pb.authStore.clear();
|
||||
|
||||
console.log('Logout completed successfully with data cleanup');
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Fallback to basic logout if sync fails
|
||||
this.pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently authenticated
|
||||
*/
|
||||
public isAuthenticated(): boolean {
|
||||
return this.pb.authStore.isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user model
|
||||
*/
|
||||
public getCurrentUser(): any {
|
||||
return this.pb.authStore.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID
|
||||
*/
|
||||
public getUserId(): string | null {
|
||||
return this.pb.authStore.model?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to auth state changes
|
||||
* @param callback Function to call when auth state changes
|
||||
*/
|
||||
public onAuthStateChange(callback: (isValid: boolean) => void): void {
|
||||
this.authChangeCallbacks.push(callback);
|
||||
|
||||
// Initialize AuthSyncService when first callback is registered
|
||||
if (!this.authSyncServiceInitialized && this.authChangeCallbacks.length === 1) {
|
||||
this.initAuthSyncService();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove auth state change subscription
|
||||
* @param callback Function to remove from subscribers
|
||||
*/
|
||||
public offAuthStateChange(callback: (isValid: boolean) => void): void {
|
||||
this.authChangeCallbacks = this.authChangeCallbacks.filter(
|
||||
(cb) => cb !== callback,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set updating state to prevent auth change notifications during updates
|
||||
*/
|
||||
public setUpdating(updating: boolean): void {
|
||||
this.isUpdating = updating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers of auth state change
|
||||
*/
|
||||
private notifyAuthChange(): void {
|
||||
const isValid = this.pb.authStore.isValid;
|
||||
this.authChangeCallbacks.forEach((callback) => callback(isValid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the AuthSyncService (lazy loading)
|
||||
*/
|
||||
private async initAuthSyncService(): Promise<void> {
|
||||
if (this.authSyncServiceInitialized) return;
|
||||
|
||||
try {
|
||||
// Dynamically import AuthSyncService to avoid circular dependencies
|
||||
const { AuthSyncService } = await import('../database/AuthSyncService');
|
||||
|
||||
// Initialize the service
|
||||
AuthSyncService.getInstance();
|
||||
|
||||
this.authSyncServiceInitialized = true;
|
||||
console.log('AuthSyncService initialized successfully');
|
||||
|
||||
// If user is already authenticated, trigger initial sync
|
||||
if (this.isAuthenticated()) {
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
authSync.handleLogin().catch(err => {
|
||||
console.error('Error during initial data sync:', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize AuthSyncService:', error);
|
||||
}
|
||||
}
|
||||
}
|
756
src/scripts/pocketbase/FileManager.ts
Normal file
756
src/scripts/pocketbase/FileManager.ts
Normal file
|
@ -0,0 +1,756 @@
|
|||
import { Authentication } from "./Authentication";
|
||||
|
||||
export class FileManager {
|
||||
private auth: Authentication;
|
||||
private static instance: FileManager;
|
||||
private static UNSUPPORTED_EXTENSIONS = ['afdesign', 'psd', 'ai', 'sketch'];
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of FileManager
|
||||
*/
|
||||
public static getInstance(): FileManager {
|
||||
if (!FileManager.instance) {
|
||||
FileManager.instance = new FileManager();
|
||||
}
|
||||
return FileManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a file type is supported
|
||||
* @param file The file to validate
|
||||
* @returns Object with validation result and reason if invalid
|
||||
*/
|
||||
public validateFileType(file: File): { valid: boolean; reason?: string } {
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (fileExtension && FileManager.UNSUPPORTED_EXTENSIONS.includes(fileExtension)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `File type .${fileExtension} is not supported. Please convert to PDF or image format.`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to a record
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to attach the file to
|
||||
* @param field The field name for the file
|
||||
* @param file The file to upload
|
||||
* @param append Whether to append the file to existing files (default: false)
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async uploadFile<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
file: File,
|
||||
append: boolean = false
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to upload files");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Validate file size
|
||||
const maxSize = 200 * 1024 * 1024; // 200MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`File size ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds 200MB limit`);
|
||||
}
|
||||
|
||||
// Check for potentially problematic file types
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
// Validate file type
|
||||
const validation = this.validateFileType(file);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.reason);
|
||||
}
|
||||
|
||||
// Log upload attempt
|
||||
console.log('Attempting file upload:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
extension: fileExtension,
|
||||
collection: collectionName,
|
||||
recordId: recordId,
|
||||
field: field,
|
||||
append: append
|
||||
});
|
||||
|
||||
// Create FormData for the upload
|
||||
const formData = new FormData();
|
||||
|
||||
// Use the + prefix for the field name if append is true
|
||||
const fieldName = append ? `${field}+` : field;
|
||||
|
||||
// Get existing record to preserve existing files
|
||||
let existingRecord: any = null;
|
||||
let existingFiles: string[] = [];
|
||||
|
||||
try {
|
||||
if (recordId) {
|
||||
existingRecord = await pb.collection(collectionName).getOne(recordId);
|
||||
existingFiles = existingRecord[field] || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch existing record:', error);
|
||||
}
|
||||
|
||||
// Check if the file already exists
|
||||
const fileExists = existingFiles.some(existingFile =>
|
||||
existingFile.toLowerCase() === file.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (fileExists) {
|
||||
console.warn(`File with name ${file.name} already exists. Renaming to avoid conflicts.`);
|
||||
const timestamp = new Date().getTime();
|
||||
const nameParts = file.name.split('.');
|
||||
const extension = nameParts.pop();
|
||||
const baseName = nameParts.join('.');
|
||||
const newFileName = `${baseName}_${timestamp}.${extension}`;
|
||||
|
||||
// Create a new file with the modified name
|
||||
const newFile = new File([file], newFileName, { type: file.type });
|
||||
formData.append(fieldName, newFile);
|
||||
} else {
|
||||
formData.append(fieldName, file);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pb.collection(collectionName).update<T>(recordId, formData);
|
||||
console.log('Upload successful:', {
|
||||
result,
|
||||
fileInfo: {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
},
|
||||
collection: collectionName,
|
||||
recordId: recordId
|
||||
});
|
||||
|
||||
// Verify the file was actually added to the record
|
||||
try {
|
||||
const updatedRecord = await pb.collection(collectionName).getOne(recordId);
|
||||
console.log('Updated record files:', {
|
||||
files: updatedRecord.files,
|
||||
recordId: recordId
|
||||
});
|
||||
} catch (verifyError) {
|
||||
console.warn('Could not verify file upload:', verifyError);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (pbError: any) {
|
||||
// Log detailed PocketBase error
|
||||
console.error('PocketBase upload error:', {
|
||||
status: pbError?.status,
|
||||
response: pbError?.response,
|
||||
data: pbError?.data,
|
||||
message: pbError?.message
|
||||
});
|
||||
|
||||
// More specific error message based on file type
|
||||
if (fileExtension && FileManager.UNSUPPORTED_EXTENSIONS.includes(fileExtension)) {
|
||||
throw new Error(`Upload failed: File type .${fileExtension} is not supported. Please convert to PDF or image format.`);
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed: ${pbError?.message || 'Unknown PocketBase error'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload file to ${collectionName}:`, {
|
||||
error: err,
|
||||
fileInfo: {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
},
|
||||
auth: {
|
||||
isAuthenticated: this.auth.isAuthenticated(),
|
||||
userId: this.auth.getUserId()
|
||||
}
|
||||
});
|
||||
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to a record with chunked upload support
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to attach the files to
|
||||
* @param field The field name for the files
|
||||
* @param files Array of files to upload
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async uploadFiles<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
files: File[],
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to upload files");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit
|
||||
const MAX_BATCH_SIZE = 25 * 1024 * 1024; // 25MB per batch
|
||||
|
||||
// Validate file types and sizes first
|
||||
for (const file of files) {
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error(
|
||||
`File ${file.name} is too large. Maximum size is 50MB.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validation = this.validateFileType(file);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`File ${file.name}: ${validation.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing record if updating
|
||||
let existingFiles: string[] = [];
|
||||
if (recordId) {
|
||||
try {
|
||||
const record = await pb
|
||||
.collection(collectionName)
|
||||
.getOne<T>(recordId);
|
||||
existingFiles = (record as any)[field] || [];
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch existing record:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Process files in batches
|
||||
let currentBatchSize = 0;
|
||||
let currentBatch: File[] = [];
|
||||
let allProcessedFiles: File[] = [];
|
||||
|
||||
// Process each file
|
||||
for (const file of files) {
|
||||
let processedFile = file;
|
||||
|
||||
try {
|
||||
// Try to compress image files if needed
|
||||
if (file.type.startsWith("image/")) {
|
||||
processedFile = await this.compressImageIfNeeded(file, 50); // 50MB max size
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to process file ${file.name}:`, error);
|
||||
processedFile = file; // Use original file if processing fails
|
||||
}
|
||||
|
||||
// Check if adding this file would exceed batch size
|
||||
if (currentBatchSize + processedFile.size > MAX_BATCH_SIZE) {
|
||||
// Upload current batch
|
||||
if (currentBatch.length > 0) {
|
||||
await this.uploadBatch(
|
||||
collectionName,
|
||||
recordId,
|
||||
field,
|
||||
currentBatch,
|
||||
);
|
||||
allProcessedFiles.push(...currentBatch);
|
||||
}
|
||||
// Reset batch
|
||||
currentBatch = [processedFile];
|
||||
currentBatchSize = processedFile.size;
|
||||
} else {
|
||||
// Add to current batch
|
||||
currentBatch.push(processedFile);
|
||||
currentBatchSize += processedFile.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload any remaining files
|
||||
if (currentBatch.length > 0) {
|
||||
await this.uploadBatch(collectionName, recordId, field, currentBatch);
|
||||
allProcessedFiles.push(...currentBatch);
|
||||
}
|
||||
|
||||
// Get the final record state
|
||||
const finalRecord = await pb
|
||||
.collection(collectionName)
|
||||
.getOne<T>(recordId);
|
||||
return finalRecord;
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload files to ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a batch of files
|
||||
* @private
|
||||
*/
|
||||
private async uploadBatch<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
files: File[],
|
||||
): Promise<void> {
|
||||
const pb = this.auth.getPocketBase();
|
||||
const formData = new FormData();
|
||||
|
||||
// Get existing files to check for duplicates
|
||||
let existingFiles: string[] = [];
|
||||
try {
|
||||
const record = await pb.collection(collectionName).getOne(recordId);
|
||||
existingFiles = record[field] || [];
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch existing record for duplicate check:", error);
|
||||
}
|
||||
|
||||
// Add new files, renaming duplicates if needed
|
||||
for (const file of files) {
|
||||
let fileToUpload = file;
|
||||
|
||||
// Check if filename already exists
|
||||
if (Array.isArray(existingFiles) && existingFiles.includes(file.name)) {
|
||||
const timestamp = new Date().getTime();
|
||||
const nameParts = file.name.split('.');
|
||||
const extension = nameParts.pop();
|
||||
const baseName = nameParts.join('.');
|
||||
const newFileName = `${baseName}_${timestamp}.${extension}`;
|
||||
|
||||
// Create a new file with the modified name
|
||||
fileToUpload = new File([file], newFileName, { type: file.type });
|
||||
|
||||
console.log(`Renamed duplicate file from ${file.name} to ${newFileName}`);
|
||||
}
|
||||
|
||||
formData.append(field, fileToUpload);
|
||||
}
|
||||
|
||||
// Tell PocketBase to keep existing files
|
||||
if (existingFiles.length > 0) {
|
||||
formData.append(`${field}@`, ''); // This tells PocketBase to keep existing files
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection(collectionName).update(recordId, formData);
|
||||
} catch (error: any) {
|
||||
if (error.status === 413) {
|
||||
throw new Error(
|
||||
`Upload failed: Batch size too large. Please try uploading smaller files.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append multiple files to a record without overriding existing ones
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to attach the files to
|
||||
* @param field The field name for the files
|
||||
* @param files Array of files to upload
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async appendFiles<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
files: File[],
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to upload files");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// First, get the current record to check existing files
|
||||
const record = await pb.collection(collectionName).getOne<T>(recordId);
|
||||
|
||||
// Get existing files from the record
|
||||
const existingFiles = (record as any)[field] || [];
|
||||
const existingFilenames = new Set(existingFiles);
|
||||
|
||||
// Create FormData for the new files only
|
||||
const formData = new FormData();
|
||||
|
||||
// Tell PocketBase to keep existing files
|
||||
formData.append(`${field}@`, '');
|
||||
|
||||
// Append new files, renaming if needed to avoid duplicates
|
||||
for (const file of files) {
|
||||
let fileToUpload = file;
|
||||
|
||||
// Check if filename already exists
|
||||
if (existingFilenames.has(file.name)) {
|
||||
const timestamp = new Date().getTime();
|
||||
const nameParts = file.name.split('.');
|
||||
const extension = nameParts.pop();
|
||||
const baseName = nameParts.join('.');
|
||||
const newFileName = `${baseName}_${timestamp}.${extension}`;
|
||||
|
||||
// Create a new file with the modified name
|
||||
fileToUpload = new File([file], newFileName, { type: file.type });
|
||||
|
||||
console.log(`Renamed duplicate file from ${file.name} to ${newFileName}`);
|
||||
}
|
||||
|
||||
formData.append(field, fileToUpload);
|
||||
}
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.update<T>(recordId, formData);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to append files to ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for a file
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record containing the file
|
||||
* @param filename The name of the file
|
||||
* @returns The URL to access the file
|
||||
*/
|
||||
public getFileUrl(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
filename: string,
|
||||
): string {
|
||||
const pb = this.auth.getPocketBase();
|
||||
return pb.files.getURL(
|
||||
{ id: recordId, collectionId: collectionName },
|
||||
filename,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from a record
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record containing the file
|
||||
* @param field The field name of the file to delete
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async deleteFile<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to delete files");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const data = { [field]: null };
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.update<T>(recordId, data);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete file from ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record containing the file
|
||||
* @param filename The name of the file
|
||||
* @returns The file blob
|
||||
*/
|
||||
public async downloadFile(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
filename: string,
|
||||
): Promise<Blob> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to download files");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const url = this.getFileUrl(collectionName, recordId, filename);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.blob();
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to download file from ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple files from a record
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record containing the files
|
||||
* @param field The field name containing the files
|
||||
* @returns Array of file URLs
|
||||
*/
|
||||
public async getFiles(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
): Promise<string[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to get files");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Get the record to retrieve the filenames
|
||||
const record = await pb.collection(collectionName).getOne(recordId);
|
||||
|
||||
// Get the filenames from the specified field
|
||||
const filenames = record[field] || [];
|
||||
|
||||
// Convert filenames to URLs
|
||||
const fileUrls = filenames.map((filename: string) =>
|
||||
this.getFileUrl(collectionName, recordId, filename),
|
||||
);
|
||||
|
||||
return fileUrls;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get files from ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an image file if it's too large
|
||||
* @param file The image file to compress
|
||||
* @param maxSizeInMB Maximum size in MB
|
||||
* @returns Promise<File> The compressed file
|
||||
*/
|
||||
public async compressImageIfNeeded(
|
||||
file: File,
|
||||
maxSizeInMB: number = 50,
|
||||
): Promise<File> {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
||||
if (file.size <= maxSizeInBytes) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.src = e.target?.result as string;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
// Calculate new dimensions while maintaining aspect ratio
|
||||
const maxDimension = 3840; // Higher quality for larger files
|
||||
if (width > height && width > maxDimension) {
|
||||
height *= maxDimension / width;
|
||||
width = maxDimension;
|
||||
} else if (height > maxDimension) {
|
||||
width *= maxDimension / height;
|
||||
height = maxDimension;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to blob with higher quality for larger files
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to compress image"));
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
new File([blob], file.name, {
|
||||
type: "image/jpeg",
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
"image/jpeg",
|
||||
0.85, // Higher quality setting for larger files
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error("Failed to load image for compression"));
|
||||
};
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Failed to read file for compression"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file token for accessing protected files
|
||||
* @returns Promise<string> The file token
|
||||
*/
|
||||
public async getFileToken(): Promise<string> {
|
||||
// Check authentication status
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.warn("User is not authenticated when trying to get file token");
|
||||
|
||||
// Try to refresh the auth if possible
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
if (pb.authStore.isValid) {
|
||||
console.log(
|
||||
"Auth store is valid, but auth check failed. Trying to refresh token.",
|
||||
);
|
||||
await pb.collection("users").authRefresh();
|
||||
console.log("Auth refreshed successfully");
|
||||
} else {
|
||||
throw new Error("User must be authenticated to get a file token");
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("Failed to refresh authentication:", refreshError);
|
||||
throw new Error("User must be authenticated to get a file token");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Log auth status
|
||||
console.log("Auth status before getting token:", {
|
||||
isValid: pb.authStore.isValid,
|
||||
token: pb.authStore.token
|
||||
? pb.authStore.token.substring(0, 10) + "..."
|
||||
: "none",
|
||||
model: pb.authStore.model ? pb.authStore.model.id : "none",
|
||||
});
|
||||
|
||||
const result = await pb.files.getToken();
|
||||
console.log("Got file token:", result.substring(0, 10) + "...");
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Failed to get file token:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file URL with an optional token for protected files
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record containing the file
|
||||
* @param filename The name of the file
|
||||
* @param useToken Whether to include a token for protected files
|
||||
* @returns Promise<string> The file URL with token if requested
|
||||
*/
|
||||
public async getFileUrlWithToken(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
filename: string,
|
||||
useToken: boolean = false,
|
||||
): Promise<string> {
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Check if filename is empty
|
||||
if (!filename) {
|
||||
console.error(
|
||||
`Empty filename provided for ${collectionName}/${recordId}`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.warn("User is not authenticated when trying to get file URL");
|
||||
}
|
||||
|
||||
// Always try to use token for protected files
|
||||
if (useToken) {
|
||||
try {
|
||||
console.log(
|
||||
`Getting file token for ${collectionName}/${recordId}/${filename}`,
|
||||
);
|
||||
const token = await this.getFileToken();
|
||||
console.log(`Got token: ${token.substring(0, 10)}...`);
|
||||
|
||||
// Make sure to pass the token as a query parameter
|
||||
const url = pb.files.getURL(
|
||||
{ id: recordId, collectionId: collectionName },
|
||||
filename,
|
||||
{ token },
|
||||
);
|
||||
console.log(`Generated URL with token: ${url.substring(0, 50)}...`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error("Error getting file token:", error);
|
||||
// Fall back to URL without token
|
||||
const url = pb.files.getURL(
|
||||
{ id: recordId, collectionId: collectionName },
|
||||
filename,
|
||||
);
|
||||
console.log(`Fallback URL without token: ${url.substring(0, 50)}...`);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// If not using token
|
||||
const url = pb.files.getURL(
|
||||
{ id: recordId, collectionId: collectionName },
|
||||
filename,
|
||||
);
|
||||
console.log(`Generated URL without token: ${url.substring(0, 50)}...`);
|
||||
return url;
|
||||
}
|
||||
}
|
369
src/scripts/pocketbase/Get.ts
Normal file
369
src/scripts/pocketbase/Get.ts
Normal file
|
@ -0,0 +1,369 @@
|
|||
import { Authentication } from "./Authentication";
|
||||
|
||||
// Base interface for PocketBase records
|
||||
interface BaseRecord {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Interface for request options
|
||||
interface RequestOptions {
|
||||
fields?: string[];
|
||||
disableAutoCancellation?: boolean;
|
||||
expand?: string[] | string;
|
||||
}
|
||||
|
||||
// Utility function to check if a value is a UTC date string
|
||||
function isUTCDateString(value: any): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
return isoDateRegex.test(value);
|
||||
}
|
||||
|
||||
// Utility function to format a date to local ISO-like string
|
||||
function formatLocalDate(date: Date, includeSeconds: boolean = true): string {
|
||||
const pad = (num: number) => num.toString().padStart(2, "0");
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
|
||||
// Format for datetime-local input (YYYY-MM-DDThh:mm)
|
||||
if (!includeSeconds) {
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
const seconds = pad(date.getSeconds());
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Utility function to convert UTC date strings to local time
|
||||
function convertUTCToLocal<T>(data: T): T {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
const converted = { ...data };
|
||||
for (const [key, value] of Object.entries(converted)) {
|
||||
// Special handling for event date fields
|
||||
if (
|
||||
(key === "start_date" ||
|
||||
key === "end_date" ||
|
||||
key === "time_checked_in") &&
|
||||
isUTCDateString(value)
|
||||
) {
|
||||
// Convert UTC date string to local date string
|
||||
const date = new Date(value);
|
||||
(converted as any)[key] = formatLocalDate(date, false);
|
||||
} else if (isUTCDateString(value)) {
|
||||
// Convert UTC date string to local date string
|
||||
const date = new Date(value);
|
||||
(converted as any)[key] = formatLocalDate(date);
|
||||
} else if (Array.isArray(value)) {
|
||||
(converted as any)[key] = value.map((item) => convertUTCToLocal(item));
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
(converted as any)[key] = convertUTCToLocal(value);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
export class Get {
|
||||
private auth: Authentication;
|
||||
private static instance: Get;
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of Get
|
||||
*/
|
||||
public static getInstance(): Get {
|
||||
if (!Get.instance) {
|
||||
Get.instance = new Get();
|
||||
}
|
||||
return Get.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UTC date strings to local time
|
||||
* @param data The data to convert
|
||||
* @returns The converted data
|
||||
*/
|
||||
public static convertUTCToLocal<T>(data: T): T {
|
||||
return convertUTCToLocal(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a UTC date string
|
||||
* @param value The value to check
|
||||
* @returns True if the value is a UTC date string
|
||||
*/
|
||||
public static isUTCDateString(value: any): boolean {
|
||||
return isUTCDateString(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to local ISO-like string
|
||||
* @param date The date to format
|
||||
* @param includeSeconds Whether to include seconds in the formatted string
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
public static formatLocalDate(
|
||||
date: Date,
|
||||
includeSeconds: boolean = true,
|
||||
): string {
|
||||
return formatLocalDate(date, includeSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single record by ID
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to retrieve
|
||||
* @param options Optional request options including fields to select and auto-cancellation control
|
||||
* @returns The requested record
|
||||
*/
|
||||
public async getOne<T extends BaseRecord>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.warn(
|
||||
`User not authenticated, but attempting to get record from ${collectionName} anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Handle expand parameter
|
||||
let expandString: string | undefined;
|
||||
if (options?.expand) {
|
||||
if (Array.isArray(options.expand)) {
|
||||
expandString = options.expand.join(",");
|
||||
} else if (typeof options.expand === "string") {
|
||||
expandString = options.expand;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(expandString && { expand: expandString }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getOne<T>(recordId, requestOptions);
|
||||
return convertUTCToLocal(result);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to get record ${recordId} from ${collectionName}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple records by their IDs
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordIds Array of record IDs to retrieve
|
||||
* @param options Optional request options including fields to select and auto-cancellation control
|
||||
* @returns Array of requested records
|
||||
*/
|
||||
public async getMany<T extends BaseRecord>(
|
||||
collectionName: string,
|
||||
recordIds: string[],
|
||||
options?: RequestOptions,
|
||||
): Promise<T[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.warn(
|
||||
`User not authenticated, but attempting to get records from ${collectionName} anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build filter for multiple IDs
|
||||
const filter = recordIds.map((id) => `id="${id}"`).join(" || ");
|
||||
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Handle expand parameter
|
||||
let expandString: string | undefined;
|
||||
if (options?.expand) {
|
||||
if (Array.isArray(options.expand)) {
|
||||
expandString = options.expand.join(",");
|
||||
} else if (typeof options.expand === "string") {
|
||||
expandString = options.expand;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
filter,
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(expandString && { expand: expandString }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getList<T>(1, recordIds.length, requestOptions);
|
||||
return result.items.map((item) => convertUTCToLocal(item));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to get records ${recordIds.join(", ")} from ${collectionName}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records with pagination
|
||||
* @param collectionName The name of the collection
|
||||
* @param page Page number (1-based)
|
||||
* @param perPage Number of items per page
|
||||
* @param filter Optional filter string
|
||||
* @param sort Optional sort string
|
||||
* @param options Optional request options including fields to select and auto-cancellation control
|
||||
* @returns Paginated list of records
|
||||
*/
|
||||
public async getList<T extends BaseRecord>(
|
||||
collectionName: string,
|
||||
page: number = 1,
|
||||
perPage: number = 20,
|
||||
filter?: string,
|
||||
sort?: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<{
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
items: T[];
|
||||
}> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to retrieve records");
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
const requestOptions = {
|
||||
...(filter && { filter }),
|
||||
...(sort && { sort }),
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getList<T>(page, perPage, requestOptions);
|
||||
|
||||
return {
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
items: result.items.map((item) => convertUTCToLocal(item)),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to get list from ${collectionName}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all records from a collection
|
||||
* @param collectionName The name of the collection
|
||||
* @param filter Optional filter string
|
||||
* @param sort Optional sort string
|
||||
* @param options Optional request options including fields to select and auto-cancellation control
|
||||
* @returns Array of all matching records
|
||||
*/
|
||||
public async getAll<T extends BaseRecord>(
|
||||
collectionName: string,
|
||||
filter?: string,
|
||||
sort?: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<T[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.warn(
|
||||
`User not authenticated, but attempting to get records from ${collectionName} anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get records even if authentication check fails
|
||||
// This is a workaround for cases where isAuthenticated() returns false
|
||||
// but the token is still valid for API requests
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Handle expand parameter
|
||||
let expandString: string | undefined;
|
||||
if (options?.expand) {
|
||||
if (Array.isArray(options.expand)) {
|
||||
expandString = options.expand.join(",");
|
||||
} else if (typeof options.expand === "string") {
|
||||
expandString = options.expand;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
...(filter && { filter }),
|
||||
...(sort && { sort }),
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(expandString && { expand: expandString }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getFullList<T>(requestOptions);
|
||||
return result.map((item) => convertUTCToLocal(item));
|
||||
} catch (err) {
|
||||
console.error(`Failed to get all records from ${collectionName}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first record that matches a filter
|
||||
* @param collectionName The name of the collection
|
||||
* @param filter Filter string
|
||||
* @param options Optional request options including fields to select and auto-cancellation control
|
||||
* @returns The first matching record or null if none found
|
||||
*/
|
||||
public async getFirst<T extends BaseRecord>(
|
||||
collectionName: string,
|
||||
filter: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<T | null> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to retrieve records");
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
const requestOptions = {
|
||||
filter,
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
sort: "created",
|
||||
perPage: 1,
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getList<T>(1, 1, requestOptions);
|
||||
return result.items.length > 0
|
||||
? convertUTCToLocal(result.items[0])
|
||||
: null;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get first record from ${collectionName}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
191
src/scripts/pocketbase/SendLog.ts
Normal file
191
src/scripts/pocketbase/SendLog.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
import { Authentication } from "./Authentication";
|
||||
import { Collections } from "../../schemas/pocketbase";
|
||||
import type { Log } from "../../schemas/pocketbase";
|
||||
|
||||
// Log data interface for creating new logs
|
||||
interface LogData {
|
||||
user: string; // Relation to User
|
||||
type: string; // Standard types: "error", "update", "delete", "create", "login", "logout"
|
||||
part: string; // The specific part/section being logged
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class SendLog {
|
||||
private auth: Authentication;
|
||||
private static instance: SendLog;
|
||||
private readonly COLLECTION_NAME = Collections.LOGS;
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of SendLog
|
||||
*/
|
||||
public static getInstance(): SendLog {
|
||||
if (!SendLog.instance) {
|
||||
SendLog.instance = new SendLog();
|
||||
}
|
||||
return SendLog.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current authenticated user's ID
|
||||
* @returns The user ID or null if not authenticated
|
||||
*/
|
||||
private getCurrentUserId(): string | null {
|
||||
const user = this.auth.getCurrentUser();
|
||||
if (!user) {
|
||||
console.debug("SendLog: No current user found");
|
||||
return null;
|
||||
}
|
||||
console.debug("SendLog: Current user ID:", user.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a log entry to PocketBase
|
||||
* @param type The type of log entry
|
||||
* @param part The specific part/section being logged
|
||||
* @param message The log message
|
||||
* @param overrideUserId Optional user ID to override the current user
|
||||
* @returns Promise that resolves when the log is created
|
||||
*/
|
||||
public async send(
|
||||
type: string,
|
||||
part: string,
|
||||
message: string,
|
||||
overrideUserId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check authentication first
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.error("SendLog: User not authenticated");
|
||||
throw new Error("User must be authenticated to create logs");
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
const userId = overrideUserId || this.getCurrentUserId();
|
||||
if (!userId) {
|
||||
console.error("SendLog: No user ID available");
|
||||
throw new Error(
|
||||
"No user ID available. User must be authenticated to create logs.",
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare log data
|
||||
const logData: LogData = {
|
||||
user: userId,
|
||||
type,
|
||||
part,
|
||||
message,
|
||||
};
|
||||
|
||||
console.debug("SendLog: Preparing to send log:", {
|
||||
collection: this.COLLECTION_NAME,
|
||||
data: logData,
|
||||
authValid: this.auth.isAuthenticated(),
|
||||
userId,
|
||||
});
|
||||
|
||||
// Get PocketBase instance
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Create the log entry
|
||||
await pb.collection(this.COLLECTION_NAME).create(logData);
|
||||
|
||||
console.debug("SendLog: Log created successfully");
|
||||
} catch (error) {
|
||||
// Enhanced error logging
|
||||
if (error instanceof Error) {
|
||||
console.error("SendLog: Failed to send log:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
type,
|
||||
part,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
console.error("SendLog: Unknown error:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a specific user
|
||||
* @param userId The ID of the user to get logs for
|
||||
* @param type Optional log type to filter by
|
||||
* @param part Optional part/section to filter by
|
||||
* @returns Array of log entries
|
||||
*/
|
||||
public async getUserLogs(
|
||||
userId: string,
|
||||
type?: string,
|
||||
part?: string,
|
||||
): Promise<Log[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to retrieve logs");
|
||||
}
|
||||
|
||||
try {
|
||||
let filter = `user = "${userId}"`;
|
||||
if (type) filter += ` && type = "${type}"`;
|
||||
if (part) filter += ` && part = "${part}"`;
|
||||
|
||||
const result = await this.auth
|
||||
.getPocketBase()
|
||||
.collection(this.COLLECTION_NAME)
|
||||
.getFullList<Log>({
|
||||
filter,
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("SendLog: Failed to get user logs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs for the current user
|
||||
* @param limit Maximum number of logs to retrieve
|
||||
* @param type Optional log type to filter by
|
||||
* @param part Optional part/section to filter by
|
||||
* @returns Array of recent log entries
|
||||
*/
|
||||
public async getRecentLogs(
|
||||
limit: number = 10,
|
||||
type?: string,
|
||||
part?: string,
|
||||
): Promise<Log[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to retrieve logs");
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = this.getCurrentUserId();
|
||||
if (!userId) {
|
||||
throw new Error("No user ID available");
|
||||
}
|
||||
|
||||
let filter = `user = "${userId}"`;
|
||||
if (type) filter += ` && type = "${type}"`;
|
||||
if (part) filter += ` && part = "${part}"`;
|
||||
|
||||
const result = await this.auth
|
||||
.getPocketBase()
|
||||
.collection(this.COLLECTION_NAME)
|
||||
.getList<Log>(1, limit, {
|
||||
filter,
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
return result.items;
|
||||
} catch (error) {
|
||||
console.error("SendLog: Failed to get recent logs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
316
src/scripts/pocketbase/Update.ts
Normal file
316
src/scripts/pocketbase/Update.ts
Normal file
|
@ -0,0 +1,316 @@
|
|||
import { Authentication } from "./Authentication";
|
||||
|
||||
// Utility function to check if a value is a date string
|
||||
function isLocalDateString(value: any): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
// Match ISO format without the Z suffix (local time)
|
||||
const isoDateRegex =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:(?:-|\+)\d{2}:\d{2})?$/;
|
||||
return isoDateRegex.test(value);
|
||||
}
|
||||
|
||||
// Utility function to convert local time to UTC
|
||||
function convertLocalToUTC<T>(data: T): T {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
const converted = { ...data };
|
||||
for (const [key, value] of Object.entries(converted)) {
|
||||
// Special handling for event date fields to ensure proper UTC conversion
|
||||
if (
|
||||
(key === "start_date" ||
|
||||
key === "end_date" ||
|
||||
key === "time_checked_in") &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
// Ensure we're converting to UTC
|
||||
const date = new Date(value);
|
||||
(converted as any)[key] = date.toISOString();
|
||||
}
|
||||
// Special handling for invoice_data to ensure it's a proper JSON object
|
||||
else if (key === "invoice_data") {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
// If it's a string representation of JSON, parse it
|
||||
const parsedValue = JSON.parse(value);
|
||||
(converted as any)[key] = parsedValue;
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, keep it as is
|
||||
console.warn("Failed to parse invoice_data as JSON:", e);
|
||||
}
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
// If it's already an object, keep it as is
|
||||
(converted as any)[key] = value;
|
||||
}
|
||||
} else if (isLocalDateString(value)) {
|
||||
// Convert local date string to UTC
|
||||
const date = new Date(value);
|
||||
(converted as any)[key] = date.toISOString();
|
||||
} else if (Array.isArray(value)) {
|
||||
(converted as any)[key] = value.map((item) => convertLocalToUTC(item));
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
(converted as any)[key] = convertLocalToUTC(value);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
export class Update {
|
||||
private auth: Authentication;
|
||||
private static instance: Update;
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of Update
|
||||
*/
|
||||
public static getInstance(): Update {
|
||||
if (!Update.instance) {
|
||||
Update.instance = new Update();
|
||||
}
|
||||
return Update.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert local time to UTC
|
||||
* @param data The data to convert
|
||||
* @returns The converted data
|
||||
*/
|
||||
public static convertLocalToUTC<T>(data: T): T {
|
||||
return convertLocalToUTC(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
* @param collectionName The name of the collection
|
||||
* @param data The data for the new record
|
||||
* @returns The created record
|
||||
*/
|
||||
public async create<T = any>(
|
||||
collectionName: string,
|
||||
data: Record<string, any>,
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to create records");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const convertedData = convertLocalToUTC(data);
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.create<T>(convertedData);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to create record in ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single field in a record
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to update
|
||||
* @param field The field to update
|
||||
* @param value The new value for the field
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async updateField<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
field: string,
|
||||
value: any,
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to update records");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const data = { [field]: value };
|
||||
const convertedData = convertLocalToUTC(data);
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.update<T>(recordId, convertedData);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to update ${field} in ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple fields in a record
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to update
|
||||
* @param updates Object containing field-value pairs to update
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async updateFields<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
updates: Record<string, any>,
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to update records");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const convertedUpdates = convertLocalToUTC(updates);
|
||||
|
||||
// If recordId is empty, create a new record instead of updating
|
||||
if (!recordId) {
|
||||
return this.create<T>(collectionName, convertedUpdates);
|
||||
}
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.update<T>(recordId, convertedUpdates);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to update fields in ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field for multiple records
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordIds Array of record IDs to update
|
||||
* @param field The field to update
|
||||
* @param value The new value for the field
|
||||
* @returns Array of updated records
|
||||
*/
|
||||
public async batchUpdateField<T = any>(
|
||||
collectionName: string,
|
||||
recordIds: string[],
|
||||
field: string,
|
||||
value: any,
|
||||
): Promise<T[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to update records");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const data = { [field]: value };
|
||||
const convertedData = convertLocalToUTC(data);
|
||||
|
||||
const updates = recordIds.map((id) =>
|
||||
pb.collection(collectionName).update<T>(id, convertedData),
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to batch update ${field} in ${collectionName}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple fields for multiple records
|
||||
* @param collectionName The name of the collection
|
||||
* @param updates Array of objects containing record ID and updates
|
||||
* @returns Array of updated records
|
||||
*/
|
||||
public async batchUpdateFields<T = any>(
|
||||
collectionName: string,
|
||||
updates: Array<{ id: string; data: Record<string, any> }>,
|
||||
): Promise<T[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to update records");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
const updatePromises = updates.map(({ id, data }) =>
|
||||
pb.collection(collectionName).update<T>(id, convertLocalToUTC(data)),
|
||||
);
|
||||
|
||||
const results = await Promise.all(updatePromises);
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error(`Failed to batch update fields in ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a record with file appends
|
||||
* This method properly handles appending files to existing records
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to update
|
||||
* @param data Regular fields to update
|
||||
* @param files Object mapping field names to arrays of files to append
|
||||
* @returns The updated record
|
||||
*/
|
||||
public async updateWithFileAppends<T = any>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
data: Record<string, any> = {},
|
||||
files: Record<string, File[]> = {}
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to update records");
|
||||
}
|
||||
|
||||
try {
|
||||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Convert regular data fields
|
||||
const convertedData = convertLocalToUTC(data);
|
||||
|
||||
// Create FormData for the update
|
||||
const formData = new FormData();
|
||||
|
||||
// Add regular fields
|
||||
Object.entries(convertedData).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// Add files with the + prefix to append them
|
||||
Object.entries(files).forEach(([fieldName, fieldFiles]) => {
|
||||
fieldFiles.forEach(file => {
|
||||
formData.append(`${fieldName}+`, file);
|
||||
});
|
||||
});
|
||||
|
||||
// Perform the update
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.update<T>(recordId, formData);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to update with file appends in ${collectionName}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.auth.setUpdating(false);
|
||||
}
|
||||
}
|
||||
}
|
37
src/utils/roleAccess.ts
Normal file
37
src/utils/roleAccess.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
export type OfficerStatus =
|
||||
| "administrator"
|
||||
| "executive"
|
||||
| "general"
|
||||
| "honorary"
|
||||
| "past"
|
||||
| "sponsor"
|
||||
| "none"
|
||||
| "";
|
||||
type RoleHierarchy = Record<OfficerStatus, OfficerStatus[]>;
|
||||
|
||||
export function hasAccess(
|
||||
userRole: OfficerStatus,
|
||||
requiredRole: OfficerStatus,
|
||||
): boolean {
|
||||
const roleHierarchy: RoleHierarchy = {
|
||||
administrator: [
|
||||
"administrator",
|
||||
"executive",
|
||||
"general",
|
||||
"honorary",
|
||||
"past",
|
||||
"sponsor",
|
||||
"none",
|
||||
"",
|
||||
],
|
||||
executive: ["executive", "general", "honorary", "past", "none", ""],
|
||||
general: ["general", "honorary", "past", "none", ""],
|
||||
honorary: ["honorary", "none", ""],
|
||||
past: ["past", "none", ""],
|
||||
sponsor: ["sponsor"], // Sponsor can only access sponsor-specific content
|
||||
none: ["none", ""],
|
||||
"": [""],
|
||||
};
|
||||
|
||||
return roleHierarchy[userRole]?.includes(requiredRole) || false;
|
||||
}
|
|
@ -9,7 +9,8 @@ export default {
|
|||
"animate-delay-100",
|
||||
"animate-delay-300",
|
||||
"animate-delay-500",
|
||||
"animate-delay-700"],
|
||||
"animate-delay-700",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
|
@ -35,7 +36,9 @@ export default {
|
|||
plugins: [
|
||||
require("tailwindcss-motion"),
|
||||
require("tailwindcss-animated"),
|
||||
require("daisyui"),
|
||||
function ({ addVariant }) {
|
||||
addVariant("in-view", "&.in-view");
|
||||
},],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
"jsxImportSource": "react",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue