Compare commits
69 Commits
ENCOA-131_
...
22209ee1c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22209ee1c1 | ||
|
|
0e2f53db0a | ||
|
|
9177a6b2ac | ||
|
|
6d1e8a9788 | ||
|
|
1c61d50a5c | ||
|
|
9f0ba418e5 | ||
|
|
6fd2e64e04 | ||
|
|
2c01e6b460 | ||
|
|
6e0c4d4361 | ||
|
|
745eef981f | ||
|
|
7a33f42bcd | ||
|
|
02564c8426 | ||
|
|
eab6ab03b7 | ||
|
|
6f534662e1 | ||
|
|
fbc7abdabb | ||
|
|
b7349b5df8 | ||
|
|
298901a642 | ||
|
|
88eafafe12 | ||
|
|
31a01a3157 | ||
|
|
a5b3a7e94d | ||
|
|
49e8237e99 | ||
|
|
d5769c2cb9 | ||
|
|
e49a325074 | ||
|
|
e6528392a2 | ||
|
|
620e4dd787 | ||
|
|
e3847baadb | ||
|
|
5b8631ab6a | ||
|
|
f9f29eabb3 | ||
|
|
898edb152f | ||
|
|
bf0d696b2f | ||
|
|
d91b1c14e7 | ||
|
|
cdd42b2f07 | ||
|
|
34bc9df9ea | ||
|
|
15cc7c8cc9 | ||
|
|
b4ab620c78 | ||
|
|
6e4ef249b8 | ||
|
|
c2b4bb29d6 | ||
|
|
cab469007b | ||
|
|
d6782bd86e | ||
|
|
6251f8f4db | ||
|
|
fb9d11f38d | ||
|
|
bb8dca69cf | ||
|
|
53b31b306d | ||
|
|
d173cdb02a | ||
|
|
07f0ea25bb | ||
|
|
e7ee55d608 | ||
|
|
7fa4edf37d | ||
|
|
49022394b0 | ||
|
|
3be0d158e3 | ||
|
|
56f374bbfe | ||
|
|
417c9176fe | ||
|
|
e3400e8564 | ||
|
|
d680905a87 | ||
|
|
c07e3f86fb | ||
|
|
238a25aaeb | ||
|
|
171231cd21 | ||
|
|
6ed342bb6f | ||
|
|
6f7ef1abef | ||
|
|
e33fa00fa3 | ||
|
|
c0b814081e | ||
|
|
e8b7c5ff80 | ||
|
|
8c94bcac52 | ||
|
|
8803a8c166 | ||
|
|
2f63fd196b | ||
|
|
42471170ce | ||
|
|
2bf9afca9c | ||
|
|
9c41ddee60 | ||
|
|
9993c7a8a7 | ||
|
|
a22c9d102f |
@@ -23,6 +23,8 @@ COPY . .
|
|||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
ENV MONGODB_URI "mongodb+srv://user:JKpFBymv0WLv3STj@encoach.lz18a.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach"
|
||||||
|
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
# If using npm comment out above and use below instead
|
# If using npm comment out above and use below instead
|
||||||
|
|||||||
304
package-lock.json
generated
304
package-lock.json
generated
@@ -26,7 +26,8 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1",
|
||||||
|
"axios-cache-interceptor": "^1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"moment-timezone": "^0.5.44",
|
||||||
|
"mongodb": "^6.8.1",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
"read-excel-file": "^5.7.1",
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "5.0.2",
|
"short-unique-id": "5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||||
"@types/blob-stream": "^0.1.33",
|
"@types/blob-stream": "^0.1.33",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
@@ -1946,6 +1949,14 @@
|
|||||||
"prop-types": "^15.7.2"
|
"prop-types": "^15.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mongodb-js/saslprep": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
|
||||||
|
"dependencies": {
|
||||||
|
"sparse-bitfield": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "14.2.5",
|
"version": "14.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
||||||
@@ -3036,6 +3047,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
||||||
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@simbathesailor/use-what-changed": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
|
||||||
|
"dev": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -3455,6 +3475,19 @@
|
|||||||
"@types/debounce": "*"
|
"@types/debounce": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webidl-conversions": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/whatwg-url": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/webidl-conversions": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "5.51.0",
|
"version": "5.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
||||||
@@ -4001,6 +4034,25 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios-cache-interceptor": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cache-parser": "1.2.5",
|
||||||
|
"fast-defer": "1.1.8",
|
||||||
|
"object-code": "1.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/arthurfiorette/axios-cache-interceptor?sponsor=1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"axios": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
||||||
@@ -4255,6 +4307,14 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bson": {
|
||||||
|
"version": "6.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
|
||||||
|
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
@@ -4318,6 +4378,11 @@
|
|||||||
"node": ">=10.16.0"
|
"node": ">=10.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cache-parser": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||||
@@ -6045,6 +6110,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-defer": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
||||||
@@ -8372,6 +8442,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/memory-pager": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -8503,6 +8578,91 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mongodb": {
|
||||||
|
"version": "6.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
|
||||||
|
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@mongodb-js/saslprep": "^1.1.5",
|
||||||
|
"bson": "^6.7.0",
|
||||||
|
"mongodb-connection-string-url": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aws-sdk/credential-providers": "^3.188.0",
|
||||||
|
"@mongodb-js/zstd": "^1.1.0",
|
||||||
|
"gcp-metadata": "^5.2.0",
|
||||||
|
"kerberos": "^2.0.1",
|
||||||
|
"mongodb-client-encryption": ">=6.0.0 <7",
|
||||||
|
"snappy": "^7.2.2",
|
||||||
|
"socks": "^2.7.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@aws-sdk/credential-providers": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mongodb-js/zstd": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"gcp-metadata": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"kerberos": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mongodb-client-encryption": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"snappy": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/whatwg-url": "^11.0.2",
|
||||||
|
"whatwg-url": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "^4.1.1",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
@@ -8730,6 +8890,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-code": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
|
||||||
|
},
|
||||||
"node_modules/object-hash": {
|
"node_modules/object-hash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
@@ -10381,6 +10546,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sparse-bitfield": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"memory-pager": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
||||||
@@ -10665,10 +10838,11 @@
|
|||||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
||||||
},
|
},
|
||||||
"node_modules/swr": {
|
"node_modules/swr": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
|
||||||
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==",
|
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"client-only": "^0.0.1",
|
||||||
"use-sync-external-store": "^1.2.0"
|
"use-sync-external-store": "^1.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -13163,6 +13337,14 @@
|
|||||||
"prop-types": "^15.7.2"
|
"prop-types": "^15.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@mongodb-js/saslprep": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
|
||||||
|
"requires": {
|
||||||
|
"sparse-bitfield": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@next/env": {
|
"@next/env": {
|
||||||
"version": "14.2.5",
|
"version": "14.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
||||||
@@ -13901,6 +14083,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
||||||
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
||||||
},
|
},
|
||||||
|
"@simbathesailor/use-what-changed": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@swc/counter": {
|
"@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -14287,6 +14475,19 @@
|
|||||||
"@types/debounce": "*"
|
"@types/debounce": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/webidl-conversions": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||||
|
},
|
||||||
|
"@types/whatwg-url": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/webidl-conversions": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@typescript-eslint/parser": {
|
"@typescript-eslint/parser": {
|
||||||
"version": "5.51.0",
|
"version": "5.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
||||||
@@ -14678,6 +14879,16 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"axios-cache-interceptor": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
|
||||||
|
"requires": {
|
||||||
|
"cache-parser": "1.2.5",
|
||||||
|
"fast-defer": "1.1.8",
|
||||||
|
"object-code": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"axobject-query": {
|
"axobject-query": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
||||||
@@ -14869,6 +15080,11 @@
|
|||||||
"update-browserslist-db": "^1.0.10"
|
"update-browserslist-db": "^1.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bson": {
|
||||||
|
"version": "6.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
|
||||||
|
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ=="
|
||||||
|
},
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
@@ -14906,6 +15122,11 @@
|
|||||||
"streamsearch": "^1.1.0"
|
"streamsearch": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cache-parser": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
|
||||||
|
},
|
||||||
"call-bind": {
|
"call-bind": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||||
@@ -16235,6 +16456,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
},
|
},
|
||||||
|
"fast-defer": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
|
||||||
|
},
|
||||||
"fast-glob": {
|
"fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
||||||
@@ -18009,6 +18235,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
},
|
},
|
||||||
|
"memory-pager": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||||
|
},
|
||||||
"merge2": {
|
"merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -18097,6 +18328,49 @@
|
|||||||
"moment": "^2.29.4"
|
"moment": "^2.29.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mongodb": {
|
||||||
|
"version": "6.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
|
||||||
|
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
|
||||||
|
"requires": {
|
||||||
|
"@mongodb-js/saslprep": "^1.1.5",
|
||||||
|
"bson": "^6.7.0",
|
||||||
|
"mongodb-connection-string-url": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mongodb-connection-string-url": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
|
||||||
|
"requires": {
|
||||||
|
"@types/whatwg-url": "^11.0.2",
|
||||||
|
"whatwg-url": "^13.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
||||||
|
"requires": {
|
||||||
|
"punycode": "^2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
|
||||||
|
},
|
||||||
|
"whatwg-url": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
|
||||||
|
"requires": {
|
||||||
|
"tr46": "^4.1.1",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ms": {
|
"ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
@@ -18248,6 +18522,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||||
},
|
},
|
||||||
|
"object-code": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
|
||||||
|
},
|
||||||
"object-hash": {
|
"object-hash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
@@ -19413,6 +19692,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
|
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
|
||||||
},
|
},
|
||||||
|
"sparse-bitfield": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
|
"requires": {
|
||||||
|
"memory-pager": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"stop-iteration-iterator": {
|
"stop-iteration-iterator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
||||||
@@ -19626,10 +19913,11 @@
|
|||||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
||||||
},
|
},
|
||||||
"swr": {
|
"swr": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
|
||||||
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==",
|
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
"client-only": "^0.0.1",
|
||||||
"use-sync-external-store": "^1.2.0"
|
"use-sync-external-store": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"moment-timezone": "^0.5.44",
|
||||||
|
"mongodb": "^6.8.1",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import moment from "moment";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
mutateUser: KeyedMutator<User>;
|
mutateUser: (user: User) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
@@ -42,7 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch("/api/users/update", {
|
.patch<{user: User}>("/api/users/update", {
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
country,
|
country,
|
||||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||||
@@ -54,7 +54,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
},
|
},
|
||||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||||
})
|
})
|
||||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
.then((response) => mutateUser(response.data.user))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||||
})
|
})
|
||||||
@@ -89,7 +89,15 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
</div>
|
</div>
|
||||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required />
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
onChange={(e) => setPhone(e)}
|
||||||
|
value={phone}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,39 +1,47 @@
|
|||||||
import { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
|
||||||
|
|
||||||
const HighlightContent: React.FC<{
|
interface HighlightedContentProps {
|
||||||
html: string;
|
html: string;
|
||||||
highlightPhrases: string[],
|
highlightConfigs: HighlightConfig[];
|
||||||
firstOccurence?: boolean
|
contentType: HighlightTarget;
|
||||||
}> = ({
|
currentSegmentIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HighlightedContent: React.FC<HighlightedContentProps> = ({
|
||||||
html,
|
html,
|
||||||
highlightPhrases,
|
highlightConfigs,
|
||||||
firstOccurence = false
|
contentType,
|
||||||
|
currentSegmentIndex
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const createHighlightedContent = useCallback(() => {
|
const createHighlightedContent = useCallback(() => {
|
||||||
if (highlightPhrases.length === 0) {
|
|
||||||
return { __html: html };
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapeRegExp = (string: string) => {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
};
|
|
||||||
|
|
||||||
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
|
|
||||||
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
|
|
||||||
|
|
||||||
let highlightedHtml = html;
|
let highlightedHtml = html;
|
||||||
|
highlightConfigs.forEach(config => {
|
||||||
|
if (config.targets.includes(contentType) || config.targets.includes('all')) {
|
||||||
|
const escapeRegExp = (string: string) => {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
if (firstOccurence) {
|
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
|
||||||
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
|
||||||
} else {
|
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
|
||||||
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
const segments = highlightedHtml.split('</div>');
|
||||||
}
|
segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => {
|
||||||
|
return `<span style="background-color: #FFFACD;">${match}</span>`;
|
||||||
|
});
|
||||||
|
highlightedHtml = segments.join('</div>');
|
||||||
|
} else {
|
||||||
|
highlightedHtml = highlightedHtml.replace(regex, (match) => {
|
||||||
|
return `<span style="background-color: #FFFACD;">${match}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { __html: highlightedHtml };
|
return { __html: highlightedHtml };
|
||||||
}, [html, highlightPhrases, firstOccurence]);
|
}, [html, highlightConfigs, contentType, currentSegmentIndex]);
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HighlightContent;
|
export default HighlightedContent;
|
||||||
@@ -1,51 +1,77 @@
|
|||||||
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import {useMemo, useState} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
const SIZE = 25;
|
||||||
|
|
||||||
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
|
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: items,
|
||||||
columns: columns,
|
columns: columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<div className="w-full h-full flex flex-col gap-2">
|
||||||
<thead>
|
<div className="w-full flex gap-2 justify-between">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
||||||
<tr key={headerGroup.id}>
|
Previous Page
|
||||||
{headerGroup.headers.map((header) => (
|
</Button>
|
||||||
<th key={header.id} colSpan={header.colSpan}>
|
<div className="flex items-center gap-4 w-fit">
|
||||||
{header.isPlaceholder ? null : (
|
<span className="opacity-80">
|
||||||
<>
|
{page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length}
|
||||||
<div
|
</span>
|
||||||
{...{
|
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= data.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||||
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
|
Next Page
|
||||||
onClick: header.column.getToggleSortingHandler(),
|
</Button>
|
||||||
}}>
|
</div>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
</div>
|
||||||
{{
|
|
||||||
asc: " 🔼",
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
desc: " 🔽",
|
<thead>
|
||||||
}[header.column.getIsSorted() as string] ?? null}
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
</div>
|
<tr key={headerGroup.id}>
|
||||||
</>
|
{headerGroup.headers.map((header) => (
|
||||||
)}
|
<th key={header.id} colSpan={header.colSpan}>
|
||||||
</th>
|
{header.isPlaceholder ? null : (
|
||||||
))}
|
<>
|
||||||
</tr>
|
<div
|
||||||
))}
|
{...{
|
||||||
</thead>
|
className: header.column.getCanSort()
|
||||||
<tbody className="px-2">
|
? "cursor-pointer select-none py-4 text-left first:pl-4"
|
||||||
{table.getRowModel().rows.map((row) => (
|
: "",
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
onClick: header.column.getToggleSortingHandler(),
|
||||||
{row.getVisibleCells().map((cell) => (
|
}}>
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{{
|
||||||
</td>
|
asc: " 🔼",
|
||||||
))}
|
desc: " 🔽",
|
||||||
</tr>
|
}[header.column.getIsSorted() as string] ?? null}
|
||||||
))}
|
</div>
|
||||||
</tbody>
|
</>
|
||||||
</table>
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,28 +65,28 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
{
|
{
|
||||||
module: "reading",
|
module: "reading",
|
||||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.reading >= user.desiredLevels.reading,
|
achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
module: "listening",
|
module: "listening",
|
||||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.listening >= user.desiredLevels.listening,
|
achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "writing",
|
module: "writing",
|
||||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.writing >= user.desiredLevels.writing,
|
achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "level",
|
module: "level",
|
||||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.level >= user.desiredLevels.level,
|
achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +1,43 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React from "react";
|
||||||
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||||
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
import formatTip from "./FormatTip";
|
||||||
|
|
||||||
|
|
||||||
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
||||||
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
||||||
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
|
const tip = {
|
||||||
const tip = {
|
"category": "",
|
||||||
category: "Strategy",
|
"embedding": "",
|
||||||
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
"text": "",
|
||||||
}
|
"html": "",
|
||||||
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
"id": "",
|
||||||
const rightTextData: WalkthroughConfigs[] = [
|
"verified": true,
|
||||||
{
|
"standalone": false,
|
||||||
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
"exercise": {
|
||||||
"wordDelay": 200,
|
"question": "",
|
||||||
"holdDelay": 5000,
|
"additional": "",
|
||||||
"highlight": []
|
"segments": []
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["A good group of friends can increase your happiness."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["The place where you live is more important for happiness than anything else."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 5000,
|
|
||||||
"highlight": []
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
const mockTip: ITrainingTip = {
|
const mockTip: ITrainingTip = {
|
||||||
id: "some random id",
|
id: "some random id",
|
||||||
tipCategory: tip.category,
|
tipCategory: tip.category,
|
||||||
tipHtml: tip.body,
|
tipHtml: tip.html,
|
||||||
standalone: false,
|
standalone: tip.standalone,
|
||||||
exercise: {
|
exercise: {
|
||||||
question: question,
|
question: tip.exercise.question,
|
||||||
highlightable: leftText,
|
additional: tip.exercise.additional,
|
||||||
segments: rightTextData
|
segments: tip.exercise.segments as WalkthroughConfigs[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const formattedTip = formatTip(mockTip);
|
||||||
<div className="flex flex-col p-10">
|
return (
|
||||||
<ExerciseWalkthrough {...trainingTip}
|
<ExerciseWalkthrough {...formatTip(trainingTip)}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TrainingExercise;
|
export default TrainingExercise;
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import React, {useState, useEffect, useRef, useCallback} from "react";
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {animated} from "@react-spring/web";
|
import { animated } from '@react-spring/web';
|
||||||
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
|
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
|
||||||
import HighlightContent from "../HighlightContent";
|
import HighlightContent from '../HighlightContent';
|
||||||
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
|
import { ITrainingTip, SegmentRef, TimelineEvent, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces';
|
||||||
|
import Tip from './Tip';
|
||||||
|
|
||||||
|
interface HtmlState {
|
||||||
|
question: string;
|
||||||
|
additional: string;
|
||||||
|
walkthrough: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
||||||
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
|
const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
|
||||||
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
|
||||||
const timelineRef = useRef<TimelineEvent[]>([]);
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||||
const animationRef = useRef<number | null>(null);
|
const animationRef = useRef<number | null>(null);
|
||||||
const segmentsRef = useRef<SegmentRef[]>([]);
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||||
|
|
||||||
|
const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || '');
|
||||||
|
const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || '');
|
||||||
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
|
||||||
|
const [htmlStates, setHtmlStates] = useState<HtmlState[]>([]);
|
||||||
|
const lastProcessedInsertTime = useRef<number>(-1);
|
||||||
|
|
||||||
const toggleAutoPlay = useCallback(() => {
|
const toggleAutoPlay = useCallback(() => {
|
||||||
setIsAutoPlaying((prev) => {
|
setIsAutoPlaying((prev) => {
|
||||||
if (!prev && currentTime === getMaxTime()) {
|
if (!prev && currentTime === getMaxTime()) {
|
||||||
@@ -33,23 +46,24 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getMaxTime = (): number => {
|
const getMaxTime = (): number => {
|
||||||
return (
|
return tip.exercise?.segments.reduce((sum, segment) =>
|
||||||
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
|
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
|
||||||
);
|
) ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeline: TimelineEvent[] = [];
|
const timeline: TimelineEvent[] = [];
|
||||||
let currentTimePosition = 0;
|
let currentTimePosition = 0;
|
||||||
segmentsRef.current = [];
|
segmentsRef.current = [];
|
||||||
|
const newHtmlStates: HtmlState[] = [];
|
||||||
|
|
||||||
tip.exercise?.segments.forEach((segment, index) => {
|
tip.exercise?.segments.forEach((segment, index) => {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(segment.html, "text/html");
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
const walkTree = (node: Node) => {
|
const walkTree = (node: Node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
|
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
Array.from(node.childNodes).forEach(walkTree);
|
Array.from(node.childNodes).forEach(walkTree);
|
||||||
}
|
}
|
||||||
@@ -62,69 +76,116 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
...segment,
|
...segment,
|
||||||
words: words,
|
words: words,
|
||||||
startTime: currentTimePosition,
|
startTime: currentTimePosition,
|
||||||
endTime: currentTimePosition + textDuration,
|
endTime: currentTimePosition + textDuration
|
||||||
});
|
});
|
||||||
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "text",
|
type: 'text',
|
||||||
start: currentTimePosition,
|
start: currentTimePosition,
|
||||||
end: currentTimePosition + textDuration,
|
end: currentTimePosition + textDuration,
|
||||||
segmentIndex: index,
|
segmentIndex: index
|
||||||
});
|
});
|
||||||
|
|
||||||
currentTimePosition += textDuration;
|
currentTimePosition += textDuration;
|
||||||
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "highlight",
|
type: 'highlight',
|
||||||
start: currentTimePosition,
|
start: currentTimePosition,
|
||||||
end: currentTimePosition + segment.holdDelay,
|
end: currentTimePosition + segment.holdDelay,
|
||||||
content: segment.highlight,
|
content: segment.highlight,
|
||||||
segmentIndex: index,
|
segmentIndex: index
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (segment.insertHTML && segment.insertHTML.length > 0) {
|
||||||
|
newHtmlStates.push({
|
||||||
|
question: questionHtml,
|
||||||
|
additional: additionalHtml,
|
||||||
|
walkthrough: walkthroughHtml
|
||||||
|
});
|
||||||
|
timeline.push({
|
||||||
|
type: 'insert',
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + segment.holdDelay,
|
||||||
|
segmentIndex: index,
|
||||||
|
content: segment.insertHTML
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentTimePosition += segment.holdDelay;
|
currentTimePosition += segment.holdDelay;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < timeline.length; i++) {
|
||||||
|
if (timeline[i].type === 'insert') {
|
||||||
|
const nextInsertIndex = timeline.findIndex((event, index) => index > i && event.type === 'insert');
|
||||||
|
if (nextInsertIndex !== -1) {
|
||||||
|
timeline[i].end = timeline[nextInsertIndex].start;
|
||||||
|
} else {
|
||||||
|
timeline[i].end = currentTimePosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
}, [tip.exercise?.segments]);
|
setHtmlStates(newHtmlStates);
|
||||||
|
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
|
||||||
|
|
||||||
const updateText = useCallback(() => {
|
const updateText = useCallback(() => {
|
||||||
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
|
const currentEvents = timelineRef.current.filter(
|
||||||
|
event => currentTime >= event.start && currentTime <= event.end
|
||||||
|
);
|
||||||
|
|
||||||
if (currentEvent) {
|
if (currentTime < lastProcessedInsertTime.current) {
|
||||||
if (currentEvent.type === "text") {
|
const lastInsertEvent = timelineRef.current
|
||||||
|
.filter(event => event.type === 'insert' && event.start <= currentTime)
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
if (lastInsertEvent) {
|
||||||
|
const stateIndex = timelineRef.current.indexOf(lastInsertEvent);
|
||||||
|
if (stateIndex >= 0 && stateIndex < htmlStates.length) {
|
||||||
|
const previousState = htmlStates[stateIndex];
|
||||||
|
setQuestionHtml(previousState.question);
|
||||||
|
setAdditionalHtml(previousState.additional);
|
||||||
|
setWalkthroughHtml(previousState.walkthrough);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no previous insert event, revert to initial state
|
||||||
|
setQuestionHtml(tip.exercise?.question || '');
|
||||||
|
setAdditionalHtml(tip.exercise?.additional || '');
|
||||||
|
setWalkthroughHtml('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEvents.forEach(currentEvent => {
|
||||||
|
if (currentEvent.type === 'text') {
|
||||||
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||||
const elapsedTime = currentTime - currentEvent.start;
|
const elapsedTime = currentTime - currentEvent.start;
|
||||||
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||||
|
|
||||||
const previousSegmentsHtml = segmentsRef.current
|
const previousSegmentsHtml = segmentsRef.current
|
||||||
.slice(0, currentEvent.segmentIndex)
|
.slice(0, currentEvent.segmentIndex)
|
||||||
.map((seg) => seg.html)
|
.map(seg => seg.html)
|
||||||
.join("");
|
.join('');
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(segment.html, "text/html");
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
let wordCount = 0;
|
let wordCount = 0;
|
||||||
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||||
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
|
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
|
||||||
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
|
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
|
||||||
action(node.cloneNode(true));
|
action(node.cloneNode(true));
|
||||||
wordCount += words.filter((w) => !/\s+/.test(w)).length;
|
wordCount += words.filter(w => !/\s+/.test(w)).length;
|
||||||
} else {
|
} else {
|
||||||
const remainingWords = wordsToShow - wordCount;
|
const remainingWords = wordsToShow - wordCount;
|
||||||
const newTextContent = words.reduce(
|
const newTextContent = words.reduce((acc, word) => {
|
||||||
(acc, word) => {
|
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||||
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
acc.text += word;
|
||||||
acc.text += word;
|
acc.nonSpaceWords++;
|
||||||
acc.nonSpaceWords++;
|
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||||
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
acc.text += word;
|
||||||
acc.text += word;
|
}
|
||||||
}
|
return acc;
|
||||||
return acc;
|
}, { text: '', nonSpaceWords: 0 }).text;
|
||||||
},
|
|
||||||
{text: "", nonSpaceWords: 0},
|
|
||||||
).text;
|
|
||||||
const newNode = node.cloneNode(false);
|
const newNode = node.cloneNode(false);
|
||||||
newNode.textContent = newTextContent;
|
newNode.textContent = newTextContent;
|
||||||
action(newNode);
|
action(newNode);
|
||||||
@@ -133,38 +194,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
const clone = node.cloneNode(false);
|
const clone = node.cloneNode(false);
|
||||||
action(clone);
|
action(clone);
|
||||||
Array.from(node.childNodes).some((child) => {
|
Array.from(node.childNodes).some(child => {
|
||||||
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
|
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return wordCount >= wordsToShow;
|
return wordCount >= wordsToShow;
|
||||||
};
|
};
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
walkTree(doc.body, (node) => fragment.appendChild(node));
|
walkTree(doc.body, node => fragment.appendChild(node));
|
||||||
|
|
||||||
const serializer = new XMLSerializer();
|
const serializer = new XMLSerializer();
|
||||||
const currentSegmentHtml = Array.from(fragment.childNodes)
|
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||||
.map((node) => serializer.serializeToString(node))
|
.map(node => serializer.serializeToString(node))
|
||||||
.join("");
|
.join('');
|
||||||
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
setWalkthroughHtml(newHtml);
|
setWalkthroughHtml(newHtml);
|
||||||
setHighlightedPhrases([]);
|
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||||
} else if (currentEvent.type === "highlight") {
|
setCurrentHighlightConfigs([]);
|
||||||
|
} else if (currentEvent.type === 'highlight') {
|
||||||
const newHtml = segmentsRef.current
|
const newHtml = segmentsRef.current
|
||||||
.slice(0, currentEvent.segmentIndex + 1)
|
.slice(0, currentEvent.segmentIndex + 1)
|
||||||
.map((seg) => seg.html)
|
.map(seg => seg.html)
|
||||||
.join("");
|
.join('');
|
||||||
setWalkthroughHtml(newHtml);
|
setWalkthroughHtml(newHtml);
|
||||||
setHighlightedPhrases(currentEvent.content || []);
|
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||||
|
setCurrentHighlightConfigs(currentEvent.content as HighlightConfig[] || []);
|
||||||
|
} else if (currentEvent.type === 'insert') {
|
||||||
|
const insertConfigs = currentEvent.content as InsertHtmlConfig[];
|
||||||
|
insertConfigs.forEach(config => {
|
||||||
|
switch (config.target) {
|
||||||
|
case 'question':
|
||||||
|
setQuestionHtml(prevHtml => insertHtmlContent(prevHtml, config));
|
||||||
|
break;
|
||||||
|
case 'additional':
|
||||||
|
setAdditionalHtml(prevHtml => insertHtmlContent(prevHtml, config));
|
||||||
|
break;
|
||||||
|
case 'segment':
|
||||||
|
setWalkthroughHtml(prevHtml => insertHtmlContent(prevHtml, config));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastProcessedInsertTime.current = currentTime;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, [currentTime]);
|
}, [currentTime, htmlStates, tip.exercise?.question, tip.exercise?.additional]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateText();
|
updateText();
|
||||||
}, [currentTime, updateText]);
|
}, [currentTime, updateText]);
|
||||||
|
|
||||||
|
const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => {
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = prevHtml;
|
||||||
|
|
||||||
|
const targetElement = tempDiv.querySelector(`#${config.targetId}`);
|
||||||
|
if (targetElement) {
|
||||||
|
switch (config.position) {
|
||||||
|
case 'append':
|
||||||
|
targetElement.insertAdjacentHTML('beforeend', config.html);
|
||||||
|
break;
|
||||||
|
case 'prepend':
|
||||||
|
targetElement.insertAdjacentHTML('afterbegin', config.html);
|
||||||
|
break;
|
||||||
|
case 'replace':
|
||||||
|
targetElement.innerHTML = config.html;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempDiv.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAutoPlaying) {
|
if (isAutoPlaying) {
|
||||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
@@ -219,60 +321,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tip.standalone || !tip.exercise) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto">
|
|
||||||
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
|
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto py-6">
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
{!tip.standalone && (
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
<div className='flex flex-col space-y-4'>
|
||||||
</div>
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||||
<div className="flex flex-col space-y-4">
|
<button
|
||||||
<div className="flex flex-row items-center space-x-4 py-4">
|
onClick={toggleAutoPlay}
|
||||||
<button
|
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
onClick={toggleAutoPlay}
|
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||||
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
>
|
||||||
aria-label={isAutoPlaying ? "Pause" : "Play"}>
|
{isAutoPlaying ? (
|
||||||
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
|
<FaRegCircleStop className="w-6 h-6" />
|
||||||
</button>
|
) : (
|
||||||
<input
|
<FaRegCirclePlay className="w-6 h-6" />
|
||||||
type="range"
|
)}
|
||||||
min="0"
|
</button>
|
||||||
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
<input
|
||||||
value={currentTime}
|
type="range"
|
||||||
onChange={handleSliderChange}
|
min="0"
|
||||||
onMouseDown={handleSliderMouseDown}
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||||
onMouseUp={handleSliderMouseUp}
|
value={currentTime}
|
||||||
onTouchStart={handleSliderMouseDown}
|
onChange={handleSliderChange}
|
||||||
onTouchEnd={handleSliderMouseUp}
|
onMouseDown={handleSliderMouseDown}
|
||||||
className="flex-grow"
|
onMouseUp={handleSliderMouseUp}
|
||||||
/>
|
onTouchStart={handleSliderMouseDown}
|
||||||
</div>
|
onTouchEnd={handleSliderMouseUp}
|
||||||
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
className='flex-grow'
|
||||||
<div className="flex-1 bg-white p-6 rounded-lg shadow">
|
/>
|
||||||
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
|
||||||
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
|
|
||||||
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className="bg-gray-50 rounded-lg shadow">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className="p-6 space-y-4">
|
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
|
||||||
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
|
<div className="container mx-auto px-4">
|
||||||
|
<div id="question-container" className="border p-6 rounded-lg shadow-md">
|
||||||
|
<HighlightContent
|
||||||
|
html={questionHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs}
|
||||||
|
contentType="question"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tip.exercise?.additional && (<div className="container mx-auto px-4">
|
||||||
|
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
|
||||||
|
<HighlightContent
|
||||||
|
html={additionalHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs}
|
||||||
|
contentType="additional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<div className='bg-gray-50 rounded-lg shadow'>
|
||||||
|
<div id="segment-container" className='p-6 space-y-4'>
|
||||||
|
<animated.div>
|
||||||
|
<HighlightContent
|
||||||
|
html={walkthroughHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs.filter(config =>
|
||||||
|
config.targets.includes('segment') || config.targets.includes('all')
|
||||||
|
)}
|
||||||
|
contentType="segment"
|
||||||
|
currentSegmentIndex={currentSegmentIndex}
|
||||||
|
/>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
201
src/components/TrainingContent/FormatTip.ts
Normal file
201
src/components/TrainingContent/FormatTip.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange', 'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
||||||
|
]
|
||||||
|
|
||||||
|
const getRandomColors = (count: number) => {
|
||||||
|
const shuffled = [...colorOptions].sort(() => 0.5 - Math.random());
|
||||||
|
return shuffled.slice(0, count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const classMap = {
|
||||||
|
"mainDiv": {
|
||||||
|
"tip": "flex-col gap-2",
|
||||||
|
"question": "flex-col gap-2",
|
||||||
|
"additional": "flex-col gap-8",
|
||||||
|
"segment": "p-4 rounded-lg mb-4 flex flex-col gap-2"
|
||||||
|
},
|
||||||
|
"h2": {
|
||||||
|
"tip": "mb-4 font-semibold text-lg",
|
||||||
|
"question": "text-lg font-semibold mb-4",
|
||||||
|
"additional": "text-2xl font-semibold mb-4",
|
||||||
|
"segment": "text-xl font-semibold"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClass = (element: Element, style: string) => {
|
||||||
|
element.setAttribute('class', style)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// DON'T OVERRIDE DIV AND SPAN STYLES
|
||||||
|
const processHtml = (section: string, html: string, color: string) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
const mainDiv = doc.body.firstElementChild;
|
||||||
|
if (mainDiv && mainDiv.tagName === 'DIV') {
|
||||||
|
if (section === "segment") {
|
||||||
|
setClass(mainDiv, `bg-${color}-100 ${classMap["mainDiv"][section]}`);
|
||||||
|
} else {
|
||||||
|
setClass(mainDiv, classMap["mainDiv"][section as keyof typeof classMap["mainDiv"]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.querySelectorAll('h1').forEach(e => {
|
||||||
|
if (section === "additional") {
|
||||||
|
setClass(e, 'text-4xl font-bold mb-6')
|
||||||
|
} else {
|
||||||
|
setClass(e, 'text-xl font-semibold mb-4');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('h2').forEach(e => {
|
||||||
|
setClass(e, classMap["h2"][section as keyof typeof classMap["h2"]])
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('h3').forEach(e => {
|
||||||
|
e.setAttribute('class', 'text-lg font-semibold mb-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('p').forEach(e => {
|
||||||
|
if (section === "segment") {
|
||||||
|
setClass(e, 'text-gray-700 leading-relaxed')
|
||||||
|
} else {
|
||||||
|
setClass(e, 'mb-4');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('label').forEach(e => {
|
||||||
|
if (section === "additional") {
|
||||||
|
setClass(e, 'font-semibold');
|
||||||
|
} else {
|
||||||
|
setClass(e, 'min-w-[16px] mr-1 font-semibold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('ul').forEach(e => {
|
||||||
|
const hasLabel = Array.from(e.querySelectorAll('li')).some(li => li.querySelector('label'));
|
||||||
|
if (hasLabel) {
|
||||||
|
e.setAttribute('class', 'list-none space-y-2');
|
||||||
|
} else {
|
||||||
|
e.setAttribute('class', `list-disc pl-5 space-y-2`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('ol').forEach(e => {
|
||||||
|
e.setAttribute('class', 'list-decimal pl-5 space-y-2');
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('hz-row').forEach(e => {
|
||||||
|
e.setAttribute('class', `flex flex-row items-center mb-4 gap-2`);
|
||||||
|
})
|
||||||
|
|
||||||
|
if (section === "segment") {
|
||||||
|
doc.querySelectorAll('b').forEach(e => {
|
||||||
|
e.setAttribute('class', `text-${color}-700`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.querySelectorAll('section').forEach(e => {
|
||||||
|
e.setAttribute('class', `mb-8`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-box').forEach(e => {
|
||||||
|
e.setAttribute('class', `flex justify-center min-w-[32px] min-h-6 bg-gray-200 rounded`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-box-grow').forEach(e => {
|
||||||
|
e.setAttribute('class', 'flex flex-grow ml-2 w-10 min-h-6 bg-gray-200 rounded px-4 py-2');
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-box-blank').forEach(e => {
|
||||||
|
e.setAttribute('class', 'min-w-[32px] min-h-[32px] border border-gray-300 text-center mr-3 flex justify-center items-center');
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-card').forEach(e => {
|
||||||
|
e.setAttribute('class', 'bg-gray-100 rounded-lg flex flex-col p-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('footer').forEach(e => {
|
||||||
|
e.setAttribute('class', `flex flex-col gap-2 text-sm`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('single-line').forEach(e => {
|
||||||
|
e.setAttribute('class', `border-b border-black w-full h-4 inline-block`);
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('padded-line').forEach(e => {
|
||||||
|
e.setAttribute('class', `my-2 inline-block w-full`);
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('table').forEach(table => {
|
||||||
|
table.setAttribute('class', 'min-w-full bg-white border border-gray-300')
|
||||||
|
|
||||||
|
table.querySelectorAll('thead tr').forEach(tr => {
|
||||||
|
tr.setAttribute('class', 'bg-gray-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
table.querySelectorAll('th').forEach(th => {
|
||||||
|
th.setAttribute('class', 'py-2 px-4 border-b font-semibold text-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
table.querySelectorAll('tbody tr').forEach((tr, index) => {
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
tr.setAttribute('class', 'bg-gray-50');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
table.querySelectorAll('td').forEach(td => {
|
||||||
|
if (td === td.parentElement?.firstElementChild) {
|
||||||
|
td.setAttribute('class', 'py-2 px-4 border-b font-medium');
|
||||||
|
} else {
|
||||||
|
td.setAttribute('class', 'py-2 px-4 border-b');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('blockquote').forEach(e => {
|
||||||
|
setClass(e, `flex w-full justify-center ${section === "segment" ? "" : "mb-4"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('items-between').forEach(e => {
|
||||||
|
setClass(e, 'flex flex-row justify-between mb-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTip = (tip: ITrainingTip): ITrainingTip => {
|
||||||
|
if (tip.exercise && tip.exercise.segments) {
|
||||||
|
const colors = getRandomColors(tip.exercise.segments.length);
|
||||||
|
|
||||||
|
const processedSegments: WalkthroughConfigs[] = tip.exercise.segments.map((segment, index) => ({
|
||||||
|
...segment,
|
||||||
|
html: processHtml("segment", segment.html, colors[index])
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tip.id,
|
||||||
|
tipCategory: tip.tipCategory,
|
||||||
|
tipHtml: processHtml("tip", tip.tipHtml, ""),
|
||||||
|
standalone: tip.standalone,
|
||||||
|
exercise: {
|
||||||
|
question: processHtml("question", tip.exercise.question, ""),
|
||||||
|
additional: tip.exercise.additional ? processHtml("additional", tip.exercise.additional, "") : undefined,
|
||||||
|
segments: processedSegments
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tip.id,
|
||||||
|
tipCategory: tip.tipCategory,
|
||||||
|
tipHtml: processHtml("tip", tip.tipHtml, ""),
|
||||||
|
standalone: tip.standalone,
|
||||||
|
exercise: undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default formatTip;
|
||||||
83
src/components/TrainingContent/Tip.tsx
Normal file
83
src/components/TrainingContent/Tip.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { FaChessKnight, FaLink, FaPen } from 'react-icons/fa';
|
||||||
|
import { IoLanguage } from 'react-icons/io5';
|
||||||
|
import { MdOutlineCategory } from 'react-icons/md';
|
||||||
|
import { GiSkills } from 'react-icons/gi';
|
||||||
|
import { BiBookReader } from 'react-icons/bi';
|
||||||
|
|
||||||
|
type CategoryConfig = {
|
||||||
|
[key: string]: {
|
||||||
|
headerColor: string;
|
||||||
|
bodyColor: string;
|
||||||
|
textColor: string;
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryConfig : CategoryConfig = {
|
||||||
|
'Strategy': {
|
||||||
|
headerColor: 'bg-yellow-400',
|
||||||
|
bodyColor: 'bg-yellow-100',
|
||||||
|
textColor: 'text-yellow-900',
|
||||||
|
icon: FaChessKnight
|
||||||
|
},
|
||||||
|
'Word Partners': {
|
||||||
|
headerColor: 'bg-purple-700',
|
||||||
|
bodyColor: 'bg-purple-200',
|
||||||
|
textColor: 'text-purple-900',
|
||||||
|
icon: MdOutlineCategory
|
||||||
|
},
|
||||||
|
'Word Link': {
|
||||||
|
headerColor: 'bg-green-600',
|
||||||
|
bodyColor: 'bg-green-100',
|
||||||
|
textColor: 'text-green-900',
|
||||||
|
icon: FaLink
|
||||||
|
},
|
||||||
|
'CT Focus': {
|
||||||
|
headerColor: 'bg-purple-700',
|
||||||
|
bodyColor: 'bg-purple-200',
|
||||||
|
textColor: 'text-purple-900',
|
||||||
|
icon: GiSkills
|
||||||
|
},
|
||||||
|
'Reading Skill': {
|
||||||
|
headerColor: 'bg-orange-200',
|
||||||
|
bodyColor: 'bg-orange-100',
|
||||||
|
textColor: 'text-orange-900',
|
||||||
|
icon: BiBookReader
|
||||||
|
},
|
||||||
|
'Language for Writing': {
|
||||||
|
headerColor: 'bg-orange-200',
|
||||||
|
bodyColor: 'bg-orange-100',
|
||||||
|
textColor: 'text-orange-900',
|
||||||
|
icon: IoLanguage
|
||||||
|
},
|
||||||
|
'Writing Skill': {
|
||||||
|
headerColor: 'bg-orange-200',
|
||||||
|
bodyColor: 'bg-orange-100',
|
||||||
|
textColor: 'text-orange-900',
|
||||||
|
icon: FaPen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tip: React.FC<{ category: string; html: string }> = ({ category, html }) => {
|
||||||
|
|
||||||
|
const { headerColor, bodyColor, textColor, icon: Icon } = useMemo(() =>
|
||||||
|
categoryConfig[category] || categoryConfig['Strategy'], [category]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg overflow-hidden shadow-md mb-4">
|
||||||
|
<div className={`px-4 py-3 ${headerColor}`}>
|
||||||
|
<h2 className="font-bold text-white text-xl flex items-center">
|
||||||
|
<Icon className="ml-2 mr-2" size={24} />
|
||||||
|
{category === "CT Focus" ? "Critical Thinking" : category}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className={`p-6 ${bodyColor}`}>
|
||||||
|
<p className={`text-lg ${textColor}`} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tip;
|
||||||
@@ -29,7 +29,7 @@ export interface ITrainingTip {
|
|||||||
standalone: boolean;
|
standalone: boolean;
|
||||||
exercise?: {
|
exercise?: {
|
||||||
question: string;
|
question: string;
|
||||||
highlightable: string;
|
additional?: string;
|
||||||
segments: WalkthroughConfigs[]
|
segments: WalkthroughConfigs[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,16 +38,31 @@ export interface WalkthroughConfigs {
|
|||||||
html: string;
|
html: string;
|
||||||
wordDelay: number;
|
wordDelay: number;
|
||||||
holdDelay: number;
|
holdDelay: number;
|
||||||
highlight: string[];
|
highlight?: HighlightConfig[];
|
||||||
|
insertHTML?: InsertHtmlConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HighlightTarget = 'question' | 'additional' | 'segment' | 'all';
|
||||||
|
|
||||||
|
export interface HighlightConfig {
|
||||||
|
targets: HighlightTarget[];
|
||||||
|
phrases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertHtmlConfig {
|
||||||
|
target: 'question' | 'additional' | 'segment';
|
||||||
|
targetId: string;
|
||||||
|
html: string;
|
||||||
|
position: 'append' | 'prepend' | 'replace';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
type: 'text' | 'highlight';
|
type: 'text' | 'highlight' | 'insert';
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
content?: string[];
|
content?: HighlightConfig[] | InsertHtmlConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SegmentRef extends WalkthroughConfigs {
|
export interface SegmentRef extends WalkthroughConfigs {
|
||||||
|
|||||||
@@ -31,13 +31,40 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherHash = {
|
||||||
|
type: "teacher",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateHash = {
|
||||||
|
type: "corporate",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentsHash = {
|
||||||
|
type: "agent",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminDashboard({user}: Props) {
|
export default function AdminDashboard({user}: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id);
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
const {users, reload} = useUsers();
|
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
|
||||||
|
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
|
||||||
|
const {users: agents, total: totalAgents, reload: reloadAgents, isLoading: isAgentsLoading} = useUsers(agentsHash);
|
||||||
|
|
||||||
const {groups} = useGroups({});
|
const {groups} = useGroups({});
|
||||||
const {pending, done} = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
@@ -48,9 +75,6 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
setShowModal(!!selectedUser && router.asPath === "/#");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, router.asPath]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
useEffect(reload, [page]);
|
|
||||||
|
|
||||||
const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
@@ -280,44 +304,50 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter((x) => x.type === "student").length}
|
value={totalStudents}
|
||||||
onClick={() => router.push("/#students")}
|
onClick={() => router.push("/#students")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter((x) => x.type === "teacher").length}
|
value={totalTeachers}
|
||||||
onClick={() => router.push("/#teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={users.filter((x) => x.type === "corporate").length}
|
value={totalCorporate}
|
||||||
onClick={() => router.push("/#corporate")}
|
onClick={() => router.push("/#corporate")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
|
isLoading={isAgentsLoading}
|
||||||
label="Country Managers"
|
label="Country Managers"
|
||||||
value={users.filter((x) => x.type === "agent").length}
|
value={totalAgents}
|
||||||
onClick={() => router.push("/#agents")}
|
onClick={() => router.push("/#agents")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsGlobeCentralSouthAsia}
|
Icon={BsGlobeCentralSouthAsia}
|
||||||
|
isLoading={isAgentsLoading}
|
||||||
label="Countries"
|
label="Countries"
|
||||||
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
|
value={[...new Set(agents.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/#inactiveStudents")}
|
onClick={() => router.push("/#inactiveStudents")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
students.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
.length
|
.length
|
||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
@@ -325,17 +355,20 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/#inactiveCountryManagers")}
|
onClick={() => router.push("/#inactiveCountryManagers")}
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
|
isLoading={isAgentsLoading}
|
||||||
label="Inactive Country Managers"
|
label="Inactive Country Managers"
|
||||||
value={users.filter(inactiveCountryManagerFilter).length}
|
value={agents.filter(inactiveCountryManagerFilter).length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/#inactiveCorporate")}
|
onClick={() => router.push("/#inactiveCorporate")}
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
corporates.filter(
|
||||||
.length
|
(x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)),
|
||||||
|
).length
|
||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
@@ -362,6 +395,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("/#corporatestudentslevels")}
|
onClick={() => router.push("/#corporatestudentslevels")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Corporate Students Levels"
|
label="Corporate Students Levels"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
@@ -371,8 +405,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest students</span>
|
<span className="p-4">Latest students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter((x) => x.type === "student")
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -382,8 +415,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest teachers</span>
|
<span className="p-4">Latest teachers</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter((x) => x.type === "teacher")
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return dateSorter(a, b, "desc", "registrationDate");
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
})
|
})
|
||||||
@@ -395,8 +427,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest corporate</span>
|
<span className="p-4">Latest corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter((x) => x.type === "corporate")
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return dateSorter(a, b, "desc", "registrationDate");
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
})
|
})
|
||||||
@@ -408,8 +439,8 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Unpaid Corporate</span>
|
<span className="p-4">Unpaid Corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter((x) => x.type === "corporate" && x.status === "paymentDue")
|
.filter((x) => x.status === "paymentDue")
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -418,10 +449,9 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Students expiring in 1 month</span>
|
<span className="p-4">Students expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "student" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
@@ -434,10 +464,9 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Teachers expiring in 1 month</span>
|
<span className="p-4">Teachers expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "teacher" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
@@ -450,10 +479,9 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Country Manager expiring in 1 month</span>
|
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{agents
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "agent" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
@@ -466,10 +494,9 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Corporate expiring in 1 month</span>
|
<span className="p-4">Corporate expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "corporate" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
@@ -482,10 +509,8 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Students</span>
|
<span className="p-4">Expired Students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -494,10 +519,8 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Teachers</span>
|
<span className="p-4">Expired Teachers</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -506,10 +529,8 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Country Manager</span>
|
<span className="p-4">Expired Country Manager</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{agents
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) => x.type === "agent" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -518,11 +539,8 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Corporate</span>
|
<span className="p-4">Expired Corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) =>
|
|
||||||
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -542,7 +560,10 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
|
if (shouldReload && selectedUser!.type === "agent") reloadAgents();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Checkbox from "@/components/Low/Checkbox";
|
|||||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
@@ -31,7 +32,12 @@ interface Props {
|
|||||||
cancelCreation: () => void;
|
cancelCreation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIZE = 12;
|
||||||
|
|
||||||
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
|
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
|
||||||
|
const [studentsPage, setStudentsPage] = useState(0);
|
||||||
|
const [teachersPage, setTeachersPage] = useState(0);
|
||||||
|
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
||||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||||
const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]);
|
const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]);
|
||||||
@@ -69,6 +75,29 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
|
|||||||
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
||||||
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
||||||
|
|
||||||
|
const {rows: filteredStudentsRows, renderSearch: renderStudentSearch, text: studentText} = useListSearch([["name"], ["email"]], userStudents);
|
||||||
|
const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch, text: teacherText} = useListSearch([["name"], ["email"]], userTeachers);
|
||||||
|
|
||||||
|
useEffect(() => setStudentsPage(0), [studentText]);
|
||||||
|
const studentRows = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredStudentsRows.slice(
|
||||||
|
studentsPage * SIZE,
|
||||||
|
(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE,
|
||||||
|
),
|
||||||
|
[filteredStudentsRows, studentsPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => setTeachersPage(0), [teacherText]);
|
||||||
|
const teacherRows = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredTeachersRows.slice(
|
||||||
|
teachersPage * SIZE,
|
||||||
|
(teachersPage + 1) * SIZE > filteredTeachersRows.length ? filteredTeachersRows.length : (teachersPage + 1) * SIZE,
|
||||||
|
),
|
||||||
|
[filteredTeachersRows, teachersPage],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||||
}, [selectedModules]);
|
}, [selectedModules]);
|
||||||
@@ -347,9 +376,9 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-4">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
@@ -371,8 +400,11 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderStudentSearch()}
|
||||||
|
|
||||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
{userStudents.map((user) => (
|
{studentRows.map((user) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleAssignee(user)}
|
onClick={() => toggleAssignee(user)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -402,12 +434,32 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<Button className="w-[200px] h-fit" disabled={studentsPage === 0} onClick={() => setStudentsPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{studentsPage * SIZE + 1} -{" "}
|
||||||
|
{(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE} /{" "}
|
||||||
|
{filteredStudentsRows.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={(studentsPage + 1) * SIZE >= filteredStudentsRows.length}
|
||||||
|
onClick={() => setStudentsPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-3">
|
||||||
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
||||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
@@ -429,8 +481,11 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderTeacherSearch()}
|
||||||
|
|
||||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
{userTeachers.map((user) => (
|
{teacherRows.map((user) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleTeacher(user)}
|
onClick={() => toggleTeacher(user)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -453,6 +508,29 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<Button className="w-[200px] h-fit" disabled={teachersPage === 0} onClick={() => setTeachersPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{teachersPage * SIZE + 1} -{" "}
|
||||||
|
{(teachersPage + 1) * SIZE > filteredTeachersRows.length
|
||||||
|
? filteredTeachersRows.length
|
||||||
|
: (teachersPage + 1) * SIZE}{" "}
|
||||||
|
/ {filteredTeachersRows.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={(teachersPage + 1) * SIZE >= filteredTeachersRows.length}
|
||||||
|
onClick={() => setTeachersPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,510 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import Modal from "@/components/Modal";
|
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
|
||||||
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User } from "@/interfaces/user";
|
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
|
||||||
import {dateSorter} from "@/utils";
|
|
||||||
import moment from "moment";
|
|
||||||
import {useEffect, useMemo, useState} from "react";
|
|
||||||
import {
|
|
||||||
BsArrowLeft,
|
|
||||||
BsClipboard2Data,
|
|
||||||
BsClipboard2DataFill,
|
|
||||||
BsClock,
|
|
||||||
BsGlobeCentralSouthAsia,
|
|
||||||
BsPaperclip,
|
|
||||||
BsPerson,
|
|
||||||
BsPersonAdd,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPersonFillGear,
|
|
||||||
BsPersonGear,
|
|
||||||
BsPencilSquare,
|
|
||||||
BsPersonBadge,
|
|
||||||
BsPersonCheck,
|
|
||||||
BsPeople,
|
|
||||||
BsArrowRepeat,
|
|
||||||
BsPlus,
|
|
||||||
BsEnvelopePaper,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {groupByExam} from "@/utils/stats";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
|
||||||
import AssignmentView from "./AssignmentView";
|
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import AssignmentCard from "./AssignmentCard";
|
|
||||||
import {createColumnHelper} from "@tanstack/react-table";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import List from "@/components/List";
|
|
||||||
import {getUserCompanyName} from "@/resources/user";
|
|
||||||
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
|
||||||
import useUserBalance from "@/hooks/useUserBalance";
|
|
||||||
import AssignmentsPage from "./views/AssignmentsPage";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: CorporateUser;
|
|
||||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
|
||||||
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
|
|
||||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
columnHelper.accessor("name", {
|
|
||||||
header: "Student Name",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
|
||||||
header: "E-mail",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("demographicInformation.passport_id", {
|
|
||||||
header: "ID",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("group", {
|
|
||||||
header: "Group",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("corporateName", {
|
|
||||||
header: "Corporate",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.reading", {
|
|
||||||
header: "Reading",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.listening", {
|
|
||||||
header: "Listening",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.writing", {
|
|
||||||
header: "Writing",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.speaking", {
|
|
||||||
header: "Speaking",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.level", {
|
|
||||||
header: "Level",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels", {
|
|
||||||
id: "overall_level",
|
|
||||||
header: "Overall",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === info.row.original.id),
|
|
||||||
).toFixed(1)
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
|
||||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
|
||||||
Show Utilization
|
|
||||||
</Checkbox>
|
|
||||||
<List<StudentPerformanceItem>
|
|
||||||
data={items.sort(
|
|
||||||
(a, b) =>
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === b.id),
|
|
||||||
) -
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === a.id),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
columns={columns}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
|
||||||
const {groups} = useGroups({admin: user.id});
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
|
||||||
const {balance} = useUserBalance();
|
|
||||||
|
|
||||||
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
|
||||||
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
|
||||||
|
|
||||||
const assignmentsUsers = useMemo(
|
|
||||||
() =>
|
|
||||||
[...teachers, ...students].filter((x) =>
|
|
||||||
!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id),
|
|
||||||
),
|
|
||||||
[groups, teachers, students, selectedUser],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && router.asPath === "/#");
|
|
||||||
}, [selectedUser, router.asPath]);
|
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
|
||||||
<div
|
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
|
||||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupList user={user} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StudentPerformancePage = () => {
|
|
||||||
const performanceStudents = students.map((u) => ({
|
|
||||||
...u,
|
|
||||||
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
|
||||||
corporateName: getUserCompanyName(user, [], groups),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={reloadStudents}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
|
||||||
const formattedStats = studentStats
|
|
||||||
.map((s) => ({
|
|
||||||
focus: students.find((u) => u.id === s.user)?.focus,
|
|
||||||
score: s.score,
|
|
||||||
module: s.module,
|
|
||||||
}))
|
|
||||||
.filter((f) => !!f.focus);
|
|
||||||
const bandScores = formattedStats.map((s) => ({
|
|
||||||
module: s.module,
|
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {
|
|
||||||
reading: 0,
|
|
||||||
listening: 0,
|
|
||||||
writing: 0,
|
|
||||||
speaking: 0,
|
|
||||||
level: 0,
|
|
||||||
};
|
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
|
||||||
<>
|
|
||||||
{!!linkedCorporate && (
|
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
|
||||||
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
|
||||||
<IconCard
|
|
||||||
onClick={() => router.push("/#students")}
|
|
||||||
isLoading={isStudentsLoading}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Students"
|
|
||||||
value={students.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => router.push("/#teachers")}
|
|
||||||
isLoading={isTeachersLoading}
|
|
||||||
Icon={BsPencilSquare}
|
|
||||||
label="Teachers"
|
|
||||||
value={teachers.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClipboard2Data}
|
|
||||||
label="Exams Performed"
|
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPaperclip}
|
|
||||||
isLoading={isStudentsLoading}
|
|
||||||
label="Average Level"
|
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonCheck}
|
|
||||||
label="User Balance"
|
|
||||||
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClock}
|
|
||||||
label="Expiration Date"
|
|
||||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
|
||||||
color="rose"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonFillGear}
|
|
||||||
isLoading={isStudentsLoading}
|
|
||||||
label="Student Performance"
|
|
||||||
value={students.length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => router.push("/#studentsPerformance")}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
disabled={isAssignmentsLoading}
|
|
||||||
onClick={() => router.push("/#assignments")}
|
|
||||||
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
|
||||||
<span className="text-lg">Assignments</span>
|
|
||||||
<span className="font-semibold text-mti-purple-light">
|
|
||||||
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest teachers</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{teachers
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest level students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest exam count students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
|
||||||
)
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
|
||||||
<>
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="w-full flex flex-col gap-8">
|
|
||||||
<UserCard
|
|
||||||
loggedInUser={user}
|
|
||||||
onClose={(shouldReload) => {
|
|
||||||
setSelectedUser(undefined);
|
|
||||||
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
|
||||||
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
|
||||||
}}
|
|
||||||
onViewStudents={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
user={selectedUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
{router.asPath === "/#students" && (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
type="student"
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#teachers" && (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
type="teacher"
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#groups" && <GroupsList />}
|
|
||||||
{router.asPath === "/#assignments" && (
|
|
||||||
<AssignmentsPage
|
|
||||||
assignments={assignments}
|
|
||||||
user={user}
|
|
||||||
groups={assignmentsGroups}
|
|
||||||
users={assignmentsUsers}
|
|
||||||
reloadAssignments={reloadAssignments}
|
|
||||||
isLoading={isAssignmentsLoading}
|
|
||||||
onBack={() => router.push("/")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
|
|
||||||
{router.asPath === "/" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
45
src/dashboards/Corporate/MasterStatisticalPage.tsx
Normal file
45
src/dashboards/Corporate/MasterStatisticalPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import useUsers, {userHashStudent, userHashTeacher} from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {BsArrowLeft} from "react-icons/bs";
|
||||||
|
import MasterStatistical from "../MasterCorporate/MasterStatistical";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: CorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MasterStatisticalPage = ({user}: Props) => {
|
||||||
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: teachers} = useUsers(userHashTeacher);
|
||||||
|
|
||||||
|
// this workaround will allow us toreuse the master statistical due to master corporate restraints
|
||||||
|
// while still being able to use the corporate user
|
||||||
|
const groupedByNameCorporateIds = useMemo(
|
||||||
|
() => ({
|
||||||
|
[user.corporateInformation?.companyInformation?.name || user.name]: [user.id],
|
||||||
|
}),
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const teachersAndStudents = useMemo(() => [...students, ...teachers], [students, teachers]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<MasterStatistical users={teachersAndStudents} corporateUsers={groupedByNameCorporateIds} displaySelection={false} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatisticalPage;
|
||||||
154
src/dashboards/Corporate/StudentPerformanceList.tsx
Normal file
154
src/dashboards/Corporate/StudentPerformanceList.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClipboard2DataFill,
|
||||||
|
BsClock,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonAdd,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsPersonGear,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonBadge,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPlus,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import AssignmentView from "../AssignmentView";
|
||||||
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import AssignmentCard from "../AssignmentCard";
|
||||||
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
|
|
||||||
|
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
||||||
|
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
|
||||||
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Student Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("demographicInformation.passport_id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("group", {
|
||||||
|
header: "Group",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporateName", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.reading", {
|
||||||
|
header: "Reading",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.listening", {
|
||||||
|
header: "Listening",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.writing", {
|
||||||
|
header: "Writing",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.speaking", {
|
||||||
|
header: "Speaking",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.level", {
|
||||||
|
header: "Level",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels", {
|
||||||
|
id: "overall_level",
|
||||||
|
header: "Overall",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === info.row.original.id),
|
||||||
|
).toFixed(1)
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||||
|
Show Utilization
|
||||||
|
</Checkbox>
|
||||||
|
<List<StudentPerformanceItem>
|
||||||
|
data={items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === b.id),
|
||||||
|
) -
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === a.id),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformanceList;
|
||||||
49
src/dashboards/Corporate/StudentPerformancePage.tsx
Normal file
49
src/dashboards/Corporate/StudentPerformancePage.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useUsers, {userHashStudent} from "@/hooks/useUsers";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import StudentPerformanceList from "./StudentPerformanceList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudentPerformancePage = ({user}: Props) => {
|
||||||
|
const {groups} = useGroups({admin: user.id});
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const performanceStudents = students.map((u) => ({
|
||||||
|
...u,
|
||||||
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
|
corporateName: getUserCompanyName(user, [], groups),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadStudents}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformancePage;
|
||||||
546
src/dashboards/Corporate/index.tsx
Normal file
546
src/dashboards/Corporate/index.tsx
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers, {
|
||||||
|
userHashStudent,
|
||||||
|
userHashTeacher,
|
||||||
|
userHashCorporate,
|
||||||
|
} from "@/hooks/useUsers";
|
||||||
|
import {
|
||||||
|
CorporateUser,
|
||||||
|
Group,
|
||||||
|
MasterCorporateUser,
|
||||||
|
Stat,
|
||||||
|
User,
|
||||||
|
} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import { dateSorter } from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClipboard2DataFill,
|
||||||
|
BsClock,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonAdd,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsPersonGear,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonBadge,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPlus,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
BsDatabase,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {
|
||||||
|
averageLevelCalculator,
|
||||||
|
calculateAverageLevel,
|
||||||
|
calculateBandScore,
|
||||||
|
} from "@/utils/score";
|
||||||
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { groupByExam } from "@/utils/stats";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import { getUserCorporate } from "@/utils/groups";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import AssignmentView from "../AssignmentView";
|
||||||
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import AssignmentCard from "../AssignmentCard";
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import { getUserCompanyName } from "@/resources/user";
|
||||||
|
import {
|
||||||
|
futureAssignmentFilter,
|
||||||
|
pastAssignmentFilter,
|
||||||
|
archivedAssignmentFilter,
|
||||||
|
activeAssignmentFilter,
|
||||||
|
} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
|
import StudentPerformancePage from "./StudentPerformancePage";
|
||||||
|
import MasterStatistical from "../MasterCorporate/MasterStatistical";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: CorporateUser;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherHash = {
|
||||||
|
type: "teacher",
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CorporateDashboard({ user, linkedCorporate }: Props) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: stats } = useFilterRecordsByUser<Stat[]>();
|
||||||
|
const { groups } = useGroups({ admin: user.id });
|
||||||
|
const {
|
||||||
|
assignments,
|
||||||
|
isLoading: isAssignmentsLoading,
|
||||||
|
reload: reloadAssignments,
|
||||||
|
} = useAssignments({ corporate: user.id });
|
||||||
|
const { balance } = useUserBalance();
|
||||||
|
|
||||||
|
const {
|
||||||
|
users: students,
|
||||||
|
total: totalStudents,
|
||||||
|
reload: reloadStudents,
|
||||||
|
isLoading: isStudentsLoading,
|
||||||
|
} = useUsers(studentHash);
|
||||||
|
const {
|
||||||
|
users: teachers,
|
||||||
|
total: totalTeachers,
|
||||||
|
reload: reloadTeachers,
|
||||||
|
isLoading: isTeachersLoading,
|
||||||
|
} = useUsers(teacherHash);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const assignmentsGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
groups.filter(
|
||||||
|
(x) => x.admin === user.id || x.participants.includes(user.id)
|
||||||
|
),
|
||||||
|
[groups, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignmentsUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
[...teachers, ...students].filter((x) =>
|
||||||
|
!!selectedUser
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id) || false
|
||||||
|
: groups.flatMap((g) => g.participants).includes(x.id)
|
||||||
|
),
|
||||||
|
[groups, teachers, students, selectedUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
|
const getStatsByStudent = (user: User) =>
|
||||||
|
stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
|
const UserDisplay = (displayUser: User) => (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={displayUser.profilePicture}
|
||||||
|
alt={displayUser.name}
|
||||||
|
className="rounded-full w-10 h-10"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>{displayUser.name}</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// this workaround will allow us toreuse the master statistical due to master corporate restraints
|
||||||
|
// while still being able to use the corporate user
|
||||||
|
const groupedByNameCorporateIds = useMemo(
|
||||||
|
() => ({
|
||||||
|
[user.corporateInformation?.companyInformation?.name || user.name]: [
|
||||||
|
user.id,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[user]
|
||||||
|
);
|
||||||
|
const teachersAndStudents = useMemo(
|
||||||
|
() => [...students, ...teachers],
|
||||||
|
[students, teachers]
|
||||||
|
);
|
||||||
|
const MasterStatisticalPage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<MasterStatistical
|
||||||
|
users={teachersAndStudents}
|
||||||
|
corporateUsers={groupedByNameCorporateIds}
|
||||||
|
displaySelection={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupsList = () => {
|
||||||
|
const filter = (x: Group) =>
|
||||||
|
x.admin === user.id || x.participants.includes(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Groups ({groups.filter(filter).length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupList user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
|
const formattedStats = studentStats
|
||||||
|
.map((s) => ({
|
||||||
|
focus: students.find((u) => u.id === s.user)?.focus,
|
||||||
|
score: s.score,
|
||||||
|
module: s.module,
|
||||||
|
}))
|
||||||
|
.filter((f) => !!f.focus);
|
||||||
|
const bandScores = formattedStats.map((s) => ({
|
||||||
|
module: s.module,
|
||||||
|
level: calculateBandScore(
|
||||||
|
s.score.correct,
|
||||||
|
s.score.total,
|
||||||
|
s.module,
|
||||||
|
s.focus!
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const levels: { [key in Module]: number } = {
|
||||||
|
reading: 0,
|
||||||
|
listening: 0,
|
||||||
|
writing: 0,
|
||||||
|
speaking: 0,
|
||||||
|
level: 0,
|
||||||
|
};
|
||||||
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
|
return calculateAverageLevel(levels);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (router.asPath === "/#students")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#teachers")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="teacher"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
|
if (router.asPath === "/#studentsPerformance")
|
||||||
|
return <StudentPerformancePage user={user} />;
|
||||||
|
|
||||||
|
if (router.asPath === "/#assignments")
|
||||||
|
return (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
user={user}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#statistical") return <MasterStatisticalPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload && selectedUser!.type === "student")
|
||||||
|
reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher")
|
||||||
|
reloadTeachers();
|
||||||
|
}}
|
||||||
|
onViewStudents={
|
||||||
|
selectedUser.type === "corporate" ||
|
||||||
|
selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter(
|
||||||
|
(g) =>
|
||||||
|
g.admin === selectedUser.id ||
|
||||||
|
g.participants.includes(selectedUser.id)
|
||||||
|
)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" ||
|
||||||
|
selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter(
|
||||||
|
(g) =>
|
||||||
|
g.admin === selectedUser.id ||
|
||||||
|
g.participants.includes(selectedUser.id)
|
||||||
|
)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<>
|
||||||
|
{!!linkedCorporate && (
|
||||||
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
|
Linked to:{" "}
|
||||||
|
<b>
|
||||||
|
{linkedCorporate?.corporateInformation?.companyInformation.name ||
|
||||||
|
linkedCorporate.name}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label="Students"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#teachers")}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
|
Icon={BsPencilSquare}
|
||||||
|
label="Teachers"
|
||||||
|
value={totalTeachers}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={
|
||||||
|
stats.filter((s) =>
|
||||||
|
groups.flatMap((g) => g.participants).includes(s.user)
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Average Level"
|
||||||
|
value={averageLevelCalculator(
|
||||||
|
stats.filter((s) =>
|
||||||
|
groups.flatMap((g) => g.participants).includes(s.user)
|
||||||
|
)
|
||||||
|
).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#groups")}
|
||||||
|
Icon={BsPeople}
|
||||||
|
label="Groups"
|
||||||
|
value={groups.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonCheck}
|
||||||
|
label="User Balance"
|
||||||
|
value={`${balance}/${
|
||||||
|
user.corporateInformation?.companyInformation?.userAmount || 0
|
||||||
|
}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClock}
|
||||||
|
label="Expiration Date"
|
||||||
|
value={
|
||||||
|
user.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
|
||||||
|
: "Unlimited"
|
||||||
|
}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsDatabase}
|
||||||
|
label="Master Statistical"
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#statistical")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isAssignmentsLoading}
|
||||||
|
onClick={() => router.push("/#assignments")}
|
||||||
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">
|
||||||
|
{isAssignmentsLoading
|
||||||
|
? "Loading..."
|
||||||
|
: assignments.filter((a) => !a.archived).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest teachers</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{teachers
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
calculateAverageLevel(b.levels) -
|
||||||
|
calculateAverageLevel(a.levels)
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length -
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(a))).length
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import Modal from "@/components/Modal";
|
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
|
||||||
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
|
||||||
import {dateSorter} from "@/utils";
|
|
||||||
import moment from "moment";
|
|
||||||
import {useEffect, useState, useMemo} from "react";
|
|
||||||
import {
|
|
||||||
BsArrowLeft,
|
|
||||||
BsClipboard2Data,
|
|
||||||
BsClock,
|
|
||||||
BsPaperclip,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPencilSquare,
|
|
||||||
BsPersonCheck,
|
|
||||||
BsPeople,
|
|
||||||
BsBank,
|
|
||||||
BsEnvelopePaper,
|
|
||||||
BsArrowRepeat,
|
|
||||||
BsPlus,
|
|
||||||
BsPersonFillGear,
|
|
||||||
BsFilter,
|
|
||||||
BsDatabase,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
|
|
||||||
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {groupByExam} from "@/utils/stats";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
|
||||||
import AssignmentView from "./AssignmentView";
|
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import AssignmentCard from "./AssignmentCard";
|
|
||||||
import {createColumn, createColumnHelper} from "@tanstack/react-table";
|
|
||||||
import List from "@/components/List";
|
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
|
||||||
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import {groupBy, uniq, uniqBy} from "lodash";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
|
|
||||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
|
||||||
import MasterStatistical from "./MasterStatistical";
|
|
||||||
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
|
||||||
import useUserBalance from "@/hooks/useUserBalance";
|
|
||||||
import AssignmentsPage from "./views/AssignmentsPage";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: MasterCorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StudentPerformanceItem = User & {
|
|
||||||
corporate?: CorporateUser;
|
|
||||||
group?: Group;
|
|
||||||
};
|
|
||||||
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
|
||||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
|
||||||
const [availableCorporates] = useState(
|
|
||||||
uniqBy(
|
|
||||||
items.map((x) => x.corporate),
|
|
||||||
"id",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const [availableGroups] = useState(
|
|
||||||
uniqBy(
|
|
||||||
items.map((x) => x.group),
|
|
||||||
"id",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
|
|
||||||
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
columnHelper.accessor("name", {
|
|
||||||
header: "Student Name",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
|
||||||
header: "E-mail",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("demographicInformation.passport_id", {
|
|
||||||
header: "ID",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("group", {
|
|
||||||
header: "Group",
|
|
||||||
cell: (info) => info.getValue()?.name || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("corporate", {
|
|
||||||
header: "Corporate",
|
|
||||||
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.reading", {
|
|
||||||
header: "Reading",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.listening", {
|
|
||||||
header: "Listening",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.writing", {
|
|
||||||
header: "Writing",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.speaking", {
|
|
||||||
header: "Speaking",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.level", {
|
|
||||||
header: "Level",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels", {
|
|
||||||
id: "overall_level",
|
|
||||||
header: "Overall",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === info.row.original.id),
|
|
||||||
).toFixed(1)
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const filterUsers = (data: StudentPerformanceItem[]) => {
|
|
||||||
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
|
||||||
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
|
||||||
|
|
||||||
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
|
|
||||||
if (selectedCorporate !== null) filters.push(filterByCorporate);
|
|
||||||
if (selectedGroup !== null) filters.push(filterByGroup);
|
|
||||||
|
|
||||||
return filters.reduce((d, f) => d.filter(f), data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
|
||||||
<div className="w-full flex gap-4 justify-between items-center">
|
|
||||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
|
||||||
Show Utilization
|
|
||||||
</Checkbox>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger>
|
|
||||||
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
|
|
||||||
<BsFilter size={20} />
|
|
||||||
</div>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-96">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-bold text-lg">Filters</span>
|
|
||||||
<Select
|
|
||||||
options={availableCorporates.map((x) => ({
|
|
||||||
value: x?.id || "N/A",
|
|
||||||
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
|
|
||||||
}))}
|
|
||||||
isClearable
|
|
||||||
value={
|
|
||||||
selectedCorporate === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
value: selectedCorporate?.id || "N/A",
|
|
||||||
label:
|
|
||||||
selectedCorporate?.corporateInformation?.companyInformation?.name ||
|
|
||||||
selectedCorporate?.name ||
|
|
||||||
"N/A",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeholder="Select a Corporate..."
|
|
||||||
onChange={(value) =>
|
|
||||||
!value
|
|
||||||
? setSelectedCorporate(null)
|
|
||||||
: setSelectedCorporate(
|
|
||||||
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
options={availableGroups.map((x) => ({
|
|
||||||
value: x?.id || "N/A",
|
|
||||||
label: x?.name || "N/A",
|
|
||||||
}))}
|
|
||||||
isClearable
|
|
||||||
value={
|
|
||||||
selectedGroup === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
value: selectedGroup?.id || "N/A",
|
|
||||||
label: selectedGroup?.name || "N/A",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeholder="Select a Group..."
|
|
||||||
onChange={(value) =>
|
|
||||||
!value
|
|
||||||
? setSelectedGroup(null)
|
|
||||||
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<List<StudentPerformanceItem>
|
|
||||||
data={filterUsers(
|
|
||||||
items.sort(
|
|
||||||
(a, b) =>
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === b.id),
|
|
||||||
) -
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === a.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
columns={columns}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MasterCorporateDashboard({user}: Props) {
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
|
||||||
|
|
||||||
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
|
||||||
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
|
|
||||||
const {users: corporates, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(userHashCorporate);
|
|
||||||
|
|
||||||
const {groups} = useGroups({admin: user.id, userType: user.type});
|
|
||||||
const {balance} = useUserBalance();
|
|
||||||
|
|
||||||
const users = useMemo(() => uniqBy([...students, ...teachers, ...corporates, user], "id"), [corporates, students, teachers, user]);
|
|
||||||
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
|
||||||
|
|
||||||
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
|
||||||
const assignmentsUsers = useMemo(
|
|
||||||
() =>
|
|
||||||
[...students, ...teachers].filter((x) =>
|
|
||||||
!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id),
|
|
||||||
),
|
|
||||||
[groups, selectedUser, teachers, students],
|
|
||||||
);
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && router.asPath === "/");
|
|
||||||
}, [selectedUser, router.asPath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCorporateAssignments(
|
|
||||||
assignments.filter(activeAssignmentFilter).map((a) => {
|
|
||||||
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...a,
|
|
||||||
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [assignments, groups, teachers, corporates]);
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
|
||||||
<div
|
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const corporateUserFilter = (x: User) => x.type === "corporate";
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupList user={user} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StudentPerformancePage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={reloadAssignments}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MasterStatisticalPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
|
||||||
</div>
|
|
||||||
<MasterStatistical users={users} corporateUsers={corporates} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
|
||||||
<>
|
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
|
||||||
<IconCard
|
|
||||||
onClick={() => router.push("/#students")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
isLoading={isStudentsLoading}
|
|
||||||
label="Students"
|
|
||||||
value={students.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => router.push("/#teachers")}
|
|
||||||
Icon={BsPencilSquare}
|
|
||||||
isLoading={isTeachersLoading}
|
|
||||||
label="Teachers"
|
|
||||||
value={teachers.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClipboard2Data}
|
|
||||||
label="Exams Performed"
|
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPaperclip}
|
|
||||||
label="Average Level"
|
|
||||||
value={averageLevelCalculator(
|
|
||||||
students,
|
|
||||||
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
|
||||||
).toFixed(1)}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonCheck}
|
|
||||||
label="User Balance"
|
|
||||||
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClock}
|
|
||||||
label="Expiration Date"
|
|
||||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
|
||||||
color="rose"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsBank}
|
|
||||||
label="Corporate"
|
|
||||||
value={corporates.length}
|
|
||||||
isLoading={isCorporatesLoading}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => router.push("/#corporate")}
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonFillGear}
|
|
||||||
isLoading={isStudentsLoading}
|
|
||||||
label="Student Performance"
|
|
||||||
value={students.length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => router.push("/#studentsPerformance")}
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsDatabase}
|
|
||||||
label="Master Statistical"
|
|
||||||
// value={masterCorporateUserGroups.length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => router.push("/#statistical")}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
disabled={isAssignmentsLoading}
|
|
||||||
onClick={() => router.push("/#assignments")}
|
|
||||||
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
|
||||||
<span className="text-lg">Assignments</span>
|
|
||||||
<span className="font-semibold text-mti-purple-light">
|
|
||||||
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest teachers</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{teachers
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest level students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest exam count students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
|
||||||
)
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
|
||||||
<>
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="w-full flex flex-col gap-8">
|
|
||||||
<UserCard
|
|
||||||
maxUserAmount={
|
|
||||||
user.type === "mastercorporate"
|
|
||||||
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
loggedInUser={user}
|
|
||||||
onClose={(shouldReload) => {
|
|
||||||
setSelectedUser(undefined);
|
|
||||||
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
|
||||||
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
|
||||||
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
|
||||||
}}
|
|
||||||
onViewStudents={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
user={selectedUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
{router.asPath === "/#students" && (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
type="student"
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#teachers" && (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
type="teacher"
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#groups" && <GroupsList />}
|
|
||||||
{router.asPath === "/#corporate" && (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
type="corporate"
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#assignments" && (
|
|
||||||
<AssignmentsPage
|
|
||||||
assignments={assignments}
|
|
||||||
corporateAssignments={corporateAssignments}
|
|
||||||
groups={assignmentsGroups}
|
|
||||||
user={user}
|
|
||||||
users={assignmentsUsers}
|
|
||||||
reloadAssignments={reloadAssignments}
|
|
||||||
isLoading={isAssignmentsLoading}
|
|
||||||
onBack={() => router.push("/")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
|
|
||||||
{router.asPath === "/#statistical" && <MasterStatisticalPage />}
|
|
||||||
{router.asPath === "/" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
405
src/dashboards/MasterCorporate/MasterStatistical.tsx
Normal file
405
src/dashboards/MasterCorporate/MasterStatistical.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import {BsFileExcel, BsBank, BsPersonFill} from "react-icons/bs";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
|
||||||
|
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
|
||||||
|
import moment from "moment";
|
||||||
|
import {AssignmentWithCorporateId} from "@/interfaces/results";
|
||||||
|
import {flexRender, createColumnHelper, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import axios from "axios";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import {getUserName} from "@/utils/users";
|
||||||
|
|
||||||
|
interface GroupedCorporateUsers {
|
||||||
|
// list of user Ids
|
||||||
|
[key: string]: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
corporateUsers: GroupedCorporateUsers;
|
||||||
|
users: User[];
|
||||||
|
displaySelection?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
user: string;
|
||||||
|
email: string;
|
||||||
|
correct: number;
|
||||||
|
corporate: string;
|
||||||
|
submitted: boolean;
|
||||||
|
date: moment.Moment;
|
||||||
|
assignment: string;
|
||||||
|
corporateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCount {
|
||||||
|
userCount: number;
|
||||||
|
maxUserCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilters = [["email"], ["user"], ["userId"], ["exams"], ["assignment"]];
|
||||||
|
|
||||||
|
const SIZE = 16;
|
||||||
|
|
||||||
|
const MasterStatistical = (props: Props) => {
|
||||||
|
const {users, corporateUsers, displaySelection = true} = props;
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// const corporateRelevantUsers = React.useMemo(
|
||||||
|
// () => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
|
||||||
|
// [corporateUsers]
|
||||||
|
// );
|
||||||
|
|
||||||
|
const corporates = React.useMemo(() => Object.values(corporateUsers).flat(), [corporateUsers]);
|
||||||
|
|
||||||
|
const [selectedCorporates, setSelectedCorporates] = React.useState<string[]>(corporates);
|
||||||
|
const [startDate, setStartDate] = React.useState<Date | null>(moment("01/01/2023").toDate());
|
||||||
|
const [endDate, setEndDate] = React.useState<Date | null>(moment().endOf("year").toDate());
|
||||||
|
|
||||||
|
const {assignments} = useAssignmentsCorporates({
|
||||||
|
corporates: selectedCorporates,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [downloading, setDownloading] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
const tableResults = React.useMemo(
|
||||||
|
() =>
|
||||||
|
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
|
||||||
|
const userResults = a.assignees.map((assignee) => {
|
||||||
|
const userStats = a.results.find((r) => r.user === assignee)?.stats || [];
|
||||||
|
const userData = users.find((u) => u.id === assignee);
|
||||||
|
const corporate = getUserName(users.find((u) => u.id === a.assigner));
|
||||||
|
const commonData = {
|
||||||
|
user: userData?.name || "N/A",
|
||||||
|
email: userData?.email || "N/A",
|
||||||
|
userId: assignee,
|
||||||
|
corporateId: a.corporateId,
|
||||||
|
exams: a.exams.map((x) => x.id).join(", "),
|
||||||
|
corporate,
|
||||||
|
assignment: a.name,
|
||||||
|
};
|
||||||
|
if (userStats.length === 0) {
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: 0,
|
||||||
|
submitted: false,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
|
||||||
|
submitted: true,
|
||||||
|
date: moment.max(userStats.map((e) => moment(e.date))),
|
||||||
|
};
|
||||||
|
}) as TableData[];
|
||||||
|
|
||||||
|
return [...accmA, ...userResults];
|
||||||
|
}, []),
|
||||||
|
[assignments, users],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => console.log(assignments), [assignments]);
|
||||||
|
|
||||||
|
const getCorporateScores = (corporateId: string): UserCount => {
|
||||||
|
const corporateAssignmentsUsers = assignments.filter((a) => a.corporateId === corporateId).reduce((acc, a) => acc + a.assignees.length, 0);
|
||||||
|
|
||||||
|
const corporateResults = tableResults.filter((r) => r.corporateId === corporateId).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxUserCount: corporateAssignmentsUsers,
|
||||||
|
userCount: corporateResults,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCorporatesScoresHash = (data: string[]) =>
|
||||||
|
data.reduce(
|
||||||
|
(accm, id) => ({
|
||||||
|
...accm,
|
||||||
|
[id]: getCorporateScores(id),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
) as Record<string, UserCount>;
|
||||||
|
|
||||||
|
const getConsolidateScore = (data: Record<string, UserCount>) =>
|
||||||
|
Object.values(data).reduce(
|
||||||
|
(acc: UserCount, {userCount, maxUserCount}: UserCount) => ({
|
||||||
|
userCount: acc.userCount + userCount,
|
||||||
|
maxUserCount: acc.maxUserCount + maxUserCount,
|
||||||
|
}),
|
||||||
|
{userCount: 0, maxUserCount: 0},
|
||||||
|
);
|
||||||
|
|
||||||
|
const corporateScores = getCorporatesScoresHash(corporates);
|
||||||
|
const consolidateScore = getConsolidateScore(corporateScores);
|
||||||
|
|
||||||
|
const getConsolidateScoreStr = (data: UserCount) => `${data.userCount}/${data.maxUserCount}`;
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TableData>();
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("user", {
|
||||||
|
header: "User",
|
||||||
|
id: "user",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "Email",
|
||||||
|
id: "email",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(displaySelection
|
||||||
|
? [
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
id: "corporate",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
columnHelper.accessor("assignment", {
|
||||||
|
header: "Assignment",
|
||||||
|
id: "assignment",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("submitted", {
|
||||||
|
header: "Submitted",
|
||||||
|
id: "submitted",
|
||||||
|
cell: (info) => {
|
||||||
|
return (
|
||||||
|
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
|
||||||
|
<span></span>
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("correct", {
|
||||||
|
header: "Score",
|
||||||
|
id: "correct",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("date", {
|
||||||
|
header: "Date",
|
||||||
|
id: "date",
|
||||||
|
cell: (info) => {
|
||||||
|
const date = info.getValue();
|
||||||
|
if (date) {
|
||||||
|
return <span>{!!date ? date.format("DD/MM/YYYY") : "N/A"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{""}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults);
|
||||||
|
|
||||||
|
useEffect(() => setPage(0), [searchText]);
|
||||||
|
const rows = useMemo(
|
||||||
|
() => filteredRows.slice(page * SIZE, (page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE),
|
||||||
|
[filteredRows, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const areAllSelected = selectedCorporates.length === corporates.length;
|
||||||
|
|
||||||
|
const getStudentsConsolidateScore = () => {
|
||||||
|
if (tableResults.length === 0) {
|
||||||
|
return {highest: null, lowest: null};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the student with the highest and lowest score
|
||||||
|
return tableResults.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
if (curr.correct > acc.highest.correct) {
|
||||||
|
acc.highest = curr;
|
||||||
|
}
|
||||||
|
if (curr.correct < acc.lowest.correct) {
|
||||||
|
acc.lowest = curr;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{highest: tableResults[0], lowest: tableResults[0]},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = async () => {
|
||||||
|
try {
|
||||||
|
setDownloading(true);
|
||||||
|
const res = await axios.post("/api/assignments/statistical/excel", {
|
||||||
|
ids: selectedCorporates,
|
||||||
|
...(startDate ? {startDate: startDate.toISOString()} : {}),
|
||||||
|
...(endDate ? {endDate: endDate.toISOString()} : {}),
|
||||||
|
searchText,
|
||||||
|
});
|
||||||
|
toast.success("Report ready!");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = res.data;
|
||||||
|
// download should have worked but there are some CORS issues
|
||||||
|
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
|
||||||
|
// link.download="report.pdf";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noreferrer";
|
||||||
|
link.click();
|
||||||
|
setDownloading(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to display the report!");
|
||||||
|
console.error(err);
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const consolidateResults = getStudentsConsolidateScore();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displaySelection && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Consolidate"
|
||||||
|
value={getConsolidateScoreStr(consolidateScore)}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (areAllSelected) {
|
||||||
|
setSelectedCorporates([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates(corporates);
|
||||||
|
}}
|
||||||
|
isSelected={areAllSelected}
|
||||||
|
/>
|
||||||
|
{Object.keys(corporateUsers).map((corporateName) => {
|
||||||
|
const group = corporateUsers[corporateName];
|
||||||
|
const isSelected = group.every((id) => selectedCorporates.includes(id));
|
||||||
|
|
||||||
|
const valueHash = getCorporatesScoresHash(group);
|
||||||
|
const value = getConsolidateScoreStr(getConsolidateScore(valueHash));
|
||||||
|
return (
|
||||||
|
<IconCard
|
||||||
|
key={corporateName}
|
||||||
|
Icon={BsBank}
|
||||||
|
label={corporateName}
|
||||||
|
value={value}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedCorporates((prev) => prev.filter((x) => !group.includes(x)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates((prev) => [...new Set([...prev, ...group])]);
|
||||||
|
}}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Date</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
selected={startDate}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
selectsRange
|
||||||
|
showMonthDropdown
|
||||||
|
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
||||||
|
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
||||||
|
if (finalDate) {
|
||||||
|
// basicly selecting a final day works as if I'm selecting the first
|
||||||
|
// minute of that day. this way it covers the whole day
|
||||||
|
setEndDate(moment(finalDate).endOf("day").toDate());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{renderSearch()}
|
||||||
|
<div className="flex flex-col gap-3 justify-end">
|
||||||
|
<Button className="max-w-[200px] h-[70px]" variant="outline" onClick={triggerDownload}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<div className="w-full flex gap-2 justify-between">
|
||||||
|
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{page * SIZE + 1} - {(page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE} /{" "}
|
||||||
|
{filteredRows.length}
|
||||||
|
</span>
|
||||||
|
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= rows.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="p-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
{consolidateResults.highest && (
|
||||||
|
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Highest result: ${consolidateResults.highest.user}`} color="purple" />
|
||||||
|
)}
|
||||||
|
{consolidateResults.lowest && (
|
||||||
|
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Lowest result: ${consolidateResults.lowest.user}`} color="purple" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatistical;
|
||||||
43
src/dashboards/MasterCorporate/MasterStatisticalPage.tsx
Normal file
43
src/dashboards/MasterCorporate/MasterStatisticalPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import {groupBy} from "lodash";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {BsArrowLeft} from "react-icons/bs";
|
||||||
|
import MasterStatistical from "./MasterStatistical";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupedByNameCorporates: Record<string, CorporateUser[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MasterStatisticalPage = ({ groupedByNameCorporates }: Props) => {
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const groupedByNameCorporateIds = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(groupedByNameCorporates).reduce((accm, x) => {
|
||||||
|
const corporateUserIds = (groupedByNameCorporates[x] as CorporateUser[]).map((y) => y.id);
|
||||||
|
return {...accm, [x]: corporateUserIds};
|
||||||
|
}, {}),
|
||||||
|
[groupedByNameCorporates],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<MasterStatistical users={users} corporateUsers={groupedByNameCorporateIds} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatisticalPage;
|
||||||
252
src/dashboards/MasterCorporate/StudentPerformanceList.tsx
Normal file
252
src/dashboards/MasterCorporate/StudentPerformanceList.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {BsFilter} from "react-icons/bs";
|
||||||
|
|
||||||
|
import {averageLevelCalculator, calculateBandScore} from "@/utils/score";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
type StudentPerformanceItem = User & {
|
||||||
|
corporate?: CorporateUser;
|
||||||
|
group?: Group;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
||||||
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
const [availableCorporates] = useState(
|
||||||
|
uniqBy(
|
||||||
|
items.map((x) => x.corporate),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [availableGroups] = useState(
|
||||||
|
uniqBy(
|
||||||
|
items.map((x) => x.group),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Student Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("demographicInformation.passport_id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("group", {
|
||||||
|
header: "Group",
|
||||||
|
cell: (info) => info.getValue()?.name || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.reading", {
|
||||||
|
header: "Reading",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.listening", {
|
||||||
|
header: "Listening",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.writing", {
|
||||||
|
header: "Writing",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.speaking", {
|
||||||
|
header: "Speaking",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.level", {
|
||||||
|
header: "Level",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels", {
|
||||||
|
id: "overall_level",
|
||||||
|
header: "Overall",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === info.row.original.id),
|
||||||
|
).toFixed(1)
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterUsers = (data: StudentPerformanceItem[]) => {
|
||||||
|
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
||||||
|
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
||||||
|
|
||||||
|
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
|
||||||
|
if (selectedCorporate !== null) filters.push(filterByCorporate);
|
||||||
|
if (selectedGroup !== null) filters.push(filterByGroup);
|
||||||
|
|
||||||
|
return filters.reduce((d, f) => d.filter(f), data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
<div className="w-full flex gap-4 justify-between items-center">
|
||||||
|
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||||
|
Show Utilization
|
||||||
|
</Checkbox>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
|
||||||
|
<BsFilter size={20} />
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-96">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-bold text-lg">Filters</span>
|
||||||
|
<Select
|
||||||
|
options={availableCorporates.map((x) => ({
|
||||||
|
value: x?.id || "N/A",
|
||||||
|
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
|
||||||
|
}))}
|
||||||
|
isClearable
|
||||||
|
value={
|
||||||
|
selectedCorporate === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
value: selectedCorporate?.id || "N/A",
|
||||||
|
label:
|
||||||
|
selectedCorporate?.corporateInformation?.companyInformation?.name ||
|
||||||
|
selectedCorporate?.name ||
|
||||||
|
"N/A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholder="Select a Corporate..."
|
||||||
|
onChange={(value) =>
|
||||||
|
!value
|
||||||
|
? setSelectedCorporate(null)
|
||||||
|
: setSelectedCorporate(
|
||||||
|
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={availableGroups.map((x) => ({
|
||||||
|
value: x?.id || "N/A",
|
||||||
|
label: x?.name || "N/A",
|
||||||
|
}))}
|
||||||
|
isClearable
|
||||||
|
value={
|
||||||
|
selectedGroup === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
value: selectedGroup?.id || "N/A",
|
||||||
|
label: selectedGroup?.name || "N/A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholder="Select a Group..."
|
||||||
|
onChange={(value) =>
|
||||||
|
!value
|
||||||
|
? setSelectedGroup(null)
|
||||||
|
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<List<StudentPerformanceItem>
|
||||||
|
data={filterUsers(
|
||||||
|
items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === b.id),
|
||||||
|
) -
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === a.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformanceList;
|
||||||
46
src/dashboards/MasterCorporate/StudentPerformancePage.tsx
Normal file
46
src/dashboards/MasterCorporate/StudentPerformancePage.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useUsers, {userHashCorporate, userHashStudent} from "@/hooks/useUsers";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import StudentPerformanceList from "./StudentPerformanceList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudentPerformancePage = ({user}: Props) => {
|
||||||
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const {reload: reloadAssignments, isLoading: isAssignmentsLoading} = useAssignments({corporate: user.id});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadAssignments}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformancePage;
|
||||||
448
src/dashboards/MasterCorporate/index.tsx
Normal file
448
src/dashboards/MasterCorporate/index.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useState, useMemo} from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClock,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsBank,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsDatabase,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
|
import {averageLevelCalculator, calculateAverageLevel} from "@/utils/score";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {getCorporateUser} from "@/resources/user";
|
||||||
|
import {groupBy, uniqBy} from "lodash";
|
||||||
|
import MasterStatistical from "./MasterStatistical";
|
||||||
|
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
|
import StudentPerformanceList from "./StudentPerformanceList";
|
||||||
|
import StudentPerformancePage from "./StudentPerformancePage";
|
||||||
|
import MasterStatisticalPage from "./MasterStatisticalPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: MasterCorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherHash = {
|
||||||
|
type: "teacher",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateHash = {
|
||||||
|
type: "corporate",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MasterCorporateDashboard({user}: Props) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
||||||
|
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
|
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
|
||||||
|
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
|
||||||
|
|
||||||
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
|
|
||||||
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && router.asPath === "/");
|
||||||
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCorporateAssignments(
|
||||||
|
assignments.filter(activeAssignmentFilter).map((a) => {
|
||||||
|
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [assignments, groups, teachers, corporates]);
|
||||||
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
|
const UserDisplay = (displayUser: User) => (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>{displayUser.name}</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
const groupedByNameCorporates = useMemo(
|
||||||
|
() =>
|
||||||
|
groupBy(
|
||||||
|
users.filter((x) => x.type === "corporate"),
|
||||||
|
(x: CorporateUser) => x.corporateInformation?.companyInformation?.name || "N/A",
|
||||||
|
) as Record<string, CorporateUser[]>,
|
||||||
|
[users],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedByNameCorporatesKeys = Object.keys(groupedByNameCorporates);
|
||||||
|
|
||||||
|
const GroupsList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupList user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (router.asPath === "/#studentsPerformance") return <StudentPerformancePage user={user} />;
|
||||||
|
if (router.asPath === "/#statistical") return <MasterStatisticalPage groupedByNameCorporates={groupedByNameCorporates} />;
|
||||||
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
|
|
||||||
|
if (router.asPath === "/#students")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#assignments")
|
||||||
|
return (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
corporateAssignments={corporateAssignments}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
user={user}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#corporate")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="corporate"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#students")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#teachers")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="teacher"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
maxUserAmount={
|
||||||
|
user.type === "mastercorporate"
|
||||||
|
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
|
}}
|
||||||
|
onViewStudents={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#students")}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Students"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#teachers")}
|
||||||
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
|
label="Teachers"
|
||||||
|
value={totalTeachers}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
label="Average Level"
|
||||||
|
value={averageLevelCalculator(
|
||||||
|
students,
|
||||||
|
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
||||||
|
).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonCheck}
|
||||||
|
label="User Balance"
|
||||||
|
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClock}
|
||||||
|
label="Expiration Date"
|
||||||
|
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Corporate Accounts"
|
||||||
|
value={totalCorporate}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#corporate")}
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Corporate"
|
||||||
|
value={groupedByNameCorporatesKeys.length}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsDatabase}
|
||||||
|
label="Master Statistical"
|
||||||
|
// value={masterCorporateUserGroups.length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#statistical")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isAssignmentsLoading}
|
||||||
|
onClick={() => router.push("/#assignments")}
|
||||||
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">
|
||||||
|
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest teachers</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{teachers
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CorporateUser, User } from "@/interfaces/user";
|
|
||||||
import { BsFileExcel, BsBank, BsPersonFill } from "react-icons/bs";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
|
|
||||||
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
|
||||||
import ReactDatePicker from "react-datepicker";
|
|
||||||
|
|
||||||
import moment from "moment";
|
|
||||||
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
|
||||||
import {
|
|
||||||
flexRender,
|
|
||||||
createColumnHelper,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
|
||||||
import axios from "axios";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import Button from "@/components/Low/Button";
|
|
||||||
interface Props {
|
|
||||||
corporateUsers: User[];
|
|
||||||
users: User[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableData {
|
|
||||||
user: string;
|
|
||||||
email: string;
|
|
||||||
correct: number;
|
|
||||||
corporate: string;
|
|
||||||
submitted: boolean;
|
|
||||||
date: moment.Moment;
|
|
||||||
assignment: string;
|
|
||||||
corporateId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserCount {
|
|
||||||
userCount: number;
|
|
||||||
maxUserCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchFilters = [["email"], ["user"], ["userId"]];
|
|
||||||
|
|
||||||
const MasterStatistical = (props: Props) => {
|
|
||||||
const { users, corporateUsers } = props;
|
|
||||||
|
|
||||||
const corporateRelevantUsers = React.useMemo(
|
|
||||||
() => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
|
|
||||||
[corporateUsers]
|
|
||||||
);
|
|
||||||
|
|
||||||
const corporates = React.useMemo(
|
|
||||||
() => corporateRelevantUsers.map((x) => x.id),
|
|
||||||
[corporateRelevantUsers]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedCorporates, setSelectedCorporates] =
|
|
||||||
React.useState<string[]>(corporates);
|
|
||||||
const [startDate, setStartDate] = React.useState<Date | null>(
|
|
||||||
moment("01/01/2023").toDate()
|
|
||||||
);
|
|
||||||
const [endDate, setEndDate] = React.useState<Date | null>(
|
|
||||||
moment().endOf("year").toDate()
|
|
||||||
);
|
|
||||||
|
|
||||||
const { assignments } = useAssignmentsCorporates({
|
|
||||||
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
|
|
||||||
corporates: selectedCorporates,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [downloading, setDownloading] = React.useState<boolean>(false);
|
|
||||||
|
|
||||||
const tableResults = React.useMemo(
|
|
||||||
() =>
|
|
||||||
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
|
|
||||||
const userResults = a.assignees.map((assignee) => {
|
|
||||||
const userStats =
|
|
||||||
a.results.find((r) => r.user === assignee)?.stats || [];
|
|
||||||
const userData = users.find((u) => u.id === assignee);
|
|
||||||
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
|
|
||||||
const commonData = {
|
|
||||||
user: userData?.name || "",
|
|
||||||
email: userData?.email || "",
|
|
||||||
userId: assignee,
|
|
||||||
corporateId: a.corporateId,
|
|
||||||
corporate,
|
|
||||||
assignment: a.name,
|
|
||||||
};
|
|
||||||
if (userStats.length === 0) {
|
|
||||||
return {
|
|
||||||
...commonData,
|
|
||||||
correct: 0,
|
|
||||||
submitted: false,
|
|
||||||
// date: moment(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...commonData,
|
|
||||||
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
|
|
||||||
submitted: true,
|
|
||||||
date: moment.max(userStats.map((e) => moment(e.date))),
|
|
||||||
};
|
|
||||||
}) as TableData[];
|
|
||||||
|
|
||||||
return [...accmA, ...userResults];
|
|
||||||
}, []),
|
|
||||||
[assignments, users]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getCorporateScores = (corporateId: string): UserCount => {
|
|
||||||
const corporateAssignmentsUsers = assignments
|
|
||||||
.filter((a) => a.corporateId === corporateId)
|
|
||||||
.reduce((acc, a) => acc + a.assignees.length, 0);
|
|
||||||
|
|
||||||
const corporateResults = tableResults.filter(
|
|
||||||
(r) => r.corporateId === corporateId
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxUserCount: corporateAssignmentsUsers,
|
|
||||||
userCount: corporateResults,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const corporateScores = corporates.reduce(
|
|
||||||
(accm, id) => ({
|
|
||||||
...accm,
|
|
||||||
[id]: getCorporateScores(id),
|
|
||||||
}),
|
|
||||||
{}
|
|
||||||
) as Record<string, UserCount>;
|
|
||||||
|
|
||||||
const consolidateScore = Object.values(corporateScores).reduce(
|
|
||||||
(acc: UserCount, { userCount, maxUserCount }: UserCount) => ({
|
|
||||||
userCount: acc.userCount + userCount,
|
|
||||||
maxUserCount: acc.maxUserCount + maxUserCount,
|
|
||||||
}),
|
|
||||||
{ userCount: 0, maxUserCount: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const getConsolidateScoreStr = (data: UserCount) =>
|
|
||||||
`${data.userCount}/${data.maxUserCount}`;
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<TableData>();
|
|
||||||
|
|
||||||
const defaultColumns = [
|
|
||||||
columnHelper.accessor("user", {
|
|
||||||
header: "User",
|
|
||||||
id: "user",
|
|
||||||
cell: (info) => {
|
|
||||||
return <span>{info.getValue()}</span>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
|
||||||
header: "Email",
|
|
||||||
id: "email",
|
|
||||||
cell: (info) => {
|
|
||||||
return <span>{info.getValue()}</span>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("corporate", {
|
|
||||||
header: "Corporate",
|
|
||||||
id: "corporate",
|
|
||||||
cell: (info) => {
|
|
||||||
return <span>{info.getValue()}</span>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("assignment", {
|
|
||||||
header: "Assignment",
|
|
||||||
id: "assignment",
|
|
||||||
cell: (info) => {
|
|
||||||
return <span>{info.getValue()}</span>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("submitted", {
|
|
||||||
header: "Submitted",
|
|
||||||
id: "submitted",
|
|
||||||
cell: (info) => {
|
|
||||||
return (
|
|
||||||
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
|
|
||||||
<span></span>
|
|
||||||
</Checkbox>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("correct", {
|
|
||||||
header: "Correct",
|
|
||||||
id: "correct",
|
|
||||||
cell: (info) => {
|
|
||||||
return <span>{info.getValue()}</span>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("date", {
|
|
||||||
header: "Date",
|
|
||||||
id: "date",
|
|
||||||
cell: (info) => {
|
|
||||||
const date = info.getValue();
|
|
||||||
if (date) {
|
|
||||||
return <span>{date.format("DD/MM/YYYY")}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span>{""}</span>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const {
|
|
||||||
rows: filteredRows,
|
|
||||||
renderSearch,
|
|
||||||
text: searchText,
|
|
||||||
} = useListSearch(searchFilters, tableResults);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: filteredRows,
|
|
||||||
columns: defaultColumns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const areAllSelected = selectedCorporates.length === corporates.length;
|
|
||||||
|
|
||||||
const getStudentsConsolidateScore = () => {
|
|
||||||
if (tableResults.length === 0) {
|
|
||||||
return { highest: null, lowest: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the student with the highest and lowest score
|
|
||||||
return tableResults.reduce(
|
|
||||||
(acc, curr) => {
|
|
||||||
if (curr.correct > acc.highest.correct) {
|
|
||||||
acc.highest = curr;
|
|
||||||
}
|
|
||||||
if (curr.correct < acc.lowest.correct) {
|
|
||||||
acc.lowest = curr;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ highest: tableResults[0], lowest: tableResults[0] }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerDownload = async () => {
|
|
||||||
try {
|
|
||||||
setDownloading(true);
|
|
||||||
const res = await axios.post("/api/assignments/statistical/excel", {
|
|
||||||
ids: selectedCorporates,
|
|
||||||
...(startDate ? { startDate: startDate.toISOString() } : {}),
|
|
||||||
...(endDate ? { endDate: endDate.toISOString() } : {}),
|
|
||||||
searchText,
|
|
||||||
});
|
|
||||||
toast.success("Report ready!");
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = res.data;
|
|
||||||
// download should have worked but there are some CORS issues
|
|
||||||
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
|
|
||||||
// link.download="report.pdf";
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noreferrer";
|
|
||||||
link.click();
|
|
||||||
setDownloading(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Failed to display the report!");
|
|
||||||
console.error(err);
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const consolidateResults = getStudentsConsolidateScore();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap gap-2 items-center text-center">
|
|
||||||
<IconCard
|
|
||||||
Icon={BsBank}
|
|
||||||
label="Consolidate"
|
|
||||||
value={getConsolidateScoreStr(consolidateScore)}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => {
|
|
||||||
if (areAllSelected) {
|
|
||||||
setSelectedCorporates([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedCorporates(corporates);
|
|
||||||
}}
|
|
||||||
isSelected={areAllSelected}
|
|
||||||
/>
|
|
||||||
{corporateRelevantUsers.map((group) => {
|
|
||||||
const isSelected = selectedCorporates.includes(group.id);
|
|
||||||
return (
|
|
||||||
<IconCard
|
|
||||||
key={group.id}
|
|
||||||
Icon={BsBank}
|
|
||||||
label={group.corporateInformation?.companyInformation?.name}
|
|
||||||
value={getConsolidateScoreStr(corporateScores[group.id])}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => {
|
|
||||||
if (isSelected) {
|
|
||||||
setSelectedCorporates(
|
|
||||||
selectedCorporates.filter((x) => x !== group.id)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedCorporates([...selectedCorporates, group.id]);
|
|
||||||
}}
|
|
||||||
isSelected={isSelected}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 w-full">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
|
||||||
Date
|
|
||||||
</label>
|
|
||||||
<ReactDatePicker
|
|
||||||
dateFormat="dd/MM/yyyy"
|
|
||||||
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
|
||||||
selected={startDate}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
selectsRange
|
|
||||||
showMonthDropdown
|
|
||||||
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
|
||||||
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
|
||||||
if (finalDate) {
|
|
||||||
// basicly selecting a final day works as if I'm selecting the first
|
|
||||||
// minute of that day. this way it covers the whole day
|
|
||||||
setEndDate(moment(finalDate).endOf("day").toDate());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEndDate(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{renderSearch()}
|
|
||||||
<div className="flex flex-col gap-3 justify-end">
|
|
||||||
<Button
|
|
||||||
className="max-w-[200px] h-[70px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={triggerDownload}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
|
|
||||||
<thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<th className="p-4 text-left" key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody className="px-2">
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr
|
|
||||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
|
||||||
key={row.id}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 items-center text-center">
|
|
||||||
{consolidateResults.highest && (
|
|
||||||
<IconCard
|
|
||||||
onClick={() => {}}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label={`Highest result: ${consolidateResults.highest.user}`}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{consolidateResults.lowest && (
|
|
||||||
<IconCard
|
|
||||||
onClick={() => {}}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label={`Lowest result: ${consolidateResults.lowest.user}`}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MasterStatistical;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
@@ -58,6 +58,12 @@ interface Props {
|
|||||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -67,26 +73,13 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
const {permissions} = usePermissions(user.id);
|
const {permissions} = usePermissions(user.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||||
|
|
||||||
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
|
|
||||||
const assignmentsUsers = useMemo(
|
|
||||||
() =>
|
|
||||||
students.filter((x) =>
|
|
||||||
!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id)
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id),
|
|
||||||
),
|
|
||||||
[groups, students, selectedUser],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && router.asPath === "/#");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, router.asPath]);
|
}, [selectedUser, router.asPath]);
|
||||||
@@ -150,96 +143,36 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
return calculateAverageLevel(levels);
|
return calculateAverageLevel(levels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
if (router.asPath === "/#students")
|
||||||
<>
|
return (
|
||||||
{linkedCorporate && (
|
<UserList
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
user={user}
|
||||||
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
type="student"
|
||||||
</div>
|
renderHeader={(total) => (
|
||||||
)}
|
<div className="flex flex-col gap-4">
|
||||||
<section
|
<div
|
||||||
className={clsx(
|
onClick={() => router.push("/")}
|
||||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
!!linkedCorporate && "mt-12 xl:mt-6",
|
<BsArrowLeft className="text-xl" />
|
||||||
)}>
|
<span>Back</span>
|
||||||
<IconCard
|
</div>
|
||||||
onClick={() => router.push("/#students")}
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
isLoading={isStudentsLoading}
|
</div>
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Students"
|
|
||||||
value={students.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClipboard2Data}
|
|
||||||
label="Exams Performed"
|
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPaperclip}
|
|
||||||
label="Average Level"
|
|
||||||
isLoading={isStudentsLoading}
|
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPeople}
|
|
||||||
label="Groups"
|
|
||||||
value={groups.filter((x) => x.admin === user.id).length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => router.push("/#groups")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div
|
/>
|
||||||
onClick={() => router.push("/#assignments")}
|
);
|
||||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
if (router.asPath === "/#assignments")
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
return (
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<AssignmentsPage
|
||||||
<span className="text-lg">Assignments</span>
|
assignments={assignments}
|
||||||
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
groups={assignmentsGroups}
|
||||||
</span>
|
user={user}
|
||||||
</div>
|
reloadAssignments={reloadAssignments}
|
||||||
</section>
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
/>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
);
|
||||||
<span className="p-4">Latest students</span>
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest level students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest exam count students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{students
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
|
||||||
)
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -299,36 +232,95 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{router.asPath === "/#students" && (
|
|
||||||
<UserList
|
<>
|
||||||
user={user}
|
{linkedCorporate && (
|
||||||
type="student"
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
renderHeader={(total) => (
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
<div className="flex flex-col gap-4">
|
</div>
|
||||||
<div
|
)}
|
||||||
onClick={() => router.push("/")}
|
<section
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className={clsx(
|
||||||
<BsArrowLeft className="text-xl" />
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
<span>Back</span>
|
!!linkedCorporate && "mt-12 xl:mt-6",
|
||||||
</div>
|
)}>
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
<IconCard
|
||||||
</div>
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label="Students"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
label="Average Level"
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPeople}
|
||||||
|
label="Groups"
|
||||||
|
value={groups.filter((x) => x.admin === user.id).length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#groups")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
<div
|
||||||
)}
|
onClick={() => router.push("/#assignments")}
|
||||||
{router.asPath === "/#groups" && <GroupsList />}
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
{router.asPath === "/#assignments" && (
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<AssignmentsPage
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
assignments={assignments}
|
<span className="text-lg">Assignments</span>
|
||||||
groups={assignmentsGroups}
|
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||||
users={assignmentsUsers}
|
</span>
|
||||||
user={user}
|
</div>
|
||||||
reloadAssignments={reloadAssignments}
|
</section>
|
||||||
isLoading={isAssignmentsLoading}
|
|
||||||
onBack={() => router.push("/")}
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
/>
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
)}
|
<span className="p-4">Latest students</span>
|
||||||
{router.asPath === "/" && <DefaultDashboard />}
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,233 +1,183 @@
|
|||||||
import { Assignment } from "@/interfaces/results";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { CorporateUser, Group, User } from "@/interfaces/user";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import { getUserCompanyName } from "@/resources/user";
|
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
import {
|
import {
|
||||||
activeAssignmentFilter,
|
activeAssignmentFilter,
|
||||||
archivedAssignmentFilter,
|
archivedAssignmentFilter,
|
||||||
futureAssignmentFilter,
|
futureAssignmentFilter,
|
||||||
pastAssignmentFilter,
|
pastAssignmentFilter,
|
||||||
startHasExpiredAssignmentFilter,
|
startHasExpiredAssignmentFilter,
|
||||||
} from "@/utils/assignments";
|
} from "@/utils/assignments";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { groupBy } from "lodash";
|
import {groupBy} from "lodash";
|
||||||
import { useState } from "react";
|
import {useState} from "react";
|
||||||
import { BsArrowLeft, BsArrowRepeat, BsPlus } from "react-icons/bs";
|
import {BsArrowLeft, BsArrowRepeat, BsPlus} from "react-icons/bs";
|
||||||
import AssignmentCard from "../AssignmentCard";
|
import AssignmentCard from "../AssignmentCard";
|
||||||
import AssignmentCreator from "../AssignmentCreator";
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
import AssignmentView from "../AssignmentView";
|
import AssignmentView from "../AssignmentView";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
corporateAssignments?: ({ corporate?: CorporateUser } & Assignment)[];
|
corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[];
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
users: User[];
|
isLoading: boolean;
|
||||||
isLoading: boolean;
|
user: User;
|
||||||
user: User;
|
onBack: () => void;
|
||||||
onBack: () => void;
|
reloadAssignments: () => void;
|
||||||
reloadAssignments: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentsPage({
|
export default function AssignmentsPage({assignments, corporateAssignments, user, groups, isLoading, onBack, reloadAssignments}: Props) {
|
||||||
assignments,
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
corporateAssignments,
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
user,
|
|
||||||
groups,
|
|
||||||
users,
|
|
||||||
isLoading,
|
|
||||||
onBack,
|
|
||||||
reloadAssignments,
|
|
||||||
}: Props) {
|
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
|
|
||||||
const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
|
const {users} = useUsers();
|
||||||
|
|
||||||
const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
|
const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
|
||||||
|
|
||||||
return (
|
const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
|
||||||
<>
|
|
||||||
{displayAssignmentView && (
|
return (
|
||||||
<AssignmentView
|
<>
|
||||||
isOpen={displayAssignmentView}
|
{displayAssignmentView && (
|
||||||
onClose={() => {
|
<AssignmentView
|
||||||
setSelectedAssignment(undefined);
|
isOpen={displayAssignmentView}
|
||||||
setIsCreatingAssignment(false);
|
onClose={() => {
|
||||||
reloadAssignments();
|
setSelectedAssignment(undefined);
|
||||||
}}
|
setIsCreatingAssignment(false);
|
||||||
assignment={selectedAssignment}
|
reloadAssignments();
|
||||||
/>
|
}}
|
||||||
)}
|
assignment={selectedAssignment}
|
||||||
{/** I'll be using this is creating assingment as a workaround for a key to trigger a new rendering */}
|
/>
|
||||||
{isCreatingAssignment && (
|
)}
|
||||||
<AssignmentCreator
|
{/** I'll be using this is creating assingment as a workaround for a key to trigger a new rendering */}
|
||||||
assignment={selectedAssignment}
|
{isCreatingAssignment && (
|
||||||
groups={groups}
|
<AssignmentCreator
|
||||||
users={users}
|
assignment={selectedAssignment}
|
||||||
user={user}
|
groups={groups}
|
||||||
isCreating={isCreatingAssignment}
|
users={users}
|
||||||
cancelCreation={() => {
|
user={user}
|
||||||
setIsCreatingAssignment(false);
|
isCreating={isCreatingAssignment}
|
||||||
setSelectedAssignment(undefined);
|
cancelCreation={() => {
|
||||||
reloadAssignments();
|
setIsCreatingAssignment(false);
|
||||||
}}
|
setSelectedAssignment(undefined);
|
||||||
/>
|
reloadAssignments();
|
||||||
)}
|
}}
|
||||||
<div className="w-full flex justify-between items-center">
|
/>
|
||||||
<div
|
)}
|
||||||
onClick={onBack}
|
<div className="w-full flex justify-between items-center">
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
<div
|
||||||
>
|
onClick={onBack}
|
||||||
<BsArrowLeft className="text-xl" />
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<span>Back</span>
|
<BsArrowLeft className="text-xl" />
|
||||||
</div>
|
<span>Back</span>
|
||||||
<div
|
</div>
|
||||||
onClick={reloadAssignments}
|
<div
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
onClick={reloadAssignments}
|
||||||
>
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<span>Reload</span>
|
<span>Reload</span>
|
||||||
<BsArrowRepeat
|
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-lg font-bold">Active Assignments Status</span>
|
<span>
|
||||||
<div className="flex items-center gap-4">
|
<b>Total:</b> {assignments.filter(activeAssignmentFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
|
||||||
<span>
|
{assignments.filter(activeAssignmentFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
<b>Total:</b>{" "}
|
</span>
|
||||||
{assignments
|
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
|
||||||
.filter(activeAssignmentFilter)
|
<div key={x}>
|
||||||
.reduce((acc, curr) => acc + curr.results.length, 0)}
|
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
|
||||||
/
|
<span>
|
||||||
{assignments
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
|
||||||
.filter(activeAssignmentFilter)
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
.reduce((acc, curr) => curr.exams.length + acc, 0)}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
{Object.keys(
|
))}
|
||||||
groupBy(corporateAssignments, (x) => x.corporate?.id)
|
</div>
|
||||||
).map((x) => (
|
</div>
|
||||||
<div key={x}>
|
<section className="flex flex-col gap-4">
|
||||||
<span className="font-semibold">
|
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2>
|
||||||
{getUserCompanyName(
|
<div className="flex flex-wrap gap-2">
|
||||||
users.find((u) => u.id === x)!,
|
{assignments.filter(activeAssignmentFilter).map((a) => (
|
||||||
users,
|
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
||||||
groups
|
))}
|
||||||
)}
|
</div>
|
||||||
:{" "}
|
</section>
|
||||||
</span>
|
<section className="flex flex-col gap-4">
|
||||||
<span>
|
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2>
|
||||||
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
|
<div className="flex flex-wrap gap-2">
|
||||||
x
|
<div
|
||||||
].reduce((acc, curr) => curr.results.length + acc, 0)}
|
onClick={() => setIsCreatingAssignment(true)}
|
||||||
/
|
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||||
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
|
<BsPlus className="text-6xl" />
|
||||||
x
|
<span className="text-lg">New Assignment</span>
|
||||||
].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
</div>
|
||||||
</span>
|
{assignments.filter(futureAssignmentFilter).map((a) => (
|
||||||
</div>
|
<AssignmentCard
|
||||||
))}
|
{...a}
|
||||||
</div>
|
users={users}
|
||||||
</div>
|
onClick={() => {
|
||||||
<section className="flex flex-col gap-4">
|
setSelectedAssignment(a);
|
||||||
<h2 className="text-2xl font-semibold">
|
setIsCreatingAssignment(true);
|
||||||
Active Assignments (
|
}}
|
||||||
{assignments.filter(activeAssignmentFilter).length})
|
key={a.id}
|
||||||
</h2>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
))}
|
||||||
{assignments.filter(activeAssignmentFilter).map((a) => (
|
</div>
|
||||||
<AssignmentCard
|
</section>
|
||||||
{...a}
|
<section className="flex flex-col gap-4">
|
||||||
users={users}
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2>
|
||||||
onClick={() => setSelectedAssignment(a)}
|
<div className="flex flex-wrap gap-2">
|
||||||
key={a.id}
|
{assignments.filter(pastAssignmentFilter).map((a) => (
|
||||||
/>
|
<AssignmentCard
|
||||||
))}
|
{...a}
|
||||||
</div>
|
users={users}
|
||||||
</section>
|
onClick={() => setSelectedAssignment(a)}
|
||||||
<section className="flex flex-col gap-4">
|
key={a.id}
|
||||||
<h2 className="text-2xl font-semibold">
|
allowDownload
|
||||||
Planned Assignments (
|
reload={reloadAssignments}
|
||||||
{assignments.filter(futureAssignmentFilter).length})
|
allowArchive
|
||||||
</h2>
|
allowExcelDownload
|
||||||
<div className="flex flex-wrap gap-2">
|
/>
|
||||||
<div
|
))}
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
</div>
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
|
</section>
|
||||||
>
|
<section className="flex flex-col gap-4">
|
||||||
<BsPlus className="text-6xl" />
|
<h2 className="text-2xl font-semibold">Assignments start expired ({assignmentsPastExpiredStart.length})</h2>
|
||||||
<span className="text-lg">New Assignment</span>
|
<div className="flex flex-wrap gap-2">
|
||||||
</div>
|
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
|
||||||
{assignments.filter(futureAssignmentFilter).map((a) => (
|
<AssignmentCard
|
||||||
<AssignmentCard
|
{...a}
|
||||||
{...a}
|
users={users}
|
||||||
users={users}
|
onClick={() => setSelectedAssignment(a)}
|
||||||
onClick={() => {
|
key={a.id}
|
||||||
setSelectedAssignment(a);
|
allowDownload
|
||||||
setIsCreatingAssignment(true);
|
reload={reloadAssignments}
|
||||||
}}
|
allowArchive
|
||||||
key={a.id}
|
allowExcelDownload
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2>
|
||||||
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
|
<div className="flex flex-wrap gap-2">
|
||||||
</h2>
|
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
||||||
<div className="flex flex-wrap gap-2">
|
<AssignmentCard
|
||||||
{assignments.filter(pastAssignmentFilter).map((a) => (
|
{...a}
|
||||||
<AssignmentCard
|
users={users}
|
||||||
{...a}
|
onClick={() => setSelectedAssignment(a)}
|
||||||
users={users}
|
key={a.id}
|
||||||
onClick={() => setSelectedAssignment(a)}
|
allowDownload
|
||||||
key={a.id}
|
reload={reloadAssignments}
|
||||||
allowDownload
|
allowUnarchive
|
||||||
reload={reloadAssignments}
|
allowExcelDownload
|
||||||
allowArchive
|
/>
|
||||||
allowExcelDownload
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
</>
|
||||||
</section>
|
);
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">
|
|
||||||
Assignments start expired ({assignmentsPastExpiredStart.length})
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowArchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">
|
|
||||||
Archived Assignments (
|
|
||||||
{assignments.filter(archivedAssignmentFilter).length})
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
setChangedPrompt(true);
|
setChangedPrompt(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contextWordLines]);
|
}, [contextWordLines]);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {calculateAverageLevel} from "@/utils/score";
|
|||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import {Variant} from "@/interfaces/exam";
|
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||||
import useSessions, {Session} from "@/hooks/useSessions";
|
import useSessions, {Session} from "@/hooks/useSessions";
|
||||||
import SessionCard from "@/components/Medium/SessionCard";
|
import SessionCard from "@/components/Medium/SessionCard";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
@@ -41,6 +41,7 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadSession = async (session: Session) => {
|
const loadSession = async (session: Session) => {
|
||||||
|
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
||||||
state.setSelectedModules(session.selectedModules);
|
state.setSelectedModules(session.selectedModules);
|
||||||
state.setExam(session.exam);
|
state.setExam(session.exam);
|
||||||
state.setExams(session.exams);
|
state.setExams(session.exams);
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {ExamState} from "@/stores/examStore";
|
import {ExamState} from "@/stores/examStore";
|
||||||
import Axios from "axios";
|
import axios from "axios";
|
||||||
import {setupCache} from "axios-cache-interceptor";
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
const instance = Axios.create();
|
|
||||||
const axios = setupCache(instance);
|
|
||||||
|
|
||||||
export type Session = ExamState & {user: string; id: string; date: string};
|
export type Session = ExamState & {user: string; id: string; date: string};
|
||||||
|
|
||||||
export default function useSessions(user?: string) {
|
export default function useSessions(user?: string) {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
// If redirectIfFound is also set, redirect if the user was found
|
// If redirectIfFound is also set, redirect if the user was found
|
||||||
(redirectIfFound && user && user.isVerified) ||
|
(redirectIfFound && user) ||
|
||||||
// If redirectTo is set, redirect if the user was not found.
|
// If redirectTo is set, redirect if the user was not found.
|
||||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified)))
|
(redirectTo && !redirectIfFound && !user)
|
||||||
) {
|
) {
|
||||||
Router.push(redirectTo);
|
Router.push(redirectTo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,25 @@ import {setupCache} from "axios-cache-interceptor";
|
|||||||
const instance = Axios.create();
|
const instance = Axios.create();
|
||||||
const axios = setupCache(instance);
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export const userHashStudent = { type: "student" } as { type: Type };
|
export const userHashStudent = {type: "student"} as {type: Type};
|
||||||
export const userHashTeacher = { type: "teacher" } as { type: Type };
|
export const userHashTeacher = {type: "teacher"} as {type: Type};
|
||||||
export const userHashCorporate = { type: "corporate" } as { type: Type };
|
export const userHashCorporate = {type: "corporate"} as {type: Type};
|
||||||
|
|
||||||
export default function useUsers(props?: {type?: Type; page?: number; size?: number}) {
|
export default function useUsers(props?: {type?: string; page?: number; size?: number; orderBy?: string; direction?: "asc" | "desc", searchTerm?: string | undefined}) {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
const [latestID, setLatestID] = useState<string>();
|
|
||||||
const [firstID, setFirstID] = useState<string>();
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
|
|
||||||
const getData = () => {
|
const getData = () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (!!props)
|
if (!!props)
|
||||||
Object.keys(props).forEach((key) => {
|
Object.keys(props).forEach((key) => {
|
||||||
if (!!props[key as keyof typeof props]) params.append(key, props[key as keyof typeof props]!.toString());
|
if (props[key as keyof typeof props] !== undefined) params.append(key, props[key as keyof typeof props]!.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!!latestID) params.append("latestID", latestID);
|
console.log(params.toString());
|
||||||
if (!!firstID) params.append("firstID", firstID);
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
@@ -38,21 +34,8 @@ export default function useUsers(props?: {type?: Type; page?: number; size?: num
|
|||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
setLatestID(users[users.length - 1]?.id);
|
|
||||||
setFirstID(undefined);
|
|
||||||
setPage((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const previous = () => {
|
|
||||||
setLatestID(undefined);
|
|
||||||
setFirstID(page > 1 ? users[0]?.id : undefined);
|
|
||||||
setPage((prev) => prev - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(getData, [props, latestID, firstID]);
|
useEffect(getData, [props?.page, props?.size, props?.type, props?.orderBy, props?.direction, props?.searchTerm]);
|
||||||
|
|
||||||
return {users, total, page, isLoading, isError, reload: getData, next, previous};
|
return {users, total, isLoading, isError, reload: getData};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Code {
|
export interface Code {
|
||||||
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
expiryDate: Date;
|
expiryDate: Date;
|
||||||
|
|||||||
30
src/lib/mongodb.ts
Normal file
30
src/lib/mongodb.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {MongoClient} from "mongodb";
|
||||||
|
|
||||||
|
if (!process.env.MONGODB_URI) {
|
||||||
|
throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = process.env.MONGODB_URI || "";
|
||||||
|
const options = {};
|
||||||
|
|
||||||
|
let client: MongoClient;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// In development mode, use a global variable so that the value
|
||||||
|
// is preserved across module reloads caused by HMR (Hot Module Replacement).
|
||||||
|
let globalWithMongo = global as typeof globalThis & {
|
||||||
|
_mongoClient?: MongoClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalWithMongo._mongoClient) {
|
||||||
|
globalWithMongo._mongoClient = new MongoClient(uri, options);
|
||||||
|
}
|
||||||
|
client = globalWithMongo._mongoClient;
|
||||||
|
} else {
|
||||||
|
// In production mode, it's best to not use a global variable.
|
||||||
|
client = new MongoClient(uri, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a module-scoped MongoClient. By doing this in a
|
||||||
|
// separate module, the client can be shared across functions.
|
||||||
|
export default client;
|
||||||
5
src/mongodb.d.ts
vendored
Normal file
5
src/mongodb.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import {MongoClient} from "mongodb";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var _mongoClientPromise: Promise<MongoClient>;
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
list: ["student", "teacher", "agent", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
@@ -161,7 +161,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post("/api/batch_users", { users: newUsers.map(user => ({...user, type, expiryDate})) });
|
await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))});
|
||||||
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
||||||
onFinish();
|
onFinish();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Type, User, userTypes, CorporateUser, Group} from "@/interfaces/user";
|
import { Type, User, userTypes, CorporateUser, Group } from "@/interfaces/user";
|
||||||
import {Popover, Transition} from "@headlessui/react";
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, reverse} from "lodash";
|
import { capitalize, reverse } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Fragment, useEffect, useState, useMemo} from "react";
|
import { Fragment, useEffect, useState, useMemo } from "react";
|
||||||
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
import { BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {countries, TCountries} from "countries-list";
|
import { countries, TCountries } from "countries-list";
|
||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import {getUserCompanyName, isAgentUser, USER_TYPE_LABELS} from "@/resources/user";
|
import { getUserCompanyName, isAgentUser, USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {isCorporateUser} from "@/resources/user";
|
import { isCorporateUser } from "@/resources/user";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import { getUserCorporate } from "@/utils/groups";
|
||||||
import {asyncSorter} from "@/utils";
|
import { asyncSorter } from "@/utils";
|
||||||
import {exportListToExcel, UserListRow} from "@/utils/users";
|
import { exportListToExcel, UserListRow } from "@/utils/users";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import useUserBalance from "@/hooks/useUserBalance";
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
const columnHelper = createColumnHelper<User>();
|
const columnHelper = createColumnHelper<User>();
|
||||||
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
|
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
|
||||||
|
|
||||||
|
const corporatesHash = {
|
||||||
|
type: "corporate",
|
||||||
|
};
|
||||||
|
|
||||||
const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
|
const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -58,13 +63,15 @@ export default function UserList({
|
|||||||
const [sorter, setSorter] = useState<string>();
|
const [sorter, setSorter] = useState<string>();
|
||||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const userHash = useMemo(() => ({
|
const { users, total, isLoading, reload } = useUsers({type: type, size: 16, page: page, searchTerm: searchTerm});
|
||||||
type,
|
|
||||||
size: 25,
|
const {users: corporates} = useUsers(corporatesHash);
|
||||||
}), [type])
|
|
||||||
|
const totalUsers = useMemo(() => [...users, ...corporates], [users, corporates]);
|
||||||
|
|
||||||
const {users, page, total, reload, next, previous} = useUsers(userHash);
|
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
const {balance} = useUserBalance();
|
const {balance} = useUserBalance();
|
||||||
const {groups} = useGroups({
|
const {groups} = useGroups({
|
||||||
@@ -101,20 +108,20 @@ export default function UserList({
|
|||||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete<{ok: boolean}>(`/api/user?id=${user.id}`)
|
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("User deleted successfully!");
|
toast.success("User deleted successfully!");
|
||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", {toastId: "delete-error"});
|
toast.error("Something went wrong!", { toastId: "delete-error" });
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyAccount = (user: User) => {
|
const verifyAccount = (user: User) => {
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
})
|
})
|
||||||
@@ -123,22 +130,21 @@ export default function UserList({
|
|||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", {toastId: "update-error"});
|
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDisableAccount = (user: User) => {
|
const toggleDisableAccount = (user: User) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${
|
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name
|
||||||
user.name
|
|
||||||
}'s account? This change is usually related to their payment state.`,
|
}'s account? This change is usually related to their payment state.`,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
status: user.status === "disabled" ? "active" : "disabled",
|
status: user.status === "disabled" ? "active" : "disabled",
|
||||||
})
|
})
|
||||||
@@ -147,18 +153,18 @@ export default function UserList({
|
|||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", {toastId: "update-error"});
|
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const SorterArrow = ({name}: {name: string}) => {
|
const SorterArrow = ({ name }: { name: string }) => {
|
||||||
if (sorter === name) return <BsArrowUp />;
|
if (sorter === name) return <BsArrowUp />;
|
||||||
if (sorter === reverseString(name)) return <BsArrowDown />;
|
if (sorter === reverseString(name)) return <BsArrowDown />;
|
||||||
|
|
||||||
return <BsArrowDownUp />;
|
return <BsArrowDownUp />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionColumn = ({row}: {row: {original: User}}) => {
|
const actionColumn = ({ row }: { row: { original: User } }) => {
|
||||||
const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as {
|
const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as {
|
||||||
list: Type[];
|
list: Type[];
|
||||||
perm: PermissionType;
|
perm: PermissionType;
|
||||||
@@ -203,11 +209,11 @@ export default function UserList({
|
|||||||
<SorterArrow name="name" />
|
<SorterArrow name="name" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: ({row, getValue}) => (
|
cell: ({ row, getValue }) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) &&
|
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null
|
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null
|
||||||
@@ -225,9 +231,8 @@ export default function UserList({
|
|||||||
) as any,
|
) as any,
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
info.getValue()
|
info.getValue()
|
||||||
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${
|
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name
|
||||||
countries[info.getValue() as unknown as keyof TCountries]?.name
|
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
|
||||||
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
|
|
||||||
: "N/A",
|
: "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("demographicInformation.phone", {
|
columnHelper.accessor("demographicInformation.phone", {
|
||||||
@@ -293,11 +298,11 @@ export default function UserList({
|
|||||||
<SorterArrow name="name" />
|
<SorterArrow name="name" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: ({row, getValue}) => (
|
cell: ({ row, getValue }) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) &&
|
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null
|
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null
|
||||||
@@ -313,11 +318,11 @@ export default function UserList({
|
|||||||
<SorterArrow name="email" />
|
<SorterArrow name="email" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: ({row, getValue}) => (
|
cell: ({ row, getValue }) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) &&
|
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}>
|
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}>
|
||||||
{getValue()}
|
{getValue()}
|
||||||
@@ -349,7 +354,7 @@ export default function UserList({
|
|||||||
<SorterArrow name="companyName" />
|
<SorterArrow name="companyName" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => <CompanyNameCell user={info.row.original} users={users} groups={groups} />,
|
cell: (info) => <CompanyNameCell user={info.row.original} users={totalUsers} groups={groups} />,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("subscriptionExpirationDate", {
|
columnHelper.accessor("subscriptionExpirationDate", {
|
||||||
header: (
|
header: (
|
||||||
@@ -500,19 +505,17 @@ export default function UserList({
|
|||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const {rows: filteredRows, renderSearch} = useListSearch<User>(searchFields, displayUsers);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredRows,
|
data: displayUsers,
|
||||||
columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
|
columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadExcel = () => {
|
const downloadExcel = () => {
|
||||||
const csv = exportListToExcel(filteredRows, users, groups);
|
const csv = exportListToExcel(displayUsers, users, groups);
|
||||||
|
|
||||||
const element = document.createElement("a");
|
const element = document.createElement("a");
|
||||||
const file = new Blob([csv], {type: "text/csv"});
|
const file = new Blob([csv], { type: "text/csv" });
|
||||||
element.href = URL.createObjectURL(file);
|
element.href = URL.createObjectURL(file);
|
||||||
element.download = "users.csv";
|
element.download = "users.csv";
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
@@ -546,53 +549,53 @@ export default function UserList({
|
|||||||
onViewStudents={
|
onViewStudents={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-students",
|
id: "view-students",
|
||||||
filter: viewStudentFilter,
|
filter: viewStudentFilter,
|
||||||
});
|
});
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: belongsToAdminFilter,
|
filter: belongsToAdminFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/list/users");
|
router.push("/list/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
onViewTeachers={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
|
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-teachers",
|
id: "view-teachers",
|
||||||
filter: viewTeacherFilter,
|
filter: viewTeacherFilter,
|
||||||
});
|
});
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: belongsToAdminFilter,
|
filter: belongsToAdminFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/list/users");
|
router.push("/list/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewCorporate={
|
onViewCorporate={
|
||||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-corporate",
|
id: "view-corporate",
|
||||||
filter: (x: User) => x.type === "corporate",
|
filter: (x: User) => x.type === "corporate",
|
||||||
});
|
});
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: (x: User) =>
|
filter: (x: User) =>
|
||||||
groups
|
groups
|
||||||
.filter((g) => g.participants.includes(selectedUser.id))
|
.filter((g) => g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => [g.admin, ...g.participants])
|
.flatMap((g) => [g.admin, ...g.participants])
|
||||||
.includes(x.id),
|
.includes(x.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/list/users");
|
router.push("/list/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
@@ -614,18 +617,31 @@ export default function UserList({
|
|||||||
</Modal>
|
</Modal>
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<div className="w-full flex gap-2 items-end">
|
<div className="w-full flex gap-2 items-end">
|
||||||
{renderSearch()}
|
<Input label="Search" type="text" name="search" onChange={setSearchTerm} placeholder="Enter search text" value={searchTerm} />
|
||||||
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
|
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
|
||||||
Download List
|
Download List
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex gap-2 justify-between">
|
<div className="w-full flex gap-2 justify-between">
|
||||||
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={previous}>
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((prev) => prev - 1)}>
|
||||||
Previous Page
|
Previous Page
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" disabled={page * 25 >= total} onClick={next}>
|
<div className="flex items-center gap-4 w-fit">
|
||||||
Next Page
|
<span className="opacity-80">
|
||||||
</Button>
|
{page * userHash.size + 1} - {(page + 1) * userHash.size > total ? total : (page + 1) * userHash.size} / {total}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={(page + 1) * userHash.size >= total}
|
||||||
|
onClick={() => setPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -639,7 +655,7 @@ export default function UserList({
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2 w-full">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
|
|||||||
setType("student");
|
setType("student");
|
||||||
setPosition(undefined);
|
setPosition(undefined);
|
||||||
})
|
})
|
||||||
.catch(() => toast.error("Something went wrong! Please try again later!"))
|
.catch((error) => {
|
||||||
|
const data = error?.response?.data;
|
||||||
|
if (!!data?.message) return toast.error(data.message);
|
||||||
|
toast.error("Something went wrong! Please try again later!");
|
||||||
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app, storage } from "@/firebase";
|
import { app, storage } from "@/firebase";
|
||||||
import {
|
import client from "@/lib/mongodb";
|
||||||
getFirestore,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
updateDoc,
|
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
documentId,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
@@ -29,6 +19,7 @@ interface GroupScoreSummaryHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AssignmentData {
|
interface AssignmentData {
|
||||||
|
id: string;
|
||||||
assigner: string;
|
assigner: string;
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
results: any;
|
results: any;
|
||||||
@@ -41,7 +32,7 @@ interface AssignmentData {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -266,7 +257,7 @@ function commonExcel({
|
|||||||
}),
|
}),
|
||||||
`${Math.ceil(
|
`${Math.ceil(
|
||||||
data.stats.reduce((acc: number, curr: any) => acc + curr.timeSpent, 0) /
|
data.stats.reduce((acc: number, curr: any) => acc + curr.timeSpent, 0) /
|
||||||
60
|
60
|
||||||
)} minutes`,
|
)} minutes`,
|
||||||
data.lastDate.format("DD/MM/YYYY HH:mm"),
|
data.lastDate.format("DD/MM/YYYY HH:mm"),
|
||||||
data.correct,
|
data.correct,
|
||||||
@@ -392,9 +383,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const assignment = await db.collection("assignments").findOne<AssignmentData>({ id: id });
|
||||||
const data = docSnap.data() as AssignmentData;
|
if (!assignment) {
|
||||||
if (!data) {
|
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -411,19 +401,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const docsSnap = await getDocs(
|
const objectIds = assignment.assignees.map(id => id);
|
||||||
query(collection(db, "users"), where(documentId(), "in", data.assignees))
|
|
||||||
);
|
|
||||||
const users = docsSnap.docs.map((d) => ({
|
|
||||||
...d.data(),
|
|
||||||
id: d.id,
|
|
||||||
})) as User[];
|
|
||||||
|
|
||||||
const docUser = await getDoc(doc(db, "users", data.assigner));
|
const users = await db.collection("users").find<User>({
|
||||||
if (docUser.exists()) {
|
id: { $in: objectIds }
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
}).toArray();
|
||||||
const user = docUser.data() as User;
|
|
||||||
|
|
||||||
|
const user = await db.collection("users").findOne<User>({ id: assignment.assigner });
|
||||||
|
|
||||||
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
if (user && users) {
|
||||||
// generate the file ref for storage
|
// generate the file ref for storage
|
||||||
const fileName = `${Date.now().toString()}.xlsx`;
|
const fileName = `${Date.now().toString()}.xlsx`;
|
||||||
const refName = `assignment_report/${fileName}`;
|
const refName = `assignment_report/${fileName}`;
|
||||||
@@ -433,11 +420,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
switch (user.type) {
|
switch (user.type) {
|
||||||
case "teacher":
|
case "teacher":
|
||||||
case "corporate":
|
case "corporate":
|
||||||
return corporateAssignment(user as CorporateUser, data, users);
|
return corporateAssignment(user as CorporateUser, assignment, users);
|
||||||
case "mastercorporate":
|
case "mastercorporate":
|
||||||
return mastercorporateAssignment(
|
return mastercorporateAssignment(
|
||||||
user as MasterCorporateUser,
|
user as MasterCorporateUser,
|
||||||
data,
|
assignment,
|
||||||
users
|
users
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -447,18 +434,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const buffer = await getExcelFn();
|
const buffer = await getExcelFn();
|
||||||
|
|
||||||
// upload the pdf to storage
|
// upload the pdf to storage
|
||||||
const snapshot = await uploadBytes(fileRef, buffer, {
|
await uploadBytes(fileRef, buffer, {
|
||||||
contentType:
|
contentType:
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
});
|
});
|
||||||
|
|
||||||
// update the stats entries with the pdf url to prevent duplication
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
await updateDoc(docSnap.ref, {
|
await db.collection("assignments").updateOne(
|
||||||
excel: {
|
{ id: assignment.id },
|
||||||
path: refName,
|
{
|
||||||
version: process.env.EXCEL_VERSION,
|
$set: {
|
||||||
},
|
excel: {
|
||||||
});
|
path: refName,
|
||||||
|
version: process.env.EXCEL_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app, storage} from "@/firebase";
|
import { storage } from "@/firebase";
|
||||||
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where, documentId} from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import ReactPDF from "@react-pdf/renderer";
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
import GroupTestReport from "@/exams/pdf/group.test.report";
|
import GroupTestReport from "@/exams/pdf/group.test.report";
|
||||||
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
import {Stat, CorporateUser} from "@/interfaces/user";
|
import { Stat, CorporateUser } from "@/interfaces/user";
|
||||||
import {User, DemographicInformation} from "@/interfaces/user";
|
import { User, DemographicInformation } from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {ModuleScore, StudentData} from "@/interfaces/module.scores";
|
import { ModuleScore, StudentData } from "@/interfaces/module.scores";
|
||||||
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||||
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
||||||
import {calculateBandScore, getLevelScore} from "@/utils/score";
|
import { calculateBandScore, getLevelScore } from "@/utils/score";
|
||||||
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
import { generateQRCode, getRadialProgressPNG, streamToBuffer } from "@/utils/pdf";
|
||||||
import {Group} from "@/interfaces/user";
|
import { Group } from "@/interfaces/user";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
|
|
||||||
interface GroupScoreSummaryHelper {
|
interface GroupScoreSummaryHelper {
|
||||||
@@ -22,7 +22,7 @@ interface GroupScoreSummaryHelper {
|
|||||||
label: string;
|
label: string;
|
||||||
sessions: string[];
|
sessions: string[];
|
||||||
}
|
}
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -78,14 +78,14 @@ const getPerformanceSummary = (module: Module, score: number) => {
|
|||||||
|
|
||||||
const getScoreAndTotal = (stats: Stat[]) => {
|
const getScoreAndTotal = (stats: Stat[]) => {
|
||||||
return stats.reduce(
|
return stats.reduce(
|
||||||
(acc, {score}) => {
|
(acc, { score }) => {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
correct: acc.correct + score.correct,
|
correct: acc.correct + score.correct,
|
||||||
total: acc.total + score.total,
|
total: acc.total + score.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{correct: 0, total: 0},
|
{ correct: 0, total: 0 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,20 +97,21 @@ const getLevelScoreForUserExams = (bandScore: number) => {
|
|||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// verify if it's a logged user that is trying to export
|
// verify if it's a logged user that is trying to export
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const data = await db.collection("assignments").findOne({ id: id }) as {
|
||||||
const data = docSnap.data() as {
|
id: string;
|
||||||
assigner: string;
|
assigner: string;
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
results: any;
|
results: any;
|
||||||
exams: {module: Module}[];
|
exams: { module: Module }[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
pdf: {
|
pdf: {
|
||||||
path: string,
|
path: string,
|
||||||
version: string,
|
version: string,
|
||||||
},
|
},
|
||||||
};
|
} | null;
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
@@ -125,16 +126,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const user = await db.collection("users").findOne<User>({ id: req.session.user.id });
|
||||||
if (docUser.exists()) {
|
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
|
||||||
const user = docUser.data() as User;
|
|
||||||
|
|
||||||
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
if (user) {
|
||||||
// generate the QR code for the report
|
// generate the QR code for the report
|
||||||
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
||||||
|
|
||||||
if (!qrcode) {
|
if (!qrcode) {
|
||||||
res.status(500).json({ok: false});
|
res.status(500).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,17 +143,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return [...accm, ...stats];
|
return [...accm, ...stats];
|
||||||
}, []) as Stat[];
|
}, []) as Stat[];
|
||||||
|
|
||||||
const docsSnap = await getDocs(query(collection(db, "users"), where(documentId(), "in", data.assignees)));
|
const users = await db.collection("users").find<User>({
|
||||||
const users = docsSnap.docs.map((d) => ({
|
id: { $in: data.assignees.map(id => id) }
|
||||||
...d.data(),
|
}).toArray();
|
||||||
id: d.id,
|
|
||||||
})) as User[];
|
|
||||||
|
|
||||||
const flattenResultsWithGrade = flattenResults.map((e) => {
|
const flattenResultsWithGrade = flattenResults.map((e) => {
|
||||||
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
||||||
const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
|
const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
|
||||||
|
|
||||||
return {...e, bandScore};
|
return { ...e, bandScore };
|
||||||
});
|
});
|
||||||
|
|
||||||
// in order to make sure we are using unique modules, generate the set based on them
|
// in order to make sure we are using unique modules, generate the set based on them
|
||||||
@@ -162,7 +160,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
||||||
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
||||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||||
const {correct, total} = getScoreAndTotal(moduleResults);
|
const { correct, total } = getScoreAndTotal(moduleResults);
|
||||||
const png = getRadialProgressPNG("azul", correct, total);
|
const png = getRadialProgressPNG("azul", correct, total);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -175,7 +173,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
}) as ModuleScore[];
|
}) as ModuleScore[];
|
||||||
|
|
||||||
const {correct: overallCorrect, total: overallTotal} = getScoreAndTotal(flattenResults);
|
const { correct: overallCorrect, total: overallTotal } = getScoreAndTotal(flattenResults);
|
||||||
const baseOverallResult = overallCorrect / overallTotal;
|
const baseOverallResult = overallCorrect / overallTotal;
|
||||||
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
||||||
|
|
||||||
@@ -216,7 +214,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const {title, details} = getCustomData();
|
const { title, details } = getCustomData();
|
||||||
|
|
||||||
const numberOfStudents = data.assignees.length;
|
const numberOfStudents = data.assignees.length;
|
||||||
|
|
||||||
@@ -228,13 +226,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
exams.length === 0
|
exams.length === 0
|
||||||
? "N/A"
|
? "N/A"
|
||||||
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
const bandScore = exams.length === 0 ? 0 : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / exams.length;
|
const bandScore = exams.length === 0 ? 0 : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / exams.length;
|
||||||
const {correct, total} = getScoreAndTotal(exams);
|
const { correct, total } = getScoreAndTotal(exams);
|
||||||
|
|
||||||
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
||||||
|
|
||||||
@@ -258,7 +256,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const getGroupScoreSummary = () => {
|
const getGroupScoreSummary = () => {
|
||||||
const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
|
const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
|
||||||
const {bandScore, id} = curr;
|
const { bandScore, id } = curr;
|
||||||
|
|
||||||
const flooredScore = Math.floor(bandScore);
|
const flooredScore = Math.floor(bandScore);
|
||||||
|
|
||||||
@@ -286,7 +284,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
];
|
];
|
||||||
}, []) as GroupScoreSummaryHelper[];
|
}, []) as GroupScoreSummaryHelper[];
|
||||||
|
|
||||||
const result = resultHelper.map(({score, label, sessions}) => {
|
const result = resultHelper.map(({ score, label, sessions }) => {
|
||||||
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
||||||
return {
|
return {
|
||||||
label: finalLabel,
|
label: finalLabel,
|
||||||
@@ -300,36 +298,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const getInstitution = async () => {
|
const getInstitution = async () => {
|
||||||
try {
|
try {
|
||||||
// due to database inconsistencies, I'll be overprotective here
|
// due to database inconsistencies, I'll be overprotective here
|
||||||
const assignerUserSnap = await getDoc(doc(db, "users", data.assigner));
|
const assignerUser = await db.collection("users").findOne<User>({ id: data.assigner });
|
||||||
if (assignerUserSnap.exists()) {
|
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
|
||||||
const assignerUser = assignerUserSnap.data() as User;
|
|
||||||
|
|
||||||
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
if (assignerUser) {
|
||||||
if (assignerUser.type === "teacher") {
|
if (assignerUser.type === "teacher") {
|
||||||
// also search for groups where this user belongs
|
// also search for groups where this user belongs
|
||||||
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id));
|
const groups = await db.collection("groups")
|
||||||
const groupSnapshot = await getDocs(queryGroups);
|
.find<Group>({ participants: assignerUser.id })
|
||||||
|
.toArray();
|
||||||
const groups = groupSnapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as Group[];
|
|
||||||
|
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
const adminQuery = query(
|
const admins = await db.collection("users")
|
||||||
collection(db, "users"),
|
.find<CorporateUser>({ id: { $in: groups.map(g => g.admin).map(id => id)} })
|
||||||
where(
|
.toArray();
|
||||||
documentId(),
|
|
||||||
"in",
|
|
||||||
groups.map((g) => g.admin),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const adminUsersSnap = await getDocs(adminQuery);
|
|
||||||
|
|
||||||
const admins = adminUsersSnap.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as CorporateUser[];
|
|
||||||
|
|
||||||
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
|
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
|
||||||
if (adminData) {
|
if (adminData) {
|
||||||
@@ -388,39 +370,44 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// update the stats entries with the pdf url to prevent duplication
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
await updateDoc(docSnap.ref, {
|
await db.collection("assignments").updateOne(
|
||||||
pdf: {
|
{ id: data.id },
|
||||||
path: refName,
|
{
|
||||||
version: process.env.PDF_VERSION,
|
$set: {
|
||||||
},
|
pdf: {
|
||||||
});
|
path: refName,
|
||||||
|
version: process.env.PDF_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ok: false});
|
res.status(500).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const data = await db.collection("assignments").findOne({ id: id });
|
||||||
const data = docSnap.data();
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.assigner !== req.session.user.id) {
|
if (data.assigner !== req.session.user.id) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +421,6 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -12,14 +12,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// verify if it's a logged user that is trying to archive
|
// verify if it's a logged user that is trying to archive
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const docSnap = await db.collection("assignments").findOne({ id: id });
|
||||||
|
|
||||||
if (!docSnap.exists()) {
|
if (!docSnap) {
|
||||||
res.status(404).json({ ok: false });
|
res.status(404).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDoc(docSnap.ref, { archived: true }, { merge: true });
|
await db.collection("assignments").updateOne(
|
||||||
|
{ id: docSnap.id },
|
||||||
|
{ $set: { archived: true } }
|
||||||
|
);
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -26,15 +25,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "assignments", id as string));
|
const snapshot = await db.collection("assignments").findOne({ id: id as string });
|
||||||
|
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
if (snapshot) {
|
||||||
|
res.status(200).json({...snapshot, id: snapshot.id});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
|
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
|
||||||
await deleteDoc(doc(db, "assignments", id as string));
|
await db.collection("assignments").deleteOne(
|
||||||
|
{ id: id as string }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,10 @@ async function DELETE(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function PATCH(req: NextApiRequest, res: NextApiResponse) {
|
async function PATCH(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
|
||||||
await setDoc(doc(db, "assignments", id as string), {assigner: req.session.user?.id, ...req.body}, {merge: true});
|
await db.collection("assignments").updateOne(
|
||||||
|
{ id: id as string },
|
||||||
|
{ $set: {assigner: req.session.user?.id, ...req.body} }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
|
import { ObjectId } from 'mongodb';
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -12,14 +12,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// verify if it's a logged user that is trying to archive
|
// verify if it's a logged user that is trying to archive
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const doc = await db.collection("assignments").findOne({ id: id });
|
||||||
|
|
||||||
if (!docSnap.exists()) {
|
if (!doc) {
|
||||||
res.status(404).json({ ok: false });
|
res.status(404).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDoc(docSnap.ref, { released: true }, { merge: true });
|
await db.collection("assignments").updateOne(
|
||||||
|
{ id: id },
|
||||||
|
{ $set: { released: true } }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -13,26 +12,25 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// verify if it's a logged user that is trying to archive
|
// verify if it's a logged user that is trying to archive
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const data = await db.collection("assignments").findOne({ id: id });
|
||||||
|
|
||||||
if (!docSnap.exists()) {
|
if (!data) {
|
||||||
res.status(404).json({ ok: false });
|
res.status(404).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = docSnap.data();
|
|
||||||
if (moment().isAfter(moment(data.startDate))) {
|
if (moment().isAfter(moment(data.startDate))) {
|
||||||
res
|
res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ ok: false, message: "Assignmentcan no longer " });
|
.json({ ok: false, message: "Assignment can no longer " });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDoc(
|
await db.collection("assignments").updateOne(
|
||||||
docSnap.ref,
|
{ id: id },
|
||||||
{ start: true },
|
{ $set: { start: true } }
|
||||||
{ merge: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// verify if it's a logged user that is trying to archive
|
// verify if it's a logged user that is trying to archive
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const docSnap = await db.collection("assignments").findOne({ id: id });
|
||||||
|
|
||||||
if (!docSnap.exists()) {
|
if (!docSnap) {
|
||||||
res.status(404).json({ok: false});
|
res.status(404).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDoc(docSnap.ref, {archived: false}, {merge: true});
|
await db.collection("assignments").updateOne(
|
||||||
res.status(200).json({ok: true});
|
{ id: id },
|
||||||
|
{ $set: { archived: false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST") return post(req, res);
|
if (req.method === "POST") return post(req, res);
|
||||||
res.status(404).json({ok: false});
|
res.status(404).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uniqBy} from "lodash";
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {getExams} from "@/utils/exams.be";
|
|
||||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
|
||||||
import {capitalize, flatten, uniqBy} from "lodash";
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import moment from "moment";
|
|
||||||
import {sendEmail} from "@/email";
|
|
||||||
import {getAllAssignersByCorporate} from "@/utils/groups.be";
|
import {getAllAssignersByCorporate} from "@/utils/groups.be";
|
||||||
import {getAssignmentsByAssigners} from "@/utils/assignments.be";
|
import {getAssignmentsByAssigners} from "@/utils/assignments.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
@@ -14,7 +13,7 @@ import moment from "moment";
|
|||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
import {release} from "os";
|
import {release} from "os";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -31,13 +30,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const q = query(collection(db, "assignments"));
|
const docs = await db.collection("assignments").find({}).toArray();
|
||||||
const snapshot = await getDocs(q);
|
|
||||||
|
|
||||||
const docs = snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.status(200).json(docs);
|
res.status(200).json(docs);
|
||||||
}
|
}
|
||||||
@@ -135,7 +128,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
await db.collection("assignments").insertOne({
|
||||||
|
id: uuidv4(),
|
||||||
assigner: req.session.user?.id,
|
assigner: req.session.user?.id,
|
||||||
assignees,
|
assignees,
|
||||||
results: [],
|
results: [],
|
||||||
@@ -147,10 +141,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
|
|
||||||
for (const assigneeID of assignees) {
|
for (const assigneeID of assignees) {
|
||||||
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
|
|
||||||
if (!assigneeSnapshot.exists()) continue;
|
|
||||||
|
|
||||||
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
|
const assignee = await db.collection("users").findOne<User>({ id: assigneeID });
|
||||||
|
if (!assignee) continue;
|
||||||
|
|
||||||
const name = body.name;
|
const name = body.name;
|
||||||
const teacher = req.session.user!;
|
const teacher = req.session.user!;
|
||||||
const examModulesLabel = uniqBy(exams, (x) => x.module)
|
const examModulesLabel = uniqBy(exams, (x) => x.module)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app, storage } from "@/firebase";
|
import { storage } from "@/firebase";
|
||||||
import { getFirestore } from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
@@ -12,11 +11,9 @@ import { checkAccess } from "@/utils/permissions";
|
|||||||
import { getAssignmentsForCorporates } from "@/utils/assignments.be";
|
import { getAssignmentsForCorporates } from "@/utils/assignments.be";
|
||||||
import { search } from "@/utils/search";
|
import { search } from "@/utils/search";
|
||||||
import { getGradingSystem } from "@/utils/grading.be";
|
import { getGradingSystem } from "@/utils/grading.be";
|
||||||
import { Exam } from "@/interfaces/exam";
|
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -49,15 +46,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// verify if it's a logged user that is trying to export
|
// verify if it's a logged user that is trying to export
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
if (
|
if (
|
||||||
!checkAccess(req.session.user, ["mastercorporate", "developer", "admin"])
|
!checkAccess(req.session.user, ["mastercorporate", "corporate", "developer", "admin"])
|
||||||
) {
|
) {
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
const { ids, startDate, endDate, searchText } = req.body as {
|
const {
|
||||||
|
ids,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
searchText,
|
||||||
|
displaySelection = true,
|
||||||
|
} = req.body as {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
|
displaySelection?: boolean;
|
||||||
};
|
};
|
||||||
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
||||||
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
||||||
@@ -83,7 +87,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getGradingSystemHelper = (
|
const getGradingSystemHelper = (
|
||||||
exams: {id: string; module: Module; assignee: string}[],
|
exams: { id: string; module: Module; assignee: string }[],
|
||||||
assigner: string,
|
assigner: string,
|
||||||
user: User,
|
user: User,
|
||||||
correct: number,
|
correct: number,
|
||||||
@@ -100,15 +104,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
"level",
|
"level",
|
||||||
user.focus
|
user.focus
|
||||||
);
|
);
|
||||||
return { label: getGradingLabel(bandScore, gradingSystem?.steps || []), score: bandScore };
|
return {
|
||||||
|
label: getGradingLabel(bandScore, gradingSystem?.steps || []),
|
||||||
|
score: bandScore,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { score: -1, label: "N/A" };
|
return { score: -1, label: "N/A" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const tableResults = assignments.reduce(
|
const tableResults = assignments
|
||||||
(accmA: TableData[], a: AssignmentWithCorporateId) => {
|
.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
|
||||||
const userResults = a.assignees.map((assignee) => {
|
const userResults = a.assignees.map((assignee) => {
|
||||||
const userStats =
|
const userStats =
|
||||||
a.results.find((r) => r.user === assignee)?.stats || [];
|
a.results.find((r) => r.user === assignee)?.stats || [];
|
||||||
@@ -124,7 +131,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
total
|
total
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
console.log("Level", level);
|
console.log("Level", level);
|
||||||
const commonData = {
|
const commonData = {
|
||||||
user: userData?.name || "",
|
user: userData?.name || "",
|
||||||
@@ -145,12 +151,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsData = userStats.every((e) => e.module === "level") ? userStats.reduce((acc, e, index) => {
|
const partsData = userStats.every((e) => e.module === "level")
|
||||||
return {
|
? userStats.reduce((acc, e, index) => {
|
||||||
...acc,
|
return {
|
||||||
[`part${index}`]: `${e.score.correct}/${e.score.total}`
|
...acc,
|
||||||
}
|
[`part${index}`]: `${e.score.correct}/${e.score.total}`,
|
||||||
}, {}) : {};
|
};
|
||||||
|
}, {})
|
||||||
|
: {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...commonData,
|
...commonData,
|
||||||
@@ -162,9 +170,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}) as TableData[];
|
}) as TableData[];
|
||||||
|
|
||||||
return [...accmA, ...userResults];
|
return [...accmA, ...userResults];
|
||||||
},
|
}, [])
|
||||||
[]
|
.sort((a, b) => b.score - a.score);
|
||||||
).sort((a,b) => b.score - a.score);
|
|
||||||
|
|
||||||
// Create a new workbook and add a worksheet
|
// Create a new workbook and add a worksheet
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
@@ -179,10 +186,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
label: "Email",
|
label: "Email",
|
||||||
value: (entry: TableData) => entry.email,
|
value: (entry: TableData) => entry.email,
|
||||||
},
|
},
|
||||||
{
|
...(displaySelection
|
||||||
label: "Corporate",
|
? [
|
||||||
value: (entry: TableData) => entry.corporate,
|
{
|
||||||
},
|
label: "Corporate",
|
||||||
|
value: (entry: TableData) => entry.corporate,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: "Assignment",
|
label: "Assignment",
|
||||||
value: (entry: TableData) => entry.assignment,
|
value: (entry: TableData) => entry.assignment,
|
||||||
@@ -229,7 +240,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const refName = `statistical/${fileName}`;
|
const refName = `statistical/${fileName}`;
|
||||||
const fileRef = ref(storage, refName);
|
const fileRef = ref(storage, refName);
|
||||||
// upload the pdf to storage
|
// upload the pdf to storage
|
||||||
const snapshot = await uploadBytes(fileRef, buffer, {
|
await uploadBytes(fileRef, buffer, {
|
||||||
contentType:
|
contentType:
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return GET(req, res);
|
if (req.method === "GET") return GET(req, res);
|
||||||
@@ -17,18 +16,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
const code = await db.collection("codes").findOne({ id: id as string });
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "codes", id as string));
|
res.status(200).json(code);
|
||||||
|
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
|
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
const code = await db.collection("codes").findOne({ id: id as string });
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "codes", id as string));
|
if (!code) return res.status(404).json;
|
||||||
if (!snapshot.exists()) return res.status(404).json;
|
await db.collection("codes").deleteOne({ id: id as string });
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
res.status(200).json(code);
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { Code, Group, Type } from "@/interfaces/user";
|
||||||
import {Code, Group, Type} from "@/interfaces/user";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||||
import {uuidv4} from "@firebase/util";
|
|
||||||
import {prepareMailer, prepareMailOptions} from "@/email";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -18,32 +16,31 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (req.method === "POST") return post(req, res);
|
if (req.method === "POST") return post(req, res);
|
||||||
if (req.method === "DELETE") return del(req, res);
|
if (req.method === "DELETE") return del(req, res);
|
||||||
|
|
||||||
return res.status(404).json({ok: false});
|
return res.status(404).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
|
res.status(401).json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {creator} = req.query as {creator?: string};
|
const { creator } = req.query as { creator?: string };
|
||||||
const q = query(collection(db, "codes"), where("creator", "==", creator || ""));
|
const snapshot = await db.collection("codes").find(creator ? { creator: creator } : {}).toArray();
|
||||||
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
|
|
||||||
|
|
||||||
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
|
res.status(200).json(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
|
res.status(401).json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {type, codes, infos, expiryDate} = req.body as {
|
const { type, codes, infos, expiryDate } = req.body as {
|
||||||
type: Type;
|
type: Type;
|
||||||
codes: string[];
|
codes: string[];
|
||||||
infos?: {email: string; name: string; passport_id?: string}[];
|
infos?: { email: string; name: string; passport_id?: string }[];
|
||||||
expiryDate: null | Date;
|
expiryDate: null | Date;
|
||||||
};
|
};
|
||||||
const permission = PERMISSIONS.generateCode[type];
|
const permission = PERMISSIONS.generateCode[type];
|
||||||
@@ -56,19 +53,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
|
const userCodes = await db.collection("codes").find<Code>({ creator: req.session.user.id }).toArray()
|
||||||
const creatorGroupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", req.session.user.id)));
|
const creatorGroupsSnapshot = await db.collection("groups").find<Group>({ admin: req.session.user.id }).toArray()
|
||||||
|
|
||||||
const creatorGroups = (
|
|
||||||
creatorGroupsSnapshot.docs.map((x) => ({
|
|
||||||
...x.data(),
|
|
||||||
})) as Group[]
|
|
||||||
).filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
|
|
||||||
|
|
||||||
|
const creatorGroups = creatorGroupsSnapshot.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
|
||||||
const usersInGroups = creatorGroups.flatMap((x) => x.participants);
|
const usersInGroups = creatorGroups.flatMap((x) => x.participants);
|
||||||
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
|
|
||||||
...x.data(),
|
|
||||||
})) as Code[];
|
|
||||||
|
|
||||||
if (req.session.user.type === "corporate") {
|
if (req.session.user.type === "corporate") {
|
||||||
const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
|
const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
|
||||||
@@ -77,16 +67,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (totalCodes > allowedCodes) {
|
if (totalCodes > allowedCodes) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
|
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${allowedCodes - userCodes.length
|
||||||
allowedCodes - codesGeneratedByUserSnapshot.docs.length
|
} codes.`,
|
||||||
} codes.`,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const codePromises = codes.map(async (code, index) => {
|
const codePromises = codes.map(async (code, index) => {
|
||||||
const codeRef = doc(db, "codes", code);
|
const codeRef = await db.collection("codes").findOne<Code>({ id: code });
|
||||||
let codeInformation = {
|
let codeInformation = {
|
||||||
type,
|
type,
|
||||||
code,
|
code,
|
||||||
@@ -96,7 +85,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (infos && infos.length > index) {
|
if (infos && infos.length > index) {
|
||||||
const {email, name, passport_id} = infos[index];
|
const { email, name, passport_id } = infos[index];
|
||||||
const previousCode = userCodes.find((x) => x.email === email) as Code;
|
const previousCode = userCodes.find((x) => x.email === email) as Code;
|
||||||
|
|
||||||
const transport = prepareMailer();
|
const transport = prepareMailer();
|
||||||
@@ -114,16 +103,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
try {
|
try {
|
||||||
await transport.sendMail(mailOptions);
|
await transport.sendMail(mailOptions);
|
||||||
|
|
||||||
if (!previousCode) {
|
if (!previousCode && codeRef) {
|
||||||
await setDoc(
|
await db.collection("codes").updateOne(
|
||||||
codeRef,
|
{ id: codeRef.id },
|
||||||
{
|
{
|
||||||
...codeInformation,
|
$set: {
|
||||||
email: email.trim().toLowerCase(),
|
id: codeRef.id,
|
||||||
name: name.trim(),
|
...codeInformation,
|
||||||
...(passport_id ? {passport_id: passport_id.trim()} : {}),
|
email: email.trim().toLowerCase(),
|
||||||
|
name: name.trim(),
|
||||||
|
...(passport_id ? { passport_id: passport_id.trim() } : {}),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{merge: true},
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,29 +124,34 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await setDoc(codeRef, codeInformation);
|
// upsert: true -> if it doesnt exist insert
|
||||||
|
await db.collection("codes").updateOne(
|
||||||
|
{ id: code },
|
||||||
|
{ $set: { id: code, ...codeInformation} },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Promise.all(codePromises).then((results) => {
|
Promise.all(codePromises).then((results) => {
|
||||||
res.status(200).json({ok: true, valid: results.filter((x) => x).length});
|
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
|
res.status(401).json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codes = req.query.code as string[];
|
const codes = req.query.code as string[];
|
||||||
|
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
const snapshot = await getDoc(doc(db, "codes", code as string));
|
const snapshot = await db.collection("codes").findOne<Code>({ id: code as string });
|
||||||
if (!snapshot.exists()) continue;
|
if (!snapshot) continue;
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("codes").deleteOne({ id: snapshot.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({codes});
|
res.status(200).json({ codes });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,78 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
getFirestore,
|
import {sessionOptions} from "@/lib/session";
|
||||||
doc,
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
getDoc,
|
|
||||||
deleteDoc,
|
|
||||||
setDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return get(req, res);
|
if (req.method === "GET") return get(req, res);
|
||||||
if (req.method === "DELETE") return del(req, res);
|
if (req.method === "DELETE") return del(req, res);
|
||||||
if (req.method === "PATCH") return patch(req, res);
|
if (req.method === "PATCH") return patch(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
const docSnap = await db.collection("discounts").findOne({id: id});
|
||||||
|
|
||||||
const docRef = doc(db, "discounts", id);
|
if (docSnap) {
|
||||||
const docSnap = await getDoc(docRef);
|
res.status(200).json(docSnap);
|
||||||
|
} else {
|
||||||
if (docSnap.exists()) {
|
res.status(404).json(undefined);
|
||||||
res.status(200).json({
|
}
|
||||||
id: docSnap.id,
|
|
||||||
...docSnap.data(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.status(404).json(undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
const docSnap = await db.collection("discounts").findOne({id: id});
|
||||||
|
|
||||||
const docRef = doc(db, "discounts", id);
|
if (docSnap) {
|
||||||
const docSnap = await getDoc(docRef);
|
if (!["developer", "admin"].includes(req.session.user.type)) {
|
||||||
|
res.status(403).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
await db.collection("discounts").updateOne({id: id}, {$set: {id: id, ...req.body}}, {upsert: true});
|
||||||
if (!["developer", "admin"].includes(req.session.user.type)) {
|
|
||||||
res.status(403).json({ ok: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await setDoc(docRef, req.body, { merge: true });
|
res.status(200).json({ok: true});
|
||||||
|
} else {
|
||||||
res.status(200).json({ ok: true });
|
res.status(404).json({ok: false});
|
||||||
} else {
|
}
|
||||||
res.status(404).json({ ok: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
const docSnap = await db.collection("discounts").findOne({id: id});
|
||||||
|
|
||||||
const docRef = doc(db, "discounts", id);
|
if (docSnap) {
|
||||||
const docSnap = await getDoc(docRef);
|
if (!["developer", "admin"].includes(req.session.user.type)) {
|
||||||
|
res.status(403).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
await db.collection("discounts").deleteOne({id: id});
|
||||||
if (!["developer", "admin"].includes(req.session.user.type)) {
|
|
||||||
res.status(403).json({ ok: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteDoc(docRef);
|
res.status(200).json({ok: true});
|
||||||
|
} else {
|
||||||
res.status(200).json({ ok: true });
|
res.status(404).json({ok: false});
|
||||||
} else {
|
}
|
||||||
res.status(404).json({ ok: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {
|
|
||||||
getFirestore,
|
|
||||||
collection,
|
|
||||||
getDocs,
|
|
||||||
setDoc,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
deleteDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { Group } from "@/interfaces/user";
|
import { Group } from "@/interfaces/user";
|
||||||
import { Discount, Package } from "@/interfaces/paypal";
|
import { Discount, Package } from "@/interfaces/paypal";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -32,14 +23,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await getDocs(collection(db, "discounts"));
|
const snapshot = await db.collection("discounts").find({}).toArray();
|
||||||
|
res.status(200).json(snapshot);
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -56,7 +41,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const body = req.body as Discount;
|
const body = req.body as Discount;
|
||||||
|
|
||||||
await setDoc(doc(db, "discounts", v4()), body);
|
await db.collection("discounts").insertOne({ ...body });
|
||||||
|
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +57,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const discounts = req.query.discount as string[];
|
const discounts = req.query.discount as string[];
|
||||||
|
|
||||||
for (const discount of discounts) {
|
for (const discount of discounts) {
|
||||||
const snapshot = await getDoc(doc(db, "discounts", discount as string));
|
const snapshot = await db.collection("discounts").findOne({ id: discount as string });
|
||||||
if (!snapshot.exists()) continue;
|
if (!snapshot) continue;
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("discounts").deleteOne({ id: discount as string });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ discounts });
|
res.status(200).json({ discounts });
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import axios, {AxiosResponse} from "axios";
|
|||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
import {ref, uploadBytes} from "firebase/storage";
|
import {ref, uploadBytes} from "firebase/storage";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {app, storage} from "@/firebase";
|
import {storage} from "@/firebase";
|
||||||
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
import {speakingReverseMarking} from "@/utils/score";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
@@ -53,18 +54,21 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
||||||
|
|
||||||
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
|
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
|
||||||
await setDoc(
|
await db.collection("stats").updateOne(
|
||||||
doc(db, "stats", fields.id),
|
{ id: fields.id },
|
||||||
{
|
{
|
||||||
|
$set: {
|
||||||
|
id: fields.id,
|
||||||
solutions,
|
solutions,
|
||||||
score: {
|
score: {
|
||||||
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
||||||
missing: 0,
|
missing: 0,
|
||||||
total: 100,
|
total: 100,
|
||||||
},
|
},
|
||||||
isDisabled: false,
|
isDisabled: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{merge: true},
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
console.log("🌱 - Updated the DB");
|
console.log("🌱 - Updated the DB");
|
||||||
});
|
});
|
||||||
@@ -72,9 +76,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
||||||
console.log(`🌱 - Try number ${index} - ${id}`);
|
console.log(`🌱 - Try number ${index} - ${id}`);
|
||||||
const correspondingStat = await getDoc(doc(db, "stats", id));
|
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
|
||||||
|
|
||||||
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
|
if (correspondingStat) return correspondingStat;
|
||||||
await delay(3 * 10000);
|
await delay(3 * 10000);
|
||||||
return getCorrespondingStat(id, index + 1);
|
return getCorrespondingStat(id, index + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import axios, {AxiosResponse} from "axios";
|
|||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {app, storage} from "@/firebase";
|
import {storage} from "@/firebase";
|
||||||
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
import {speakingReverseMarking} from "@/utils/score";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
@@ -51,9 +51,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
solution: url,
|
solution: url,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await setDoc(
|
await db.collection("stats").updateOne(
|
||||||
doc(db, "stats", fields.id),
|
{ id: fields.id },
|
||||||
{
|
{
|
||||||
|
id: fields.id,
|
||||||
solutions,
|
solutions,
|
||||||
score: {
|
score: {
|
||||||
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
||||||
@@ -62,7 +63,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
},
|
},
|
||||||
{merge: true},
|
{upsert: true},
|
||||||
);
|
);
|
||||||
console.log("🌱 - Updated the DB");
|
console.log("🌱 - Updated the DB");
|
||||||
});
|
});
|
||||||
@@ -70,9 +71,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
||||||
console.log(`🌱 - Try number ${index} - ${id}`);
|
console.log(`🌱 - Try number ${index} - ${id}`);
|
||||||
const correspondingStat = await getDoc(doc(db, "stats", id));
|
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
|
||||||
|
|
||||||
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
|
if (correspondingStat) return correspondingStat;
|
||||||
await delay(3 * 10000);
|
await delay(3 * 10000);
|
||||||
return getCorrespondingStat(id, index + 1);
|
return getCorrespondingStat(id, index + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import axios, {AxiosResponse} from "axios";
|
import axios, {AxiosResponse} from "axios";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import {writingReverseMarking} from "@/utils/score";
|
import {writingReverseMarking} from "@/utils/score";
|
||||||
|
|
||||||
@@ -19,7 +18,8 @@ function delay(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -37,9 +37,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const correspondingStat = await getCorrespondingStat(req.body.id, 1);
|
const correspondingStat = await getCorrespondingStat(req.body.id, 1);
|
||||||
|
|
||||||
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data}));
|
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data}));
|
||||||
await setDoc(
|
await db.collection("stats").updateOne(
|
||||||
doc(db, "stats", (req.body as Body).id),
|
{ id: (req.body as Body).id},
|
||||||
{
|
{
|
||||||
|
id: (req.body as Body).id,
|
||||||
solutions,
|
solutions,
|
||||||
score: {
|
score: {
|
||||||
correct: writingReverseMarking[backendRequest.data.overall],
|
correct: writingReverseMarking[backendRequest.data.overall],
|
||||||
@@ -48,16 +49,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
},
|
},
|
||||||
{merge: true},
|
{upsert: true},
|
||||||
);
|
);
|
||||||
console.log("🌱 - Updated the DB");
|
console.log("🌱 - Updated the DB");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
||||||
console.log(`🌱 - Try number ${index} - ${id}`);
|
console.log(`🌱 - Try number ${index} - ${id}`);
|
||||||
const correspondingStat = await getDoc(doc(db, "stats", id));
|
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id});
|
||||||
|
|
||||||
|
if (correspondingStat) return correspondingStat;
|
||||||
|
|
||||||
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
|
|
||||||
await delay(3 * 10000);
|
await delay(3 * 10000);
|
||||||
return getCorrespondingStat(id, index + 1);
|
return getCorrespondingStat(id, index + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -24,13 +23,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {module, id} = req.query as {module: string; id: string};
|
const {module, id} = req.query as {module: string; id: string};
|
||||||
|
|
||||||
const docRef = doc(db, module, id);
|
const docSnap = await db.collection(module).findOne({ id: id});
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
if (docSnap) {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
id: docSnap.id,
|
...docSnap,
|
||||||
...docSnap.data(),
|
|
||||||
module,
|
module,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -46,11 +43,14 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {module, id} = req.query as {module: string; id: string};
|
const {module, id} = req.query as {module: string; id: string};
|
||||||
|
|
||||||
const docRef = doc(db, module, id);
|
const docSnap = await db.collection(module).findOne({ id: id});
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
if (docSnap) {
|
||||||
await setDoc(docRef, req.body, {merge: true});
|
await db.collection(module).updateOne(
|
||||||
|
{ id: id},
|
||||||
|
{ $set: { id: id, ...req.body }},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ok: false});
|
res.status(404).json({ok: false});
|
||||||
@@ -65,16 +65,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {module, id} = req.query as {module: string; id: string};
|
const {module, id} = req.query as {module: string; id: string};
|
||||||
|
|
||||||
const docRef = doc(db, module, id);
|
const docSnap = await db.collection(module).findOne({ id: id});
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
if (docSnap) {
|
||||||
if (!PERMISSIONS.examManagement.delete.includes(req.session.user.type)) {
|
if (!PERMISSIONS.examManagement.delete.includes(req.session.user.type)) {
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteDoc(docRef);
|
await db.collection(module).deleteOne({ id: id });
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {shuffle} from "lodash";
|
|
||||||
import {Difficulty, Exam} from "@/interfaces/exam";
|
import {Difficulty, Exam} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {shuffle} from "lodash";
|
|
||||||
import {Difficulty, Exam} from "@/interfaces/exam";
|
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, setDoc, doc, runTransaction, collection, query, where, getDocs} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { Exam, InstructorGender, Variant } from "@/interfaces/exam";
|
||||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
import { getExams } from "@/utils/exams.be";
|
||||||
import {getExams} from "@/utils/exams.be";
|
import { Module } from "@/interfaces";
|
||||||
import {Module} from "@/interfaces";
|
import { getUserCorporate } from "@/utils/groups.be";
|
||||||
import {getUserCorporate} from "@/utils/groups.be";
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -16,16 +16,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (req.method === "GET") return await GET(req, res);
|
if (req.method === "GET") return await GET(req, res);
|
||||||
if (req.method === "POST") return await POST(req, res);
|
if (req.method === "POST") return await POST(req, res);
|
||||||
|
|
||||||
res.status(404).json({ok: false});
|
res.status(404).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
const { module, avoidRepeated, variant, instructorGender } = req.query as {
|
||||||
module: Module;
|
module: Module;
|
||||||
avoidRepeated: string;
|
avoidRepeated: string;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
@@ -38,13 +38,15 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {module} = req.query as {module: string};
|
const { module } = req.query as { module: string };
|
||||||
const corporate = await getUserCorporate(req.session.user.id);
|
const corporate = await getUserCorporate(req.session.user.id);
|
||||||
|
|
||||||
|
const session = client.startSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exam = {
|
const exam = {
|
||||||
...req.body,
|
...req.body,
|
||||||
@@ -57,20 +59,25 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await runTransaction(db, async (transaction) => {
|
await session.withTransaction(async () => {
|
||||||
const docRef = doc(db, module, req.body.id);
|
const docSnap = await db.collection(module).findOne({ id: req.body.id }, { session });
|
||||||
const docSnap = await transaction.get(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
if (docSnap) {
|
||||||
throw new Error("Name already exists");
|
throw new Error("Name already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDocRef = doc(db, module, req.body.id);
|
await db.collection(module).insertOne(
|
||||||
transaction.set(newDocRef, exam);
|
{ id: req.body.id, ...exam },
|
||||||
|
{ session }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json(exam);
|
res.status(200).json(exam);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Transaction failed: ", error);
|
console.error("Transaction failed: ", error);
|
||||||
res.status(500).json({ok: false, error: (error as any).message});
|
res.status(500).json({ ok: false, error: (error as any).message });
|
||||||
|
} finally {
|
||||||
|
session.endSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {flatten} from "lodash";
|
import {flatten} from "lodash";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -25,16 +24,12 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
||||||
const moduleRef = collection(db, module);
|
const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray();
|
||||||
|
|
||||||
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
return snapshot.map((doc) => ({
|
||||||
const snapshot = await getDocs(q);
|
...doc,
|
||||||
|
|
||||||
return snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
module,
|
module,
|
||||||
})) as Exam[];
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const moduleExams = await Promise.all(moduleExamsPromises);
|
const moduleExams = await Promise.all(moduleExamsPromises);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc, getDoc, deleteDoc, query} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {CorporateUser, Group} from "@/interfaces/user";
|
import {CorporateUser, Group} from "@/interfaces/user";
|
||||||
@@ -14,10 +13,11 @@ import {getUserCorporate} from "@/utils/groups.be";
|
|||||||
import {Grading} from "@/interfaces";
|
import {Grading} from "@/interfaces";
|
||||||
import {getGroupsForUser} from "@/utils/groups.be";
|
import {getGroupsForUser} from "@/utils/groups.be";
|
||||||
import {uniq} from "lodash";
|
import {uniq} from "lodash";
|
||||||
import {getUser} from "@/utils/users.be";
|
import {getSpecificUsers, getUser} from "@/utils/users.be";
|
||||||
import { getGradingSystem } from "@/utils/grading.be";
|
import {getGradingSystem} from "@/utils/grading.be";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -36,6 +36,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(200).json(gradingSystem);
|
return res.status(200).json(gradingSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGrading(id: string, body: Grading) {
|
||||||
|
if (await db.collection("grading").findOne({id})) {
|
||||||
|
await db.collection("grading").updateOne({id}, {$set: body});
|
||||||
|
} else {
|
||||||
|
await db.collection("grading").insertOne({id, ...body});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ok: false});
|
||||||
@@ -49,16 +57,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const body = req.body as Grading;
|
const body = req.body as Grading;
|
||||||
await setDoc(doc(db, "grading", req.session.user.id), body);
|
await updateGrading(req.session.user.id, body);
|
||||||
|
|
||||||
if (req.session.user.type === "mastercorporate") {
|
if (req.session.user.type === "mastercorporate") {
|
||||||
const groups = await getGroupsForUser(req.session.user.id);
|
const groups = await getGroupsForUser(req.session.user.id);
|
||||||
const participants = uniq(groups.flatMap((x) => x.participants));
|
const participants = uniq(groups.flatMap((x) => x.participants));
|
||||||
|
|
||||||
const participantUsers = await Promise.all(participants.map(getUser));
|
const participantUsers = await getSpecificUsers(participants);
|
||||||
const corporateUsers = participantUsers.filter((x) => x.type === "corporate") as CorporateUser[];
|
const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[];
|
||||||
|
|
||||||
await Promise.all(corporateUsers.map(async (g) => await setDoc(doc(db, "grading", g.id), body)));
|
await Promise.all(corporateUsers.map(async (g) => await updateGrading(g.id, body)));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
|
|||||||
@@ -1,108 +1,91 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
getFirestore,
|
import {sessionOptions} from "@/lib/session";
|
||||||
collection,
|
import {Group} from "@/interfaces/user";
|
||||||
getDocs,
|
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
|
||||||
getDoc,
|
|
||||||
doc,
|
|
||||||
deleteDoc,
|
|
||||||
setDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import { Group } from "@/interfaces/user";
|
|
||||||
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return await get(req, res);
|
if (req.method === "GET") return await get(req, res);
|
||||||
if (req.method === "DELETE") return await del(req, res);
|
if (req.method === "DELETE") return await del(req, res);
|
||||||
if (req.method === "PATCH") return await patch(req, res);
|
if (req.method === "PATCH") return await patch(req, res);
|
||||||
|
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "groups", id));
|
const snapshot = await db.collection("groups").findOne({id: id});
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (snapshot) {
|
||||||
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
|
res.status(200).json({...snapshot});
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
const group = await db.collection("groups").findOne<Group>({id: id});
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "groups", id));
|
if (!group) {
|
||||||
const group = { ...snapshot.data(), id: snapshot.id } as Group;
|
res.status(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (
|
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
||||||
user.type === "admin" ||
|
await db.collection("groups").deleteOne({id: id});
|
||||||
user.type === "developer" ||
|
|
||||||
user.id === group.admin
|
|
||||||
) {
|
|
||||||
await deleteDoc(snapshot.ref);
|
|
||||||
|
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ok: true});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ ok: false });
|
res.status(403).json({ok: false});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "groups", id));
|
const group = await db.collection("groups").findOne<Group>({id: id});
|
||||||
const group = { ...snapshot.data(), id: snapshot.id } as Group;
|
if (!group) {
|
||||||
|
res.status(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (
|
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
||||||
user.type === "admin" ||
|
if ("participants" in req.body) {
|
||||||
user.type === "developer" ||
|
const newParticipants = (req.body.participants as string[]).filter((x) => !group.participants.includes(x));
|
||||||
user.id === group.admin
|
await Promise.all(newParticipants.map(async (p) => await updateExpiryDateOnGroup(p, group.admin)));
|
||||||
) {
|
}
|
||||||
if ("participants" in req.body) {
|
|
||||||
const newParticipants = (req.body.participants as string[]).filter(
|
|
||||||
(x) => !group.participants.includes(x),
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
newParticipants.map(
|
|
||||||
async (p) => await updateExpiryDateOnGroup(p, group.admin),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
await db.collection("groups").updateOne({id: req.session.user.id}, {$set: {id, ...req.body}}, {upsert: true});
|
||||||
|
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ok: true});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ ok: false });
|
res.status(403).json({ok: false});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
||||||
import {uniq, uniqBy} from "lodash";
|
import {uniq, uniqBy} from "lodash";
|
||||||
import {getUser} from "@/utils/users.be";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -41,10 +39,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
|
await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", v4()), {
|
await db.collection("groups").insertOne({
|
||||||
name: body.name,
|
id: v4(),
|
||||||
admin: body.admin,
|
name: body.name,
|
||||||
participants: body.participants,
|
admin: body.admin,
|
||||||
});
|
participants: body.participants,
|
||||||
|
})
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {
|
|
||||||
getFirestore,
|
|
||||||
getDoc,
|
|
||||||
doc,
|
|
||||||
deleteDoc,
|
|
||||||
setDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { Ticket } from "@/interfaces/ticket";
|
|
||||||
import { Invite } from "@/interfaces/invite";
|
import { Invite } from "@/interfaces/invite";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -33,10 +25,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "invites", id));
|
const snapshot = await db.collection("invites").findOne({ id: id });
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (snapshot) {
|
||||||
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
|
res.status(200).json(snapshot);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
@@ -50,12 +42,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "invites", id));
|
const snapshot = await db.collection("invites").findOne<Invite>({ id: id });
|
||||||
const data = snapshot.data() as Invite;
|
if(!snapshot){
|
||||||
|
res.status(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("invites").deleteOne({ id: id });
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -70,11 +65,14 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
const snapshot = await getDoc(doc(db, "invites", id));
|
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
await db.collection("invites").updateOne(
|
||||||
|
{ id: id },
|
||||||
|
{ $set: {id: id, ...req.body} },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
return res.status(200).json({ ok: true });
|
return res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { Invite } from "@/interfaces/invite";
|
||||||
import {Ticket} from "@/interfaces/ticket";
|
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||||
import {Invite} from "@/interfaces/invite";
|
import { v4 } from "uuid";
|
||||||
import {CorporateUser, Group, User} from "@/interfaces/user";
|
import { sendEmail } from "@/email";
|
||||||
import {v4} from "uuid";
|
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
||||||
import {sendEmail} from "@/email";
|
|
||||||
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -22,12 +20,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addToInviterGroup(user: User, invitedBy: User) {
|
async function addToInviterGroup(user: User, invitedBy: User) {
|
||||||
const invitedByGroupsRef = await getDocs(query(collection(db, "groups"), where("admin", "==", invitedBy.id)));
|
const invitedByGroups = await db.collection("groups").find<Group>({ admin: invitedBy.id }).toArray();
|
||||||
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
|
|
||||||
...g.data(),
|
|
||||||
id: g.id,
|
|
||||||
})) as Group[];
|
|
||||||
|
|
||||||
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
|
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
|
||||||
|
|
||||||
if (typeGroupName) {
|
if (typeGroupName) {
|
||||||
@@ -38,14 +31,18 @@ async function addToInviterGroup(user: User, invitedBy: User) {
|
|||||||
participants: [],
|
participants: [],
|
||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
await setDoc(
|
|
||||||
doc(db, "groups", typeGroup.id),
|
await db.collection("groups").updateOne(
|
||||||
|
{ id: typeGroup.id },
|
||||||
{
|
{
|
||||||
...typeGroup,
|
$set: {
|
||||||
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
|
...typeGroup,
|
||||||
|
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{merge: true},
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
|
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
|
||||||
@@ -55,54 +52,58 @@ async function addToInviterGroup(user: User, invitedBy: User) {
|
|||||||
participants: [],
|
participants: [],
|
||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
await setDoc(
|
|
||||||
doc(db, "groups", invitationsGroup.id),
|
await db.collection("groups").updateOne(
|
||||||
|
{ id: invitationsGroup.id },
|
||||||
{
|
{
|
||||||
...invitationsGroup,
|
$set: {
|
||||||
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
|
...invitationsGroup,
|
||||||
},
|
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
|
||||||
{
|
}
|
||||||
merge: true,
|
|
||||||
},
|
},
|
||||||
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
|
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
|
||||||
const corporatesRef = await getDocs(query(collection(db, "users"), where("type", "==", "corporate")));
|
const corporatesRef = await db.collection("users").find<CorporateUser>({ type: "corporate" }).toArray();
|
||||||
const corporates = (corporatesRef.docs.map((x) => ({...x.data(), id: x.id})) as CorporateUser[]).filter((x) => x.id !== invitedBy.id);
|
const corporates = corporatesRef.filter((x) => x.id !== invitedBy.id);
|
||||||
|
|
||||||
const userGroupsRef = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", user.id)));
|
const userGroups = await db.collection("groups").find<Group>({
|
||||||
const userGroups = userGroupsRef.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
|
participants: user.id
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
|
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
corporateGroups.map(async (group) => {
|
corporateGroups.map(async (group) => {
|
||||||
await setDoc(doc(db, "groups", group.id), {participants: group.participants.filter((x) => x !== user.id)}, {merge: true});
|
await db.collection("groups").updateOne(
|
||||||
|
{ id: group.id },
|
||||||
|
{ $set: { participants: group.participants.filter((x) => x !== user.id) } },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
const snapshot = await getDoc(doc(db, "invites", id));
|
const invite = await db.collection("invites").findOne<Invite>({ id: id});
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (invite) {
|
||||||
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
|
if (invite.to !== req.session.user.id) return res.status(403).json({ ok: false });
|
||||||
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
|
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("invites").deleteOne({ id: id });
|
||||||
const invitedByRef = await getDoc(doc(db, "users", invite.from));
|
|
||||||
if (!invitedByRef.exists()) return res.status(404).json({ok: false});
|
const invitedBy = await db.collection("users").findOne<User>({ id: invite.from});
|
||||||
|
if (!invitedBy) return res.status(404).json({ ok: false });
|
||||||
|
|
||||||
await updateExpiryDateOnGroup(invite.to, invite.from);
|
await updateExpiryDateOnGroup(invite.to, invite.from);
|
||||||
|
|
||||||
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
|
|
||||||
|
|
||||||
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
|
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
|
||||||
await addToInviterGroup(req.session.user, invitedBy);
|
await addToInviterGroup(req.session.user, invitedBy);
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Ticket} from "@/interfaces/ticket";
|
|
||||||
import {Invite} from "@/interfaces/invite";
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Group, User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {v4} from "uuid";
|
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -27,17 +24,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
const snapshot = await getDoc(doc(db, "invites", id));
|
const invite = await db.collection("invites").findOne<Invite>({ id: id});
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (invite) {
|
||||||
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
|
|
||||||
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
|
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("invites").deleteOne({ id: id });
|
||||||
const invitedByRef = await getDoc(doc(db, "users", invite.from));
|
const invitedBy = await db.collection("users").findOne<User>({ id: invite.from });
|
||||||
if (!invitedByRef.exists()) return res.status(404).json({ok: false});
|
if (!invitedBy) return res.status(404).json({ok: false});
|
||||||
|
|
||||||
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {Invite} from "@/interfaces/invite";
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Ticket} from "@/interfaces/ticket";
|
import {Ticket} from "@/interfaces/ticket";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {collection, doc, getDoc, getDocs, getFirestore, setDoc} from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -25,29 +24,20 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const snapshot = await getDocs(collection(db, "invites"));
|
const snapshot = await db.collection("invites").find({}).toArray();
|
||||||
|
res.status(200).json(snapshot);
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const body = req.body as Invite;
|
const body = req.body as Invite;
|
||||||
|
|
||||||
const existingInvites = (await getDocs(collection(db, "invites"))).docs.map((x) => ({...x.data(), id: x.id})) as Invite[];
|
const existingInvites = await db.collection("invites").find<Invite>({}).toArray();
|
||||||
|
|
||||||
const invitedRef = await getDoc(doc(db, "users", body.to));
|
const invited = await db.collection("users").findOne<User>({ id: body.to});
|
||||||
if (!invitedRef.exists()) return res.status(404).json({ok: false});
|
if (!invited) return res.status(404).json({ok: false});
|
||||||
|
|
||||||
const invitedByRef = await getDoc(doc(db, "users", body.from));
|
const invitedBy = await db.collection("users").findOne<User>({ id: body.from});
|
||||||
if (!invitedByRef.exists()) return res.status(404).json({ok: false});
|
if (!invitedBy) return res.status(404).json({ok: false});
|
||||||
|
|
||||||
const invited = {...invitedRef.data(), id: invitedRef.id} as User;
|
|
||||||
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
@@ -67,7 +57,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
if (existingInvites.filter((i) => i.to === body.to && i.from === body.from).length == 0) {
|
if (existingInvites.filter((i) => i.to === body.to && i.from === body.from).length == 0) {
|
||||||
const shortUID = new ShortUniqueId();
|
const shortUID = new ShortUniqueId();
|
||||||
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
|
await db.collection("invites").updateOne(
|
||||||
|
{ id: body.id || shortUID.randomUUID(8)},
|
||||||
|
{ $set: body },
|
||||||
|
{ upsert: true}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
|
import {getAuth, signInWithEmailAndPassword} from "firebase/auth";
|
||||||
import { app } from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import { getFirestore, getDoc, doc } from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(login, sessionOptions);
|
export default withIronSessionApiRoute(login, sessionOptions);
|
||||||
|
|
||||||
async function login(req: NextApiRequest, res: NextApiResponse) {
|
async function login(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { email, password } = req.body as { email: string; password: string };
|
const {email, password} = req.body as {email: string; password: string};
|
||||||
|
|
||||||
signInWithEmailAndPassword(auth, email.toLowerCase(), password)
|
signInWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
const userId = userCredentials.user.uid;
|
const userId = userCredentials.user.uid;
|
||||||
|
|
||||||
const docUser = await getDoc(doc(db, "users", userId));
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
if (!docUser.exists()) {
|
const user = await db.collection("users").findOne<User>({id: userId});
|
||||||
res.status(401).json({ error: 401, message: "User does not exist!" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = docUser.data() as User;
|
if (!user) return res.status(401).json({error: 401, message: "User does not exist!"});
|
||||||
|
|
||||||
req.session.user = { ...user, id: userId };
|
req.session.user = {...user, id: userId};
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({ user: { ...user, id: userId } });
|
res.status(200).json({user: {...user, id: userId}});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
res.status(401).json({ error });
|
res.status(401).json({error});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc, limit, updateDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import {CorporateUser, Group, Type} from "@/interfaces/user";
|
import {CorporateUser, Group, Type, User} from "@/interfaces/user";
|
||||||
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {getUserCorporate, getUserGroups} from "@/utils/groups.be";
|
import {getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup} from "@/utils/groups.be";
|
||||||
import {uniq} from "lodash";
|
import {uniq} from "lodash";
|
||||||
import {getUser} from "@/utils/users.be";
|
import {getSpecificUsers, getUser} from "@/utils/users.be";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const DEFAULT_DESIRED_LEVELS = {
|
const DEFAULT_DESIRED_LEVELS = {
|
||||||
reading: 9,
|
reading: 9,
|
||||||
@@ -26,15 +26,16 @@ const DEFAULT_LEVELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
const getUsersOfType = async (admin: string, type: Type) => {
|
const getUsersOfType = async (admin: string, type: Type) => {
|
||||||
const groups = await getUserGroups(admin);
|
const groups = await getUserGroups(admin);
|
||||||
const users = await Promise.all(uniq(groups.flatMap((x) => x.participants)).map(getUser));
|
const participants = groups.flatMap((x) => x.participants);
|
||||||
|
const users = await getSpecificUsers(participants);
|
||||||
|
|
||||||
return users.filter((x) => x.type === type).map((x) => x.id);
|
return users.filter((x) => x?.type === type).map((x) => x?.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -76,6 +77,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
bio: "",
|
bio: "",
|
||||||
|
id: userId,
|
||||||
type: type,
|
type: type,
|
||||||
focus: "academic",
|
focus: "academic",
|
||||||
status: "active",
|
status: "active",
|
||||||
@@ -101,8 +103,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const code = uid.randomUUID(6);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await db.collection("users").insertOne(user);
|
||||||
await setDoc(doc(db, "codes", code), {
|
await db.collection("codes").insertOne({
|
||||||
code,
|
code,
|
||||||
creator: maker.id,
|
creator: maker.id,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
@@ -134,34 +136,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
await db.collection("groups").insertMany([defaultStudentsGroup, defaultTeachersGroup]);
|
||||||
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!!corporate) {
|
if (!!corporate) {
|
||||||
const corporateQ = query(collection(db, "users"), where("email", "==", corporate.trim().toLowerCase()));
|
const corporateUser = await db.collection("users").findOne<CorporateUser>({email: corporate.trim().toLowerCase()});
|
||||||
const corporateSnapshot = await getDocs(corporateQ);
|
|
||||||
|
|
||||||
if (!corporateSnapshot.empty) {
|
if (!!corporateUser) {
|
||||||
const corporateUser = {...corporateSnapshot.docs[0].data(), id: corporateSnapshot.docs[0].id} as CorporateUser;
|
await db.collection("codes").updateOne({code}, {$set: {creator: corporateUser.id}});
|
||||||
await setDoc(doc(db, "codes", code), {creator: corporateUser.id}, {merge: true});
|
const typeGroup = await db
|
||||||
|
.collection("groups")
|
||||||
|
.findOne<Group>({creator: corporateUser.id, name: type === "student" ? "Students" : "Teachers"});
|
||||||
|
|
||||||
const q = query(
|
if (!!typeGroup) {
|
||||||
collection(db, "groups"),
|
if (!typeGroup.participants.includes(userId)) {
|
||||||
where("admin", "==", corporateUser.id),
|
await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}});
|
||||||
where("name", "==", type === "student" ? "Students" : "Teachers"),
|
|
||||||
limit(1),
|
|
||||||
);
|
|
||||||
const snapshot = await getDocs(q);
|
|
||||||
|
|
||||||
if (!snapshot.empty) {
|
|
||||||
const doc = snapshot.docs[0];
|
|
||||||
const participants: string[] = doc.get("participants");
|
|
||||||
|
|
||||||
if (!participants.includes(userId)) {
|
|
||||||
await updateDoc(doc.ref, {
|
|
||||||
participants: [...participants, userId],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const defaultGroup: Group = {
|
const defaultGroup: Group = {
|
||||||
@@ -172,30 +161,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup);
|
await db.collection("groups").insertOne(defaultGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maker.type === "corporate") {
|
if (maker.type === "corporate") {
|
||||||
await setDoc(doc(db, "codes", code), {creator: maker.id}, {merge: true});
|
await db.collection("codes").updateOne({code}, {$set: {creator: maker.id}});
|
||||||
|
const typeGroup = await getUserNamedGroup(maker.id, type === "student" ? "Students" : "Teachers");
|
||||||
|
|
||||||
const q = query(
|
if (!!typeGroup) {
|
||||||
collection(db, "groups"),
|
if (!typeGroup.participants.includes(userId)) {
|
||||||
where("admin", "==", maker.id),
|
await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}});
|
||||||
where("name", "==", type === "student" ? "Students" : "Teachers"),
|
|
||||||
limit(1),
|
|
||||||
);
|
|
||||||
const snapshot = await getDocs(q);
|
|
||||||
|
|
||||||
if (!snapshot.empty) {
|
|
||||||
const doc = snapshot.docs[0];
|
|
||||||
const participants: string[] = doc.get("participants");
|
|
||||||
|
|
||||||
if (!participants.includes(userId)) {
|
|
||||||
await updateDoc(doc.ref, {
|
|
||||||
participants: [...participants, userId],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const defaultGroup: Group = {
|
const defaultGroup: Group = {
|
||||||
@@ -206,22 +183,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup);
|
await db.collection("groups").insertOne(defaultGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") {
|
if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") {
|
||||||
const q = query(collection(db, "groups"), where("admin", "==", corporateCorporate.id), where("name", "==", "corporate"), limit(1));
|
const corporateGroup = await getUserNamedGroup(corporateCorporate.id, "Corporate");
|
||||||
const snapshot = await getDocs(q);
|
|
||||||
|
|
||||||
if (!snapshot.empty) {
|
if (!!corporateGroup) {
|
||||||
const doc = snapshot.docs[0];
|
if (!corporateGroup.participants.includes(userId)) {
|
||||||
const participants: string[] = doc.get("participants");
|
await db
|
||||||
|
.collection("groups")
|
||||||
if (!participants.includes(userId)) {
|
.updateOne({id: corporateGroup.id}, {$set: {participants: [...corporateGroup.participants, userId]}});
|
||||||
await updateDoc(doc.ref, {
|
|
||||||
participants: [...participants, userId],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const defaultGroup: Group = {
|
const defaultGroup: Group = {
|
||||||
@@ -232,19 +205,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup);
|
await db.collection("groups").insertOne(defaultGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!!groupID) {
|
if (!!groupID) {
|
||||||
const groupSnapshot = await getDoc(doc(db, "groups", groupID));
|
const group = await getGroup(groupID);
|
||||||
await setDoc(groupSnapshot.ref, {participants: [...groupSnapshot.data()!.participants, userId]}, {merge: true});
|
if (!!group) await db.collection("groups").updateOne({id: group.id}, {$set: {participants: [...group.participants, userId]}});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Returning - ${email}`);
|
console.log(`Returning - ${email}`);
|
||||||
return res.status(200).json({ok: true});
|
return res.status(200).json({ok: true});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (error.code.includes("email-already-in-use")) return res.status(403).json({error, message: "E-mail is already in the platform."});
|
||||||
|
|
||||||
console.log(`Failing - ${email}`);
|
console.log(`Failing - ${email}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
return res.status(401).json({error});
|
return res.status(401).json({error});
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -23,14 +22,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
const docSnap = await db.collection("packages").findOne({ id: id});
|
||||||
|
|
||||||
const docRef = doc(db, "packages", id);
|
if (docSnap) {
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
id: docSnap.id,
|
...docSnap,
|
||||||
...docSnap.data(),
|
|
||||||
module,
|
module,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -46,16 +42,16 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docRef = doc(db, "packages", id);
|
const docSnap = await db.collection("packages").findOne({ id: id});
|
||||||
const docSnap = await getDoc(docRef);
|
if (docSnap) {
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
|
||||||
if (!["developer", "admin"].includes(req.session.user.type)) {
|
if (!["developer", "admin"].includes(req.session.user.type)) {
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await db.collection("packages").updateOne(
|
||||||
await setDoc(docRef, req.body, {merge: true});
|
{ id: id },
|
||||||
|
{ $set: req.body }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
} else {
|
} else {
|
||||||
@@ -71,16 +67,14 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docRef = doc(db, "packages", id);
|
const docSnap = await db.collection("packages").findOne({ id: id});
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
if (docSnap) {
|
||||||
if (!["developer", "admin"].includes(req.session.user.type)) {
|
if (!["developer", "admin"].includes(req.session.user.type)) {
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await db.collection("packages").deleteOne({ id: id });
|
||||||
await deleteDoc(docRef);
|
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
import {Package} from "@/interfaces/paypal";
|
import {Package} from "@/interfaces/paypal";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -18,14 +17,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const snapshot = await getDocs(collection(db, "packages"));
|
const snapshot = await db.collection("packages").find({}).toArray();
|
||||||
|
res.status(200).json(snapshot);
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -38,7 +31,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"});
|
return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"});
|
||||||
|
|
||||||
const body = req.body as Package;
|
const body = req.body as Package;
|
||||||
|
// Package already had an id but a new one was being set
|
||||||
await setDoc(doc(db, "packages", v4()), body);
|
// with v4() don't know if intentional or not, recreated the behaviour as was
|
||||||
|
await db.collection("packages").insertOne({...body, id: v4()})
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app, storage} from "@/firebase";
|
import {storage} from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
import {Payment} from "@/interfaces/paypal";
|
import {Payment} from "@/interfaces/paypal";
|
||||||
import {deleteObject, ref} from "firebase/storage";
|
import {deleteObject, ref} from "firebase/storage";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "payments", id));
|
const payment = await db.collection("payments").findOne<Payment>({id});
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (!!payment) {
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
res.status(200).json(payment);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
@@ -45,15 +45,15 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "payments", id));
|
const payment = await db.collection("payments").findOne<Payment>({id});
|
||||||
const data = snapshot.data() as Payment;
|
if (!payment) return res.status(404).json({ok: false});
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
if (data.commissionTransfer) await deleteObject(ref(storage, data.commissionTransfer));
|
if (payment.commissionTransfer) await deleteObject(ref(storage, payment.commissionTransfer));
|
||||||
if (data.corporateTransfer) await deleteObject(ref(storage, data.corporateTransfer));
|
if (payment.corporateTransfer) await deleteObject(ref(storage, payment.corporateTransfer));
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("payments").deleteOne({id: payment.id});
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,15 +68,17 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
const snapshot = await getDoc(doc(db, "payments", id));
|
|
||||||
|
const payment = await db.collection("payments").findOne<Payment>({id});
|
||||||
|
if (!payment) return res.status(404).json({ok: false});
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
await db.collection("payments").updateOne({id: payment.id}, {$set: req.body});
|
||||||
|
|
||||||
if (req.body.isPaid) {
|
if (req.body.isPaid) {
|
||||||
const corporateID = req.body.corporate;
|
const corporateID = req.body.corporate;
|
||||||
await setDoc(doc(db, "users", corporateID), {status: "active"}, {merge: true});
|
await db.collection("users").updateOne({id: corporateID}, {$set: {status: "active"}});
|
||||||
}
|
}
|
||||||
return res.status(200).json({ok: true});
|
return res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,62 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {
|
import {sessionOptions} from "@/lib/session";
|
||||||
getFirestore,
|
import {Payment} from "@/interfaces/paypal";
|
||||||
collection,
|
import {PaymentsStatus} from "@/interfaces/user.payments";
|
||||||
getDocs,
|
import client from "@/lib/mongodb";
|
||||||
query,
|
|
||||||
where,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import { Payment } from "@/interfaces/paypal";
|
|
||||||
import { PaymentsStatus } from "@/interfaces/user.payments";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return await get(req, res);
|
if (req.method === "GET") return await get(req, res);
|
||||||
|
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// user can fetch payments assigned to him as an agent
|
// user can fetch payments assigned to him as an agent
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if it's an admin, don't apply query filters
|
const payments = await db
|
||||||
const whereClauses = ["admin", "developer"].includes(req.session.user.type)
|
.collection("payments")
|
||||||
? []
|
.find(["admin", "developer"].includes(req.session.user.type) ? {} : {[req.session.user.type]: req.session.user.id})
|
||||||
: [
|
.toArray();
|
||||||
// where("agent", "==", "xRMirufz6PPQqxKBgvPTWiWKBD63"),
|
|
||||||
where(req.session.user.type, "==", req.session.user.id),
|
|
||||||
// Based on the logic of query we should be able to do this:
|
|
||||||
// where("isPaid", "==", paid === "paid"),
|
|
||||||
// but for some reason it is ignoring all but the first clause
|
|
||||||
// I opted into only fetching relevant content for the user
|
|
||||||
// and then filter it with JS
|
|
||||||
];
|
|
||||||
|
|
||||||
const codeQuery = query(collection(db, "payments"), ...whereClauses);
|
if (payments.length === 0) {
|
||||||
|
res.status(200).json({
|
||||||
|
pending: [],
|
||||||
|
done: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = await getDocs(codeQuery);
|
const paidStatusEntries = payments.reduce(
|
||||||
if (snapshot.empty) {
|
(acc: PaymentsStatus, doc) => {
|
||||||
res.status(200).json({
|
if (doc.isPaid) {
|
||||||
pending: [],
|
return {
|
||||||
done: [],
|
...acc,
|
||||||
});
|
done: [...acc.done, doc.corporate],
|
||||||
return;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const docs = snapshot.docs.map((doc) => ({
|
return {
|
||||||
id: doc.id,
|
...acc,
|
||||||
...doc.data(),
|
pending: [...acc.pending, doc.corporate],
|
||||||
})) as Payment[];
|
};
|
||||||
|
},
|
||||||
const paidStatusEntries = docs.reduce(
|
{
|
||||||
(acc: PaymentsStatus, doc) => {
|
pending: [],
|
||||||
if (doc.isPaid) {
|
done: [],
|
||||||
return {
|
},
|
||||||
...acc,
|
);
|
||||||
done: [...acc.done, doc.corporate],
|
res.status(200).json({
|
||||||
};
|
pending: [...new Set(paidStatusEntries.pending)],
|
||||||
}
|
done: [...new Set(paidStatusEntries.done)],
|
||||||
|
});
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
pending: [...acc.pending, doc.corporate],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pending: [],
|
|
||||||
done: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
res.status(200).json({
|
|
||||||
pending: [...new Set(paidStatusEntries.pending)],
|
|
||||||
done: [...new Set(paidStatusEntries.done)],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app, storage} from "@/firebase";
|
import { storage } from "@/firebase";
|
||||||
import {getFirestore, getDoc, doc, updateDoc, deleteField, setDoc} from "firebase/firestore";
|
import client from "@/lib/mongodb";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {FilesStorage} from "@/interfaces/storage.files";
|
import { FilesStorage } from "@/interfaces/storage.files";
|
||||||
|
|
||||||
import {Payment} from "@/interfaces/paypal";
|
import { Payment } from "@/interfaces/paypal";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {ref, uploadBytes, deleteObject, getDownloadURL} from "firebase/storage";
|
import { ref, uploadBytes, deleteObject, getDownloadURL } from "firebase/storage";
|
||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
const getPaymentField = (type: FilesStorage) => {
|
const getPaymentField = (type: FilesStorage) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -25,28 +25,36 @@ const getPaymentField = (type: FilesStorage) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => {
|
const handleDelete = async (paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => {
|
||||||
const paymentRef = doc(db, "payments", paymentId);
|
const paymentDoc = await db.collection("payments").findOne<Payment>({ id: paymentId })
|
||||||
const paymentDoc = await getDoc(paymentRef);
|
|
||||||
const {[paymentField]: paymentFieldPath} = paymentDoc.data() as Payment;
|
if (paymentDoc) {
|
||||||
// Create a reference to the file to delete
|
const { [paymentField]: paymentFieldPath } = paymentDoc;
|
||||||
const documentRef = ref(storage, paymentFieldPath);
|
|
||||||
await deleteObject(documentRef);
|
// Create a reference to the file to delete
|
||||||
await updateDoc(paymentRef, {
|
const documentRef = ref(storage, paymentFieldPath);
|
||||||
[paymentField]: deleteField(),
|
await deleteObject(documentRef);
|
||||||
isPaid: false,
|
await db.collection("payments").deleteOne({ id: paymentId });
|
||||||
});
|
|
||||||
|
await db.collection("payments").updateOne(
|
||||||
|
{ id: paymentId },
|
||||||
|
{
|
||||||
|
$unset: { [paymentField]: "" },
|
||||||
|
$set: { isPaid: false }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") =>
|
const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const form = formidable({keepExtensions: true});
|
const form = formidable({ keepExtensions: true });
|
||||||
form.parse(req, async (err: any, fields: any, files: any) => {
|
form.parse(req, async (err: any, fields: any, files: any) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const {file} = files;
|
const { file } = files;
|
||||||
const fileName = Date.now() + "-" + file.name;
|
const fileName = Date.now() + "-" + file.name;
|
||||||
const fileRef = ref(storage, fileName);
|
const fileRef = ref(storage, fileName);
|
||||||
|
|
||||||
@@ -54,11 +62,13 @@ const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField
|
|||||||
const snapshot = await uploadBytes(fileRef, binary);
|
const snapshot = await uploadBytes(fileRef, binary);
|
||||||
fs.rmSync(file.path);
|
fs.rmSync(file.path);
|
||||||
|
|
||||||
const paymentRef = doc(db, "payments", paymentId);
|
await db.collection("payments").updateOne(
|
||||||
|
{ id: paymentId },
|
||||||
|
{
|
||||||
|
$set: { [paymentField]: snapshot.ref.fullPath }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await updateDoc(paymentRef, {
|
|
||||||
[paymentField]: snapshot.ref.fullPath,
|
|
||||||
});
|
|
||||||
resolve(snapshot.ref.fullPath);
|
resolve(snapshot.ref.fullPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -69,7 +79,7 @@ const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField
|
|||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,80 +92,89 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {type, paymentId} = req.query as {
|
const { type, paymentId } = req.query as {
|
||||||
type: FilesStorage;
|
type: FilesStorage;
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
};
|
};
|
||||||
const paymentField = getPaymentField(type);
|
const paymentField = getPaymentField(type);
|
||||||
|
|
||||||
if (paymentField === null) {
|
if (paymentField === null) {
|
||||||
res.status(500).json({error: "Failed to identify payment field"});
|
res.status(500).json({ error: "Failed to identify payment field" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paymentRef = doc(db, "payments", paymentId);
|
const paymentRef = await db.collection("payments").findOne<Payment>({ id: paymentId })
|
||||||
const {[paymentField]: paymentFieldPath} = (await getDoc(paymentRef)).data() as Payment;
|
if (paymentRef) {
|
||||||
|
const { [paymentField]: paymentFieldPath } = paymentRef;
|
||||||
|
|
||||||
// Create a reference to the file to delete
|
// Create a reference to the file to delete
|
||||||
const documentRef = ref(storage, paymentFieldPath);
|
const documentRef = ref(storage, paymentFieldPath);
|
||||||
const url = await getDownloadURL(documentRef);
|
const url = await getDownloadURL(documentRef);
|
||||||
res.status(200).json({url, name: documentRef.name});
|
res.status(200).json({ url, name: documentRef.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {type, paymentId} = req.query as {
|
const { type, paymentId } = req.query as {
|
||||||
type: FilesStorage;
|
type: FilesStorage;
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
};
|
};
|
||||||
const paymentField = getPaymentField(type);
|
const paymentField = getPaymentField(type);
|
||||||
|
|
||||||
if (paymentField === null) {
|
if (paymentField === null) {
|
||||||
res.status(500).json({error: "Failed to identify payment field"});
|
res.status(500).json({ error: "Failed to identify payment field" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ref = await handleUpload(req, paymentId, paymentField);
|
const ref = await handleUpload(req, paymentId, paymentField);
|
||||||
|
|
||||||
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
|
const updatedDoc = await db.collection("payments").findOne<Payment>({ id: paymentId })
|
||||||
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
|
if (updatedDoc && updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
|
||||||
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
|
await db.collection("payments").updateOne(
|
||||||
|
{ id: paymentId },
|
||||||
|
{ $set: { isPaid: true } }
|
||||||
|
);
|
||||||
|
|
||||||
await setDoc(doc(db, "users", updatedDoc.corporate), {status: "active"}, {merge: true});
|
await db.collection("users").updateOne(
|
||||||
|
{ id: updatedDoc.corporate },
|
||||||
|
{ $set: { status: "active" } }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
res.status(200).json({ref});
|
res.status(200).json({ ref });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({error});
|
res.status(500).json({ error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {type, paymentId} = req.query as {
|
const { type, paymentId } = req.query as {
|
||||||
type: FilesStorage;
|
type: FilesStorage;
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
};
|
};
|
||||||
const paymentField = getPaymentField(type);
|
const paymentField = getPaymentField(type);
|
||||||
if (paymentField === null) {
|
if (paymentField === null) {
|
||||||
res.status(500).json({error: "Failed to identify payment field"});
|
res.status(500).json({ error: "Failed to identify payment field" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleDelete(paymentId, paymentField);
|
await handleDelete(paymentId, paymentField);
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({error: "Failed to delete file"});
|
res.status(500).json({ error: "Failed to delete file" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {type, paymentId} = req.query as {
|
const { type, paymentId } = req.query as {
|
||||||
type: FilesStorage;
|
type: FilesStorage;
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
};
|
};
|
||||||
const paymentField = getPaymentField(type);
|
const paymentField = getPaymentField(type);
|
||||||
if (paymentField === null) {
|
if (paymentField === null) {
|
||||||
res.status(500).json({error: "Failed to identify payment field"});
|
res.status(500).json({ error: "Failed to identify payment field" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,15 +182,15 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
await handleDelete(paymentId, paymentField);
|
await handleDelete(paymentId, paymentField);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({error: "Failed to delete file"});
|
res.status(500).json({ error: "Failed to delete file" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ref = await handleUpload(req, paymentId, paymentField);
|
const ref = await handleUpload(req, paymentId, paymentField);
|
||||||
res.status(200).json({ref});
|
res.status(200).json({ ref });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({error: "Failed to upload file"});
|
res.status(500).json({ error: "Failed to upload file" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
import {Payment} from "@/interfaces/paypal";
|
import {Payment} from "@/interfaces/paypal";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -24,20 +23,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const snapshot = await getDocs(collection(db, "payments"));
|
const payments = await db.collection("payments").find({}).toArray();
|
||||||
|
|
||||||
res.status(200).json(
|
res.status(200).json(payments);
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const body = req.body as Payment;
|
const body = req.body as Payment;
|
||||||
|
|
||||||
const shortUID = new ShortUniqueId();
|
const shortUID = new ShortUniqueId();
|
||||||
await setDoc(doc(db, "payments", shortUID.randomUUID(8)), body);
|
await db.collection("payments").insertOne({...body, id: shortUID.randomUUID(8)});
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,24 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {
|
import {sessionOptions} from "@/lib/session";
|
||||||
getFirestore,
|
import client from "@/lib/mongodb";
|
||||||
getDocs,
|
|
||||||
collection,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const payments = await getDocs(collection(db, "paypalpayments"));
|
const payments = await db.collection("paypalpayments").find({}).toArray();
|
||||||
|
|
||||||
const data = payments.docs.map((doc) => doc.data());
|
res.status(200).json(payments);
|
||||||
res.status(200).json(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") await get(req, res);
|
if (req.method === "GET") await get(req, res);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
@@ -11,7 +10,7 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {IntentionResult, PaymentIntention} from "@/interfaces/paymob";
|
import {IntentionResult, PaymentIntention} from "@/interfaces/paymob";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -26,14 +25,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const snapshot = await getDocs(collection(db, "payments"));
|
const snapshot = await db.collection("payments").find().toArray();
|
||||||
|
res.status(200).json(snapshot);
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc, getDoc, query, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group, User} from "@/interfaces/user";
|
import {Group, User} from "@/interfaces/user";
|
||||||
@@ -11,8 +9,9 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob";
|
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST") await post(req, res);
|
if (req.method === "POST") await post(req, res);
|
||||||
@@ -32,11 +31,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
duration_unit: DurationUnit;
|
duration_unit: DurationUnit;
|
||||||
};
|
};
|
||||||
|
|
||||||
const userSnapshot = await getDoc(doc(db, "users", userID as string));
|
const user = await db.collection("users").findOne<User>({ id: userID as string });
|
||||||
|
|
||||||
if (!userSnapshot.exists() || !duration || !duration_unit) return res.status(404).json({ok: false});
|
if (!user || !duration || !duration_unit) return res.status(404).json({ok: false});
|
||||||
|
|
||||||
const user = {...userSnapshot.data(), id: userSnapshot.id} as User;
|
|
||||||
|
|
||||||
const subscriptionExpirationDate = user.subscriptionExpirationDate;
|
const subscriptionExpirationDate = user.subscriptionExpirationDate;
|
||||||
if (!subscriptionExpirationDate) return res.status(200).json({ok: false});
|
if (!subscriptionExpirationDate) return res.status(200).json({ok: false});
|
||||||
@@ -45,8 +42,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).endOf("day").subtract(2, "hours").toISOString();
|
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).endOf("day").subtract(2, "hours").toISOString();
|
||||||
|
|
||||||
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"}, {merge: true});
|
await db.collection("users").updateOne(
|
||||||
await setDoc(doc(db, "paypalpayments", v4()), {
|
{ id: userID as string },
|
||||||
|
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.collection("paypalpayments").insertOne({
|
||||||
|
id: v4(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
currency: transactionResult.transaction.currency,
|
currency: transactionResult.transaction.currency,
|
||||||
orderId: transactionResult.transaction.id,
|
orderId: transactionResult.transaction.id,
|
||||||
@@ -59,21 +61,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user.type === "corporate") {
|
if (user.type === "corporate") {
|
||||||
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", user.id)));
|
const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray();
|
||||||
const groups = groupsSnapshot.docs.map((g) => ({...g.data(), id: g.id})) as Group[];
|
|
||||||
|
|
||||||
const participants = (await Promise.all(
|
const participants = (await Promise.all(
|
||||||
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
|
groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})),
|
||||||
)) as User[];
|
)) as User[];
|
||||||
const sameExpiryDateParticipants = participants.filter(
|
const sameExpiryDateParticipants = participants.filter(
|
||||||
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
|
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const participant of sameExpiryDateParticipants) {
|
for (const participant of sameExpiryDateParticipants) {
|
||||||
await setDoc(
|
await db.collection("users").updateOne(
|
||||||
doc(db, "users", participant.id),
|
{ id: participant.id },
|
||||||
{subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"},
|
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
|
||||||
{merge: true},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {
|
|
||||||
getFirestore,
|
|
||||||
collection,
|
|
||||||
getDocs,
|
|
||||||
setDoc,
|
|
||||||
doc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -19,7 +12,7 @@ import { getAccessToken } from "@/utils/paypal";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { Group } from "@/interfaces/user";
|
import { Group } from "@/interfaces/user";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -60,33 +53,33 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const dateToBeAddedTo = !subscriptionExpirationDate
|
const dateToBeAddedTo = !subscriptionExpirationDate
|
||||||
? today
|
? today
|
||||||
: moment(subscriptionExpirationDate).isAfter(today)
|
: moment(subscriptionExpirationDate).isAfter(today)
|
||||||
? moment(subscriptionExpirationDate)
|
? moment(subscriptionExpirationDate)
|
||||||
: today;
|
: today;
|
||||||
|
|
||||||
const updatedExpirationDate = dateToBeAddedTo.add(
|
const updatedExpirationDate = dateToBeAddedTo.add(
|
||||||
duration,
|
duration,
|
||||||
duration_unit
|
duration_unit
|
||||||
);
|
);
|
||||||
await setDoc(
|
|
||||||
doc(db, "users", req.session.user!.id),
|
await db.collection("users").updateOne(
|
||||||
|
{ id: req.session.user!.id },
|
||||||
{
|
{
|
||||||
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
$set: {
|
||||||
status: "active",
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
},
|
status: "active",
|
||||||
{ merge: true }
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, "paypalpayments", v4()), {
|
await db.collection("paypalpayments").insertOne({
|
||||||
|
id: v4(),
|
||||||
orderId: id,
|
orderId: id,
|
||||||
userId: req.session.user!.id,
|
userId: req.session.user!.id,
|
||||||
status: request.data.status,
|
status: request.data.status,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
value:
|
value: request.data.purchase_units[0].payments.captures[0].amount.value,
|
||||||
request.data.purchase_units[0].payments.captures[0].amount.value,
|
currency: request.data.purchase_units[0].payments.captures[0].amount.currency_code,
|
||||||
currency:
|
|
||||||
request.data.purchase_units[0].payments.captures[0].amount
|
|
||||||
.currency_code,
|
|
||||||
subscriptionDuration: duration,
|
subscriptionDuration: duration,
|
||||||
subscriptionDurationUnit: duration_unit,
|
subscriptionDurationUnit: duration_unit,
|
||||||
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
@@ -96,12 +89,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user!.type === "corporate") {
|
if (user!.type === "corporate") {
|
||||||
const snapshot = await getDocs(collection(db, "groups"));
|
const groups = (
|
||||||
const groups: Group[] = (
|
await db.collection("groups").find<Group>({}).toArray()
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as Group[]
|
|
||||||
).filter((x) => x.admin === user!.id);
|
).filter((x) => x.admin === user!.id);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -109,14 +98,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
.flatMap((x) => x.participants)
|
.flatMap((x) => x.participants)
|
||||||
.map(
|
.map(
|
||||||
async (x) =>
|
async (x) =>
|
||||||
await setDoc(
|
await db.collection("users").updateOne(
|
||||||
doc(db, "users", x),
|
{ id: x },
|
||||||
{
|
{
|
||||||
subscriptionExpirationDate:
|
$set: {
|
||||||
updatedExpirationDate.toISOString(),
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
status: "active",
|
status: "active",
|
||||||
},
|
}
|
||||||
{ merge: true }
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,110 +1,103 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import { getFirestore, collection, getDocs } from "firebase/firestore";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { v4 } from "uuid";
|
import {v4} from "uuid";
|
||||||
import { OrderResponseBody } from "@paypal/paypal-js";
|
import {OrderResponseBody} from "@paypal/paypal-js";
|
||||||
import { getAccessToken } from "@/utils/paypal";
|
import {getAccessToken} from "@/utils/paypal";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "POST")
|
if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"});
|
||||||
return res.status(404).json({ ok: false, reason: "Method not supported!" });
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
if (!req.session.user) return res.status(401).json({ ok: false });
|
|
||||||
|
|
||||||
const accessToken = await getAccessToken();
|
const accessToken = await getAccessToken();
|
||||||
if (!accessToken)
|
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
||||||
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
|
|
||||||
|
|
||||||
const { currencyCode, price, trackingId } = req.body as {
|
const {currencyCode, price, trackingId} = req.body as {
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
price: number;
|
price: number;
|
||||||
trackingId: string;
|
trackingId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!trackingId)
|
if (!trackingId) return res.status(401).json({ok: false, reason: "Missing tracking id!"});
|
||||||
return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
|
|
||||||
|
|
||||||
const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`;
|
const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`;
|
||||||
const amount = {
|
const amount = {
|
||||||
currency_code: currencyCode,
|
currency_code: currencyCode,
|
||||||
value: price.toString(),
|
value: price.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
purchase_units: [
|
purchase_units: [
|
||||||
{
|
{
|
||||||
invoice_id: `INV-${v4()}`,
|
invoice_id: `INV-${v4()}`,
|
||||||
amount: {
|
amount: {
|
||||||
...amount,
|
...amount,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
item_total: amount,
|
item_total: amount,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Encoach Subscription",
|
name: "Encoach Subscription",
|
||||||
quantity: "1",
|
quantity: "1",
|
||||||
category: "DIGITAL_GOODS",
|
category: "DIGITAL_GOODS",
|
||||||
unit_amount: amount,
|
unit_amount: amount,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
payment_source: {
|
payment_source: {
|
||||||
paypal: {
|
paypal: {
|
||||||
email_address: req.session.user.email || "",
|
email_address: req.session.user.email || "",
|
||||||
address: {
|
address: {
|
||||||
address_line_1: "",
|
address_line_1: "",
|
||||||
address_line_2: "",
|
address_line_2: "",
|
||||||
admin_area_1: "",
|
admin_area_1: "",
|
||||||
admin_area_2: "",
|
admin_area_2: "",
|
||||||
// added default values as requsted by the client, using the default values recommended
|
// added default values as requsted by the client, using the default values recommended
|
||||||
// the paypal engineer, otherwise we would have to create something that would detect the location
|
// the paypal engineer, otherwise we would have to create something that would detect the location
|
||||||
// of the user and generate a valid postal code for that location...
|
// of the user and generate a valid postal code for that location...
|
||||||
country_code: "US",
|
country_code: "US",
|
||||||
postal_code: "94107",
|
postal_code: "94107",
|
||||||
},
|
},
|
||||||
experience_context: {
|
experience_context: {
|
||||||
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
|
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
landing_page: "LOGIN",
|
landing_page: "LOGIN",
|
||||||
shipping_preference: "NO_SHIPPING",
|
shipping_preference: "NO_SHIPPING",
|
||||||
user_action: "PAY_NOW",
|
user_action: "PAY_NOW",
|
||||||
brand_name: "Encoach",
|
brand_name: "Encoach",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
intent: "CAPTURE",
|
intent: "CAPTURE",
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
"PayPal-Client-Metadata-Id": trackingId,
|
"PayPal-Client-Metadata-Id": trackingId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
url,
|
url,
|
||||||
data,
|
data,
|
||||||
headers,
|
headers,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<OrderResponseBody>(url, data, headers)
|
.post<OrderResponseBody>(url, data, headers)
|
||||||
.then((request) => {
|
.then((request) => {
|
||||||
res.status(request.status).json(request.data);
|
res.status(request.status).json(request.data);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err.response.status, err.response.data);
|
console.error(err.response.status, err.response.data);
|
||||||
res.status(err.response.status).json(err.response.data);
|
res.status(err.response.status).json(err.response.data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,55 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import { getFirestore, collection, getDocs } from "firebase/firestore";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { v4 } from "uuid";
|
import {v4} from "uuid";
|
||||||
import { OrderResponseBody } from "@paypal/paypal-js";
|
import {OrderResponseBody} from "@paypal/paypal-js";
|
||||||
import { getAccessToken } from "@/utils/paypal";
|
import {getAccessToken} from "@/utils/paypal";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "PUT")
|
if (req.method !== "PUT") return res.status(404).json({ok: false, reason: "Method not supported!"});
|
||||||
return res.status(404).json({ ok: false, reason: "Method not supported!" });
|
|
||||||
|
|
||||||
if (!req.session.user) return res.status(401).json({ ok: false });
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
|
|
||||||
const accessToken = await getAccessToken();
|
const accessToken = await getAccessToken();
|
||||||
if (!accessToken)
|
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
||||||
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
|
|
||||||
|
|
||||||
const trackingId = `${req.session.user.id}-${Date.now()}`;
|
const trackingId = `${req.session.user.id}-${Date.now()}`;
|
||||||
|
|
||||||
const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`;
|
const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`;
|
||||||
const data = {
|
const data = {
|
||||||
additional_data: [
|
additional_data: [
|
||||||
{
|
{
|
||||||
key: "user_id",
|
key: "user_id",
|
||||||
value: req.session.user.id,
|
value: req.session.user.id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify({
|
console.log(
|
||||||
url,
|
JSON.stringify({
|
||||||
data,
|
url,
|
||||||
headers,
|
data,
|
||||||
}));
|
headers,
|
||||||
try {
|
}),
|
||||||
const request = await axios.put(url, data, headers);
|
);
|
||||||
|
try {
|
||||||
|
const request = await axios.put(url, data, headers);
|
||||||
|
|
||||||
return res.status(request.status).json({
|
return res.status(request.status).json({
|
||||||
ok: true,
|
ok: true,
|
||||||
trackingId,
|
trackingId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(url, err);
|
console.error(url, err);
|
||||||
return res
|
return res.status(500).json({ok: false, reason: "Failed to create tracking ID"});
|
||||||
.status(500)
|
}
|
||||||
.json({ ok: false, reason: "Failed to create tracking ID" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, doc, setDoc, getDoc} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { getPermissionDoc } from "@/utils/permissions.be";
|
||||||
import {getPermissionDoc} from "@/utils/permissions.be";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -17,30 +16,35 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const permissionDoc = await getPermissionDoc(id);
|
const permissionDoc = await getPermissionDoc(id);
|
||||||
return res.status(200).json({allowed: permissionDoc.users.includes(req.session.user.id)});
|
return res.status(200).json({ allowed: permissionDoc.users.includes(req.session.user.id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
const {users} = req.body;
|
const { users } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, "permissions", id), {users}, {merge: true});
|
await db.collection("permissions").updateOne(
|
||||||
return res.status(200).json({ok: true});
|
{ id: id },
|
||||||
|
{ $set: {...users, id: id} },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return res.status(500).json({ok: false});
|
return res.status(500).json({ ok: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,28 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app } from "@/firebase";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {
|
import {sessionOptions} from "@/lib/session";
|
||||||
getFirestore,
|
import {Permission} from "@/interfaces/permissions";
|
||||||
collection,
|
import {bootstrap} from "@/utils/permissions.be";
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
where,
|
|
||||||
doc,
|
|
||||||
setDoc,
|
|
||||||
addDoc,
|
|
||||||
getDoc,
|
|
||||||
deleteDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import { Permission } from "@/interfaces/permissions";
|
|
||||||
import { bootstrap } from "@/utils/permissions.be";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return get(req, res);
|
if (req.method === "GET") return get(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Boostrap");
|
console.log("Boostrap");
|
||||||
try {
|
try {
|
||||||
await bootstrap();
|
await bootstrap();
|
||||||
return res.status(200).json({ ok: true });
|
return res.status(200).json({ok: true});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update permissions", err);
|
console.error("Failed to update permissions", err);
|
||||||
return res.status(500).json({ ok: false });
|
return res.status(500).json({ok: false});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app } from "@/firebase";
|
|
||||||
import {
|
|
||||||
getFirestore,
|
|
||||||
collection,
|
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
where,
|
|
||||||
doc,
|
|
||||||
setDoc,
|
|
||||||
addDoc,
|
|
||||||
getDoc,
|
|
||||||
deleteDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { Permission } from "@/interfaces/permissions";
|
import { Permission } from "@/interfaces/permissions";
|
||||||
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
|
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
|||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
|
import {Code, CorporateInformation, DemographicInformation, Group, Type} from "@/interfaces/user";
|
||||||
import {CorporateInformation, DemographicInformation, Group, Type} from "@/interfaces/user";
|
|
||||||
import {addUserToGroupOnCreation} from "@/utils/registration";
|
import {addUserToGroupOnCreation} from "@/utils/registration";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(register, sessionOptions);
|
export default withIronSessionApiRoute(register, sessionOptions);
|
||||||
|
|
||||||
@@ -45,24 +45,13 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
code?: string;
|
code?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
const codeDoc = await db.collection("codes").findOne<Code>({code});
|
||||||
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
|
|
||||||
|
|
||||||
if (code && code.length > 0 && codeDocs.length === 0) {
|
if (code && code.length > 0 && !!codeDoc) {
|
||||||
res.status(400).json({error: "Invalid Code!"});
|
res.status(400).json({error: "Invalid Code!"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeData =
|
|
||||||
codeDocs.length > 0
|
|
||||||
? (codeDocs[0].data() as {
|
|
||||||
code: string;
|
|
||||||
type: Type;
|
|
||||||
creator?: string;
|
|
||||||
expiryDate: Date | null;
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
const userId = userCredentials.user.uid;
|
const userId = userCredentials.user.uid;
|
||||||
@@ -70,31 +59,32 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
|
id: userId,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
levels: DEFAULT_LEVELS,
|
levels: DEFAULT_LEVELS,
|
||||||
bio: "",
|
bio: "",
|
||||||
isFirstLogin: codeData ? codeData.type === "student" : true,
|
isFirstLogin: codeDoc ? codeDoc.type === "student" : true,
|
||||||
profilePicture: "/defaultAvatar.png",
|
profilePicture: "/defaultAvatar.png",
|
||||||
focus: "academic",
|
focus: "academic",
|
||||||
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
|
type: email.endsWith("@ecrop.dev") ? "developer" : codeDoc ? codeDoc.type : "student",
|
||||||
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
|
subscriptionExpirationDate: codeDoc ? codeDoc.expiryDate : moment().subtract(1, "days").toISOString(),
|
||||||
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
||||||
registrationDate: new Date().toISOString(),
|
registrationDate: new Date().toISOString(),
|
||||||
status: code ? "active" : "paymentDue",
|
status: code ? "active" : "paymentDue",
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await db.collection("users").insertOne(user);
|
||||||
|
|
||||||
if (codeDocs.length > 0 && codeData) {
|
if (!!codeDoc) {
|
||||||
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true});
|
await db.collection("codes").updateOne({code: codeDoc.code}, {$set: {userId}});
|
||||||
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator);
|
if (codeDoc.creator) await addUserToGroupOnCreation(userId, codeDoc.type, codeDoc.creator);
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = user;
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user: {...user, id: userId}});
|
res.status(200).json({user});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -116,6 +106,7 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
|
id: userId,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
levels: DEFAULT_LEVELS,
|
levels: DEFAULT_LEVELS,
|
||||||
@@ -152,15 +143,13 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await db.collection("users").insertOne(user);
|
||||||
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
await db.collection("groups").insertMany([defaultCorporateGroup, defaultStudentsGroup, defaultTeachersGroup]);
|
||||||
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
|
||||||
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
|
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = user;
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user: {...user, id: userId}});
|
res.status(200).json({user});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {NextApiRequest, NextApiResponse} from "next";
|
import {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {getAuth} from "firebase-admin/auth";
|
import {getAuth} from "firebase-admin/auth";
|
||||||
import {adminApp, app} from "@/firebase";
|
import {adminApp} from "@/firebase";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {doc, getFirestore, setDoc} from "firebase/firestore";
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
const auth = getAuth(adminApp);
|
const auth = getAuth(adminApp);
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(verify, sessionOptions);
|
export default withIronSessionApiRoute(verify, sessionOptions);
|
||||||
|
|
||||||
@@ -19,8 +20,10 @@ async function verify(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRef = doc(db, "users", user.uid);
|
await db.collection("users").updateOne(
|
||||||
await setDoc(userRef, {isVerified: true}, {merge: true});
|
{ id: user.uid},
|
||||||
|
{ $set: {isVerified: true} }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Session} from "@/hooks/useSessions";
|
import {Session} from "@/hooks/useSessions";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -23,14 +22,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docRef = doc(db, "sessions", id);
|
const docSnap = await db.collection("sessions").findOne({ id: id });
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
if (docSnap) {
|
||||||
res.status(200).json({
|
res.status(200).json(docSnap);
|
||||||
id: docSnap.id,
|
|
||||||
...docSnap.data(),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
@@ -44,11 +39,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docRef = doc(db, "sessions", id);
|
const docSnap = await db.collection("sessions").findOne({ id: id });
|
||||||
const docSnap = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (!docSnap.exists()) return res.status(404).json({ok: false});
|
if (!docSnap) return res.status(404).json({ok: false});
|
||||||
|
await db.collection("sessions").deleteOne({ id: id });
|
||||||
|
|
||||||
await deleteDoc(docRef);
|
|
||||||
return res.status(200).json({ok: true});
|
return res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Session} from "@/hooks/useSessions";
|
import {Session} from "@/hooks/useSessions";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -24,12 +23,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const {user} = req.query as {user?: string};
|
const {user} = req.query as {user?: string};
|
||||||
|
|
||||||
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions");
|
const q = user ? {user: user} : {};
|
||||||
const snapshot = await getDocs(q);
|
const sessions = await db.collection("sessions").find<Session>(q).limit(10).toArray();
|
||||||
const sessions = snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as Session[];
|
|
||||||
|
|
||||||
res.status(200).json(
|
res.status(200).json(
|
||||||
sessions.filter((x) => {
|
sessions.filter((x) => {
|
||||||
@@ -45,9 +40,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(401).json({ok: false});
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = req.body;
|
const session = req.body;
|
||||||
await setDoc(doc(db, "sessions", session.id), session, {merge: true});
|
|
||||||
|
await db.collection("sessions").updateOne({id: session.id}, {$set: session}, {upsert: true});
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { app, storage } from "@/firebase";
|
import { storage } from "@/firebase";
|
||||||
import {
|
import client from "@/lib/mongodb";
|
||||||
getFirestore,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
updateDoc,
|
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import ReactPDF from "@react-pdf/renderer";
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
@@ -35,7 +26,8 @@ import {
|
|||||||
} from "@/utils/pdf";
|
} from "@/utils/pdf";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { getCorporateNameForStudent } from "@/utils/groups.be";
|
import { getCorporateNameForStudent } from "@/utils/groups.be";
|
||||||
const db = getFirestore(app);
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -281,19 +273,22 @@ async function getPdfUrl(pdfStream: any, docsSnap: any) {
|
|||||||
|
|
||||||
// upload the pdf to storage
|
// upload the pdf to storage
|
||||||
const pdfBuffer = await streamToBuffer(pdfStream);
|
const pdfBuffer = await streamToBuffer(pdfStream);
|
||||||
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
await uploadBytes(fileRef, pdfBuffer, {
|
||||||
contentType: "application/pdf",
|
contentType: "application/pdf",
|
||||||
});
|
});
|
||||||
|
|
||||||
// update the stats entries with the pdf url to prevent duplication
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
docsSnap.docs.forEach(async (doc: any) => {
|
await db.collection("stats").updateOne(
|
||||||
await updateDoc(doc.ref, {
|
{ id: docsSnap.id },
|
||||||
pdf: {
|
{
|
||||||
path: refName,
|
$set: {
|
||||||
version: process.env.PDF_VERSION,
|
pdf: {
|
||||||
},
|
path: refName,
|
||||||
});
|
version: process.env.PDF_VERSION,
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
return getDownloadURL(fileRef);
|
return getDownloadURL(fileRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,16 +297,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
// fetch stats entries for this particular user with the requested exam session
|
// fetch stats entries for this particular user with the requested exam session
|
||||||
const docsSnap = await getDocs(
|
const stats = await db.collection("stats").find<Stat>({ session: id }).toArray();
|
||||||
query(collection(db, "stats"), where("session", "==", id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (docsSnap.empty) {
|
if (stats.length == 0) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = docsSnap.docs.map((d) => d.data()) as Stat[];
|
|
||||||
// verify if the stats already have a pdf generated
|
// verify if the stats already have a pdf generated
|
||||||
const hasPDF = stats.find(
|
const hasPDF = stats.find(
|
||||||
(s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION
|
(s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION
|
||||||
@@ -336,26 +328,25 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// generate the pdf report
|
// generate the pdf report
|
||||||
const docUser = await getDoc(doc(db, "users", userId));
|
const docUser = await db.collection("users").findOne<User>({ id: userId});
|
||||||
|
|
||||||
if (docUser.exists()) {
|
if (docUser) {
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
|
||||||
const [stat] = stats;
|
const [stat] = stats;
|
||||||
|
|
||||||
if (stat.module === "level") {
|
if (stat.module === "level") {
|
||||||
const user = docUser.data() as StudentUser;
|
const user = docUser as StudentUser;
|
||||||
|
|
||||||
const uniqueExercises = stats.map((s) => ({
|
const uniqueExercises = stats.map((s) => ({
|
||||||
name: "Gramar & Vocabulary",
|
name: "Gramar & Vocabulary",
|
||||||
result: `${s.score.correct}/${s.score.total}`,
|
result: `${s.score.correct}/${s.score.total}`,
|
||||||
}));
|
}));
|
||||||
const dates = stats.map((s) => moment(s.date));
|
const dates = stats.map((s) => moment(s.date));
|
||||||
const timeSpent = `${
|
const timeSpent = `${stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
|
||||||
stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
|
} minutes`;
|
||||||
} minutes`;
|
|
||||||
const score = stats.reduce((accm, s) => accm + s.score.correct, 0);
|
const score = stats.reduce((accm, s) => accm + s.score.correct, 0);
|
||||||
const corporateName = await getCorporateNameForStudent(userId);
|
const corporateName = await getCorporateNameForStudent(userId);
|
||||||
const pdfStream = await ReactPDF.renderToStream(
|
const pdfStream = await ReactPDF.renderToStream(
|
||||||
<LevelTestReport
|
<LevelTestReport
|
||||||
date={moment.max(dates).format("DD/MM/YYYY")}
|
date={moment.max(dates).format("DD/MM/YYYY")}
|
||||||
@@ -373,11 +364,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = await getPdfUrl(pdfStream, docsSnap);
|
const url = await getPdfUrl(pdfStream, docUser);
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = docUser.data() as User;
|
const user = docUser as User;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfStream = await getDefaultPDFStream(
|
const pdfStream = await getDefaultPDFStream(
|
||||||
@@ -386,7 +377,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
`${req.headers.origin || ""}${req.url}`
|
`${req.headers.origin || ""}${req.url}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = await getPdfUrl(pdfStream, docsSnap);
|
const url = await getPdfUrl(pdfStream, docUser);
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -411,21 +402,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
const docsSnap = await getDocs(
|
const stats = await db.collection("stats").find<Stat>({ session: id }).toArray();
|
||||||
query(collection(db, "stats"), where("session", "==", id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (docsSnap.empty) {
|
if (stats.length == 0) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = docsSnap.docs.map((d) => d.data());
|
|
||||||
|
|
||||||
const hasPDF = stats.find((s) => s.pdf?.path);
|
const hasPDF = stats.find((s) => s.pdf?.path);
|
||||||
|
|
||||||
if (hasPDF) {
|
if (hasPDF) {
|
||||||
const fileRef = ref(storage, hasPDF.pdf.path);
|
const fileRef = ref(storage, hasPDF.pdf!.path);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
return res.redirect(url);
|
return res.redirect(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
|
||||||
import {sessionOptions} from "@/lib/session";
|
|
||||||
import {uuidv4} from "@firebase/util";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return GET(req, res);
|
if (req.method === "GET") return GET(req, res);
|
||||||
@@ -17,8 +13,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "stats", id as string));
|
const snapshot = await db.collection("stats").findOne({ id: id as string});
|
||||||
if (!snapshot.exists()) return res.status(404).json({id: snapshot.id});
|
if (!snapshot) return res.status(404).json({id: id as string});
|
||||||
|
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc, deleteDoc} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { Stat } from "@/interfaces/user";
|
||||||
import {Stat} from "@/interfaces/user";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { groupBy } from "lodash";
|
||||||
import {groupBy} from "lodash";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -19,33 +18,28 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const snapshot = await db.collection("stats").find<Stat>({}).toArray();
|
||||||
|
|
||||||
const q = query(collection(db, "stats"));
|
res.status(200).json(snapshot);
|
||||||
|
|
||||||
const snapshot = await getDocs(q);
|
|
||||||
|
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = req.body as Stat[];
|
const stats = req.body as Stat[];
|
||||||
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
|
stats.forEach(async (stat) => await db.collection("stats").updateOne(
|
||||||
await stats.forEach(async (stat) => {
|
{ id: stat.id },
|
||||||
const sessionDoc = await getDoc(doc(db, "sessions", stat.session));
|
{ $set: stat },
|
||||||
if (sessionDoc.exists()) await deleteDoc(sessionDoc.ref);
|
{ upsert: true }
|
||||||
|
));
|
||||||
|
stats.forEach(async (stat) => {
|
||||||
|
await db.collection("sessions").deleteOne({ id: stat.session })
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedStatsByAssignment = groupBy(
|
const groupedStatsByAssignment = groupBy(
|
||||||
@@ -55,22 +49,23 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (Object.keys(groupedStatsByAssignment).length > 0) {
|
if (Object.keys(groupedStatsByAssignment).length > 0) {
|
||||||
const assignments = Object.keys(groupedStatsByAssignment);
|
const assignments = Object.keys(groupedStatsByAssignment);
|
||||||
|
|
||||||
await assignments.forEach(async (assignmentId) => {
|
assignments.forEach(async (assignmentId) => {
|
||||||
const assignmentStats = groupedStatsByAssignment[assignmentId] as Stat[];
|
const assignmentStats = groupedStatsByAssignment[assignmentId] as Stat[];
|
||||||
|
|
||||||
const assignmentSnapshot = await getDoc(doc(db, "assignments", assignmentId));
|
const assignmentSnapshot = await db.collection("assignments").findOne<Assignment>({ id: assignmentId });
|
||||||
await setDoc(
|
await db.collection("assignments").updateOne(
|
||||||
doc(db, "assignments", assignmentId),
|
{ id: assignmentId },
|
||||||
{
|
{
|
||||||
results: [
|
$set: {
|
||||||
...(assignmentSnapshot.data() as Assignment).results,
|
results: [
|
||||||
{user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats},
|
...assignmentSnapshot ? assignmentSnapshot.results : [],
|
||||||
],
|
{ user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats },
|
||||||
},
|
],
|
||||||
{merge: true},
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,29 +6,25 @@ import {sessionOptions} from "@/lib/session";
|
|||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import {groupByModule, groupBySession} from "@/utils/stats";
|
import {groupByModule, groupBySession} from "@/utils/stats";
|
||||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import {getAuth} from "firebase/auth";
|
import client from "@/lib/mongodb";
|
||||||
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {groupBy} from "lodash";
|
import {groupBy} from "lodash";
|
||||||
import {NextApiRequest, NextApiResponse} from "next";
|
import {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(update, sessionOptions);
|
export default withIronSessionApiRoute(update, sessionOptions);
|
||||||
|
|
||||||
async function update(req: NextApiRequest, res: NextApiResponse) {
|
async function update(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await db.collection("users").findOne({ id: req.session.user.id });
|
||||||
if (!docUser.exists()) {
|
|
||||||
|
if (!docUser) {
|
||||||
res.status(401).json(undefined);
|
res.status(401).json(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = query(collection(db, "stats"), where("user", "==", req.session.user.id));
|
const stats = await db.collection("stats").find<Stat>({ user: req.session.user.id }).toArray();
|
||||||
const stats = (await getDocs(q)).docs.map((doc) => ({
|
|
||||||
...(doc.data() as Stat),
|
|
||||||
id: doc.id,
|
|
||||||
})) as Stat[];
|
|
||||||
|
|
||||||
const groupedStats = groupBySession(stats);
|
const groupedStats = groupBySession(stats);
|
||||||
const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => {
|
const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => {
|
||||||
@@ -102,8 +98,10 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus),
|
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userDoc = doc(db, "users", req.session.user.id);
|
await db.collection("users").updateOne(
|
||||||
await updateDoc(userDoc, {levels});
|
{ id: req.session.user.id},
|
||||||
|
{ $set: {levels} }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -16,14 +15,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {user} = req.query;
|
const {user} = req.query;
|
||||||
const q = query(collection(db, "stats"), where("user", "==", user));
|
const snapshot = await db.collection("stats").find({ user: user }).toArray();
|
||||||
|
|
||||||
const snapshot = await getDocs(q);
|
res.status(200).json(snapshot);
|
||||||
|
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Session} from "@/hooks/useSessions";
|
import {Session} from "@/hooks/useSessions";
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import formidable from "formidable-serverless";
|
|||||||
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {app, storage} from "@/firebase";
|
import {app, storage} from "@/firebase";
|
||||||
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
|
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, setDoc, doc, getDocs, query, collection, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
@@ -12,7 +11,8 @@ import * as Stripe from "stripe";
|
|||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, days, key, checkout} = req.body as {email: string; days: string; key: string; checkout: string};
|
const {email, days, key, checkout} = req.body as {email: string; days: string; key: string; checkout: string};
|
||||||
@@ -24,41 +24,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const code = uid.randomUUID(6);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
const codeCheckerRef = await getDocs(query(collection(db, "codes"), where("checkout", "==", checkout)));
|
const codeCheckerRef = await db.collection("codes").find({ checkout: checkout }).toArray();
|
||||||
if (codeCheckerRef.docs.length !== 0) {
|
if (codeCheckerRef.length !== 0) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailCheckerRef = await getDocs(query(collection(db, "users"), where("email", "==", email)));
|
const emailCheckerRef = await db.collection("users").find({ email: email }).toArray();
|
||||||
if (emailCheckerRef.docs.length !== 0) {
|
if (emailCheckerRef.length !== 0) {
|
||||||
const user = emailCheckerRef.docs[0];
|
const user = emailCheckerRef[0];
|
||||||
if (!user.data().subscriptionExpirationDate) {
|
if (!user.subscriptionExpirationDate) {
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeUserRef = await getDocs(query(collection(db, "codes"), where("userId", "==", user.id)));
|
const codeUserRef = await db.collection("codes").find({ userId: user.id }).toArray();
|
||||||
const userCode = codeUserRef.docs[0];
|
const userCode = codeUserRef[0];
|
||||||
|
|
||||||
if (userCode.data().checkout && userCode.data().checkout === checkout) {
|
if (userCode.checkout && userCode.checkout === checkout) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await db.collection("users").updateOne(
|
||||||
await setDoc(
|
{ id: user.id },
|
||||||
user.ref,
|
{ $set: {subscriptionExpirationDate: moment(user.subscriptionExpirationDate).add(days, "days").toISOString()} }
|
||||||
{subscriptionExpirationDate: moment(user.data().subscriptionExpirationDate).add(days, "days").toISOString()},
|
|
||||||
{merge: true},
|
|
||||||
);
|
);
|
||||||
await setDoc(userCode.ref, {checkout}, {merge: true});
|
await db.collection("codes").updateOne(
|
||||||
|
{ id: userCode.id },
|
||||||
|
{ $set: {checkout} }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeRef = doc(db, "codes", code);
|
await db.collection("codes").updateOne(
|
||||||
await setDoc(codeRef, {type: "student", code, expiryDate: moment(new Date()).add(days, "days").toISOString(), checkout});
|
{ id: code },
|
||||||
|
{ $set: {type: "student", code, expiryDate: moment(new Date()).add(days, "days").toISOString(), checkout} }
|
||||||
|
);
|
||||||
|
|
||||||
const transport = prepareMailer();
|
const transport = prepareMailer();
|
||||||
const mailOptions = prepareMailOptions(
|
const mailOptions = prepareMailOptions(
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import {getFirestore, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket";
|
||||||
import {Ticket, TicketTypeLabel, TicketStatusLabel} from "@/interfaces/ticket";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {sendEmail} from "@/email";
|
import { sendEmail } from "@/email";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -22,16 +21,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
const snapshot = await db.collection("tickets").findOne({ id: id });
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (snapshot) {
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
res.status(200).json({ ...snapshot });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
@@ -39,43 +38,42 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
|
||||||
const data = snapshot.data() as Ticket;
|
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await deleteDoc(snapshot.ref);
|
await db.collection("tickets").deleteOne({ id: id });
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
const body = req.body as Ticket;
|
const body = req.body as Ticket;
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
const data = await db.collection("tickets").updateOne(
|
||||||
|
{ id: id },
|
||||||
|
{ $set: body },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
const data = snapshot.data() as Ticket;
|
|
||||||
await setDoc(snapshot.ref, body, {merge: true});
|
|
||||||
try {
|
try {
|
||||||
// send email if the status actually changed to completed
|
// send email if the status actually changed to completed
|
||||||
if (data.status !== req.body.status && req.body.status === "completed") {
|
if (body.status !== req.body.status && req.body.status === "completed") {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
"ticketStatusCompleted",
|
"ticketStatusCompleted",
|
||||||
{
|
{
|
||||||
@@ -88,17 +86,17 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
description: body.description,
|
description: body.description,
|
||||||
environment: process.env.ENVIRONMENT,
|
environment: process.env.ENVIRONMENT,
|
||||||
},
|
},
|
||||||
[data.reporter.email],
|
[body.reporter.email],
|
||||||
`Ticket ${id}: ${data.subject}`,
|
`Ticket ${id}: ${body.subject}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
// doesnt matter if the email fails
|
// doesnt matter if the email fails
|
||||||
}
|
}
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import {sendEmail} from "@/email";
|
import { sendEmail } from "@/email";
|
||||||
import {app} from "@/firebase";
|
import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket";
|
||||||
import {Ticket, TicketTypeLabel, TicketWithCorporate} from "@/interfaces/ticket";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import client from "@/lib/mongodb";
|
||||||
import {collection, doc, getDocs, getFirestore, setDoc, where, query} from "firebase/firestore";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {Group, CorporateUser} from "@/interfaces/user";
|
import { Group, CorporateUser } from "@/interfaces/user";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,31 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const snapshot = await getDocs(collection(db, "tickets"));
|
const docs = await db.collection("tickets").find<Ticket>({}).toArray();
|
||||||
|
|
||||||
const docs = snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as Ticket[];
|
|
||||||
|
|
||||||
// fetch all groups for these users
|
// fetch all groups for these users
|
||||||
|
|
||||||
const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))];
|
const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))];
|
||||||
|
|
||||||
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains-any", reporters)));
|
const groups = await db.collection("groups").find<Group>({
|
||||||
const groups = groupsSnapshot.docs.map((doc) => doc.data()) as Group[];
|
participants: { $in: reporters }
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
// based on the admin of each group, verify if it exists and it's of type corporate
|
// based on the admin of each group, verify if it exists and it's of type corporate
|
||||||
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
|
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
|
||||||
const adminsSnapshot =
|
const admins = groupsAdmins.length > 0
|
||||||
groupsAdmins.length > 0
|
? await db.collection("users").find<CorporateUser>({ id: { $in: groupsAdmins }, type: "corporate" }).toArray()
|
||||||
? await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")))
|
: [];
|
||||||
: {docs: []};
|
|
||||||
const admins = adminsSnapshot.docs.map((doc) => doc.data());
|
|
||||||
|
|
||||||
const docsWithAdmins = docs.map((d) => {
|
const docsWithAdmins = docs.map((d) => {
|
||||||
const group = groups.find((g) => g.participants.includes(d.reporter.id));
|
const group = groups.find((g) => g.participants.includes(d.reporter.id));
|
||||||
const admin = admins.find((a) => a.id === group?.admin) as CorporateUser;
|
const admin = admins.find((a) => a.id === group?.admin);
|
||||||
|
|
||||||
if (admin) {
|
if (admin) {
|
||||||
return {
|
return {
|
||||||
@@ -81,8 +73,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const shortUID = new ShortUniqueId();
|
const shortUID = new ShortUniqueId();
|
||||||
const id = body.id || shortUID.randomUUID(8);
|
const id = body.id || shortUID.randomUUID(8);
|
||||||
await setDoc(doc(db, "tickets", id), body);
|
await db.collection("tickets").updateOne(
|
||||||
res.status(200).json({ok: true});
|
{ id: id },
|
||||||
|
{ $set: { ...body, id: id } }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {app} from "@/firebase";
|
import client from "@/lib/mongodb";
|
||||||
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -26,14 +25,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(400).json({ message: 'Invalid ID' });
|
return res.status(400).json({ message: 'Invalid ID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const docRef = doc(db, "training", id);
|
const doc = await db.collection("training").findOne({ id: id });
|
||||||
const docSnap = await getDoc(docRef);
|
if (doc) {
|
||||||
|
res.status(200).json(doc);
|
||||||
if (docSnap.exists()) {
|
|
||||||
res.status(200).json({
|
|
||||||
id: docSnap.id,
|
|
||||||
...docSnap.data(),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ message: 'Document not found' });
|
res.status(404).json({ message: 'Document not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user