Compare commits
81 Commits
5792e80112
...
v0.7.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
a1d9e147f9
|
|||
|
92bfc7bafa
|
|||
|
6f61edd659
|
|||
|
ea4e848e57
|
|||
|
c08fa6c2d8
|
|||
|
01aae9e8ff
|
|||
|
70c6837858
|
|||
|
6167a713dd
|
|||
|
ab6b909fba
|
|||
|
9fdab2acc9
|
|||
|
68c242ae81
|
|||
|
cb3c1ab05f
|
|||
|
02dae967a2
|
|||
|
77ae081031
|
|||
|
aed60cc0d5
|
|||
|
278c7309b7
|
|||
|
a11b2a0568
|
|||
|
ff8e54449a
|
|||
|
64a59e856f
|
|||
|
5e8c5a1631
|
|||
|
e97949cab3
|
|||
|
b7a3608e67
|
|||
|
bbb544c029
|
|||
|
da42f6ed22
|
|||
|
8016e20451
|
|||
|
64ee8f4fea
|
|||
|
17e8d7dc37
|
|||
|
a409b0a5c7
|
|||
|
6ec4a1e025
|
|||
|
d063b0cf0d
|
|||
|
643d74e29d
|
|||
|
1526a10630
|
|||
|
fc035106d0
|
|||
|
8ae855838b
|
|||
|
9bd10b56d9
|
|||
|
1a78f82c5e
|
|||
|
475ba45248
|
|||
|
2a949d771a
|
|||
|
7fc640d679
|
|||
|
91b54cf791
|
|||
|
27b15a37f7
|
|||
|
947b463fe2
|
|||
|
c3098b073f
|
|||
|
b2420b270c
|
|||
|
9104ccab0f
|
|||
|
387af2e6ce
|
|||
|
6654132120
|
|||
|
59d2729719
|
|||
|
9f398e5509
|
|||
|
2fb236cf97
|
|||
|
7bc0573455
|
|||
|
68a2b8ffff
|
|||
|
ce696a5a04
|
|||
|
b0d6ec877b
|
|||
|
c03ad48615
|
|||
|
55bc1acbb3
|
|||
|
cd692a6f3b
|
|||
|
737de91bbc
|
|||
|
a6e357f973
|
|||
|
76b0498a18
|
|||
|
d6339815aa
|
|||
|
97481a5d2e
|
|||
|
369bbc4960
|
|||
|
c3ee739366
|
|||
|
adc34a116b
|
|||
|
b506ab7ca9
|
|||
|
dd631b71bb
|
|||
|
b0921ccf32
|
|||
|
78211a33ae
|
|||
|
4a273ccb2f
|
|||
|
3a67f2fbb1
|
|||
|
77619b0741
|
|||
|
ea785887a1
|
|||
|
b860e1d977
|
|||
|
274d0193f7
|
|||
|
033993b1b8
|
|||
|
2872fb867e
|
|||
|
8e73650462
|
|||
|
634cff507c
|
|||
|
fa4d83e42d
|
|||
|
c92f737612
|
@@ -1,9 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
@@ -4,9 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- alpha
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
env:
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-18.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -21,29 +24,24 @@ jobs:
|
|||||||
run: yarn install
|
run: yarn install
|
||||||
- name: Install Quasar CLI
|
- name: Install Quasar CLI
|
||||||
run: yarn global add @quasar/cli
|
run: yarn global add @quasar/cli
|
||||||
|
- name: Temporary - Invoke custom qcalendar build
|
||||||
|
run: quasar ext invoke @quasar/qcalendar
|
||||||
- name: Create env file
|
- name: Create env file
|
||||||
run: |
|
run: |
|
||||||
echo "${{ vars.ENV_FILE }}" > .env.local
|
echo "${{ vars.ENV_FILE }}" > .env.local
|
||||||
- name: Show env file
|
- name: Show env file
|
||||||
run: |
|
run: |
|
||||||
/bin/cat .env.local
|
/bin/cat .env.local
|
||||||
- name: Build Project
|
- name: Build and Release
|
||||||
run: quasar build -m pwa
|
id: build
|
||||||
- name: Get Version Number
|
|
||||||
id: get_version
|
|
||||||
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")"
|
|
||||||
- name: Tarfile
|
|
||||||
run: |
|
run: |
|
||||||
cd dist/pwa
|
npx semantic-release
|
||||||
tar czf ../../build-${{ steps.get_version.outputs.VERSION }}.tar.gz .
|
env:
|
||||||
- name: Upload Artifact
|
GITEA_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||||
uses: actions/upload-artifact@v3
|
GITEA_URL: ${{ vars.GT_URL }}
|
||||||
with:
|
|
||||||
name: build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}
|
|
||||||
path: build-${{ steps.get_version.outputs.VERSION }}.tar.gz
|
|
||||||
- name: Trigger Ansible Deploy Playbook
|
- name: Trigger Ansible Deploy Playbook
|
||||||
uses: https://github.com/distributhor/workflow-webhook@v3
|
uses: https://github.com/distributhor/workflow-webhook@v3
|
||||||
with:
|
with:
|
||||||
webhook_url: ${{ vars.WEBHOOK_URL }}
|
webhook_url: ${{ vars.WEBHOOK_URL }}
|
||||||
verbose: true
|
verbose: true
|
||||||
data: '{ "artifact_url": "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id}}/artifacts/build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}" }'
|
data: '{ "artifact_url": "${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v${{ steps.build.outputs.VERSION }}/release-${{ steps.build.outputs.VERSION }}.tar.gz" }'
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -34,4 +34,10 @@ yarn-error.log*
|
|||||||
*.sln
|
*.sln
|
||||||
|
|
||||||
# local .env files
|
# local .env files
|
||||||
.env.local*
|
.env*
|
||||||
|
|
||||||
|
# version file
|
||||||
|
src/version.js
|
||||||
|
VERSION
|
||||||
|
release-*.gz
|
||||||
|
CHANGELOG.md
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"semi": true
|
|
||||||
}
|
|
||||||
26
.releaserc.json
Normal file
26
.releaserc.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"branches": [
|
||||||
|
"main",
|
||||||
|
"next",
|
||||||
|
{ "name": "beta", "prerelease": true },
|
||||||
|
{ "name": "alpha", "prerelease": true }
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"prepareCmd": "npm run generate-version '${nextRelease.version}' && quasar build -m pwa",
|
||||||
|
"publishCmd": "tar -czvf release-${nextRelease.version}.tar.gz -C dist/pwa . && echo '::set-output name=VERSION::${nextRelease.version}'"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@saithodev/semantic-release-gitea",
|
||||||
|
{
|
||||||
|
"assets": ["release-${nextRelease.version}.tar.gz"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -41,3 +41,7 @@ quasar build
|
|||||||
### Customize the configuration
|
### Customize the configuration
|
||||||
|
|
||||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
|
||||||
|
https://github.com/semantic-release/semantic-release
|
||||||
|
|||||||
4
appwrite.json
Normal file
4
appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "65ede55a213134f2b688",
|
||||||
|
"projectName": ""
|
||||||
|
}
|
||||||
20
docs/time.md
Normal file
20
docs/time.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Dealing with Time
|
||||||
|
|
||||||
|
Dealing with time sucks, okay? We have three different formats we need to deal with:
|
||||||
|
|
||||||
|
1. ES Date - The native ECMAScript Date object. This is saddled with all the legacy of the decades. Hopefully, we will be able to retire this one day... Ref: https://tc39.es/proposal-temporal/docs/index.html
|
||||||
|
2. ISO 8601 Date - Used by Appwrite backend. This is just a string, but can represent any date, with or without a timezone.
|
||||||
|
3. Timestamp - Used internally by QCalendar.
|
||||||
|
|
||||||
|
We can't just use one format. We need ISO8601 format for Appwrite, and we get passed Timestamp objects by QCalendar. In the middle of that, we need ES Date objects to do some underlying math.
|
||||||
|
|
||||||
|
Componentization:
|
||||||
|
In order to make things clean and modular, we will rely on Timestamp as the main format in a component.
|
||||||
|
|
||||||
|
In data that comes from, or goes to the backend, we will store absolute dates in ISO format.
|
||||||
|
|
||||||
|
For any user-facing dates / times, the data will be rendered in the users local time.
|
||||||
|
|
||||||
|
For time-only data (as used in Intervals, eg: '09:00'), the template will be stored as a string of 'hh:mm', and represent the users local time. We may want to change this in the future, as this could prove a problem when a user is travelling, but wants to apply a template to their home location.
|
||||||
|
|
||||||
|
For now, we'll use the Timestamp object provided by QCalendar. We might need to refactor this in the future.
|
||||||
22
generate-version.js
Normal file
22
generate-version.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const version = process.argv[2];
|
||||||
|
|
||||||
|
if (!version) throw Error('Must pass version on command line');
|
||||||
|
|
||||||
|
// Create version content
|
||||||
|
const versionContent = `export const APP_VERSION = '${version}';\n`;
|
||||||
|
const versionTxtFilePath = path.resolve(__dirname, './VERSION');
|
||||||
|
const versionFilePath = path.resolve(__dirname, 'src/version.js');
|
||||||
|
|
||||||
|
// Write version to TXT file
|
||||||
|
fs.writeFileSync(versionTxtFilePath, version, 'utf8');
|
||||||
|
// Write version to js file
|
||||||
|
fs.writeFileSync(versionFilePath, versionContent, 'utf8');
|
||||||
|
console.log(`Version file generated with version: ${version}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating version file:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
36
nohup.out
Normal file
36
nohup.out
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
2024-06-06 07:42:15,841 - vorta.i18n - DEBUG - Loading translation failed for ['en-CA', 'en-Latn-CA'].
|
||||||
|
QObject::connect: No such signal QPlatformNativeInterface::systemTrayWindowChanged(QScreen*)
|
||||||
|
2024-06-06 07:42:15,884 - root - DEBUG - Not a private SSH key file: authorized_keys
|
||||||
|
2024-06-06 07:42:15,885 - root - DEBUG - Not a private SSH key file: github_rsa.pub_bak-github
|
||||||
|
2024-06-06 07:42:15,886 - root - DEBUG - Not a private SSH key file: other_keys.seahorse
|
||||||
|
2024-06-06 07:42:16,077 - root - INFO - Using NetworkManagerMonitor NetworkStatusMonitor implementation.
|
||||||
|
Requested decoration "adwaita" not found, falling back to default
|
||||||
|
qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
|
||||||
|
2024-06-06 07:42:16,209 - vorta.borg.jobs_manager - DEBUG - Add job for site default
|
||||||
|
2024-06-06 07:42:16,210 - vorta.borg.jobs_manager - DEBUG - Start job on site: default
|
||||||
|
2024-06-06 07:42:16,237 - vorta.borg.borg_job - INFO - Running command /usr/bin/borg --version
|
||||||
|
2024-06-06 07:42:20,564 - vorta.borg.jobs_manager - DEBUG - Finish job for site: default
|
||||||
|
2024-06-06 07:42:20,565 - vorta.borg.jobs_manager - DEBUG - No more jobs for site: default
|
||||||
|
2024-06-06 07:42:20,566 - vorta.scheduler - DEBUG - Refreshing all scheduler timers
|
||||||
|
2024-06-06 07:42:20,568 - vorta.scheduler - DEBUG - Nothing scheduled for profile 1 because of unset repo.
|
||||||
|
qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
|
||||||
|
2024-06-06 07:42:23,190 - root - DEBUG - Not a private SSH key file: authorized_keys
|
||||||
|
2024-06-06 07:42:23,191 - root - DEBUG - Not a private SSH key file: github_rsa.pub_bak-github
|
||||||
|
2024-06-06 07:42:23,191 - root - DEBUG - Not a private SSH key file: other_keys.seahorse
|
||||||
|
2024-06-06 07:42:23,204 - vorta.keyring.abc - DEBUG - Only available on macOS
|
||||||
|
2024-06-06 07:42:23,244 - asyncio - DEBUG - Using selector: EpollSelector
|
||||||
|
2024-06-06 07:42:23,245 - vorta.keyring.abc - DEBUG - Using VortaSecretStorageKeyring
|
||||||
|
2024-06-06 07:49:53,786 - vorta.keyring.abc - DEBUG - Only available on macOS
|
||||||
|
2024-06-06 07:49:53,788 - asyncio - DEBUG - Using selector: EpollSelector
|
||||||
|
2024-06-06 07:49:53,788 - vorta.keyring.abc - DEBUG - Using VortaSecretStorageKeyring
|
||||||
|
2024-06-06 07:49:53,789 - asyncio - DEBUG - Using selector: EpollSelector
|
||||||
|
2024-06-06 07:49:53,790 - vorta.keyring.secretstorage - DEBUG - Found 0 passwords matching repo URL.
|
||||||
|
qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
|
||||||
|
2024-06-06 07:50:10,009 - vorta.keyring.abc - DEBUG - Only available on macOS
|
||||||
|
2024-06-06 07:50:10,011 - asyncio - DEBUG - Using selector: EpollSelector
|
||||||
|
2024-06-06 07:50:10,012 - vorta.keyring.abc - DEBUG - Using VortaSecretStorageKeyring
|
||||||
|
2024-06-06 07:50:10,012 - vorta.borg.borg_job - DEBUG - Using VortaSecretStorageKeyring keyring to store passwords.
|
||||||
|
2024-06-06 07:50:10,013 - asyncio - DEBUG - Using selector: EpollSelector
|
||||||
|
2024-06-06 07:50:10,013 - vorta.keyring.secretstorage - DEBUG - Found 0 passwords matching repo URL.
|
||||||
|
2024-06-06 07:50:10,013 - vorta.borg.borg_job - DEBUG - Password not found in primary keyring. Falling back to VortaDBKeyring.
|
||||||
|
2024-06-06 07:50:10,029 - vorta.borg.borg_job - INFO - Running command /usr/bin/borg info --info --json --log-json ssh://borg@borg.toal.ca:12022/./ptoal-linux
|
||||||
32
package.json
32
package.json
@@ -1,27 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "oys_bab",
|
"name": "oys_bab",
|
||||||
"version": "0.0.2",
|
"version": "0.0.0",
|
||||||
"description": "Manage a Borrow a Boat program for a Yacht Club",
|
"description": "Manage a Borrow a Boat program for a Yacht Club",
|
||||||
"productName": "OYS Borrow a Boat",
|
"productName": "OYS Borrow a Boat",
|
||||||
"author": "Patrick Toal <ptoal@takeflight.ca>",
|
"author": "Patrick Toal <ptoal@takeflight.ca>",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"generate-version": "node generate-version.js",
|
||||||
"lint": "eslint --ext .js,.ts,.vue ./",
|
"lint": "eslint --ext .js,.ts,.vue ./",
|
||||||
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||||
"test": "echo \"No test specified\" && exit 0",
|
"test": "echo \"No test specified\" && exit 0",
|
||||||
"dev": "quasar dev",
|
"dev": "npm run generate-version && quasar dev -m pwa",
|
||||||
"build": "quasar build"
|
"build": "npm run generate-version && quasar build -m pwa"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.11",
|
"@quasar/extras": "^1.16.11",
|
||||||
"appwrite": "^13.0.0",
|
"@quasar/quasar-app-extension-qcalendar": "https://github.com/ptoal/quasar-ui-qcalendar/releases/download/v4.0.0-beta.19/app-extension.tgz",
|
||||||
|
"@quasar/quasar-ui-qcalendar": "https://github.com/ptoal/quasar-ui-qcalendar/releases/download/v4.0.0-beta.19/qcalendar-ui.tgz",
|
||||||
|
"appwrite": "^14.0.1",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"file": "^0.2.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "3",
|
"vue": "3",
|
||||||
"vue-router": "4"
|
"vue-router": "4",
|
||||||
|
"vue3-google-login": "^2.0.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quasar/app-vite": "^1.7.4",
|
"@quasar/app-vite": "^1.9.1",
|
||||||
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.15",
|
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||||
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
"@semantic-release/exec": "^6.0.3",
|
||||||
|
"@semantic-release/github": "^10.0.6",
|
||||||
|
"@semantic-release/npm": "^12.0.1",
|
||||||
"@types/node": "^12.20.21",
|
"@types/node": "^12.20.21",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||||
"@typescript-eslint/parser": "^5.10.0",
|
"@typescript-eslint/parser": "^5.10.0",
|
||||||
@@ -30,9 +40,13 @@
|
|||||||
"eslint": "^8.10.0",
|
"eslint": "^8.10.0",
|
||||||
"eslint-config-prettier": "^8.1.0",
|
"eslint-config-prettier": "^8.1.0",
|
||||||
"eslint-plugin-vue": "^9.0.0",
|
"eslint-plugin-vue": "^9.0.0",
|
||||||
|
"git-commit-info": "^2.0.2",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"quasar": "^2.15.2",
|
"quasar": "^2.16.0",
|
||||||
"typescript": "^4.5.4",
|
"semantic-release": "^24.0.0",
|
||||||
|
"typescript": "~5.3.0",
|
||||||
|
"vite-plugin-checker": "^0.6.4",
|
||||||
|
"vue-tsc": "^1.8.22",
|
||||||
"workbox-build": "^7.0.0",
|
"workbox-build": "^7.0.0",
|
||||||
"workbox-cacheable-response": "^7.0.0",
|
"workbox-cacheable-response": "^7.0.0",
|
||||||
"workbox-core": "^7.0.0",
|
"workbox-core": "^7.0.0",
|
||||||
|
|||||||
BIN
public/tmpimg/JMI.jpg
Normal file
BIN
public/tmpimg/JMI.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
public/tmpimg/projectX.jpg
Normal file
BIN
public/tmpimg/projectX.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -9,9 +9,8 @@
|
|||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||||
|
|
||||||
const { configure } = require('quasar/wrappers');
|
const { configure } = require('quasar/wrappers');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
module.exports = configure(function (/* ctx */) {
|
module.exports = configure(function () {
|
||||||
return {
|
return {
|
||||||
eslint: {
|
eslint: {
|
||||||
// fix: true,
|
// fix: true,
|
||||||
@@ -49,12 +48,11 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
|
|
||||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||||
build: {
|
build: {
|
||||||
env: require('dotenv').config({ path: '.env.local' }).parsed,
|
|
||||||
target: {
|
target: {
|
||||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||||
node: 'node16',
|
node: 'node16',
|
||||||
},
|
},
|
||||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||||
// vueRouterBase,
|
// vueRouterBase,
|
||||||
// vueDevtools,
|
// vueDevtools,
|
||||||
// vueOptionsAPI: false,
|
// vueOptionsAPI: false,
|
||||||
@@ -73,9 +71,20 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
// extendViteConf (viteConf) {},
|
// extendViteConf (viteConf) {},
|
||||||
// viteVuePluginOptions: {},
|
// viteVuePluginOptions: {},
|
||||||
|
|
||||||
// vitePlugins: [
|
vitePlugins: [
|
||||||
// [ 'package-name', { ..options.. } ]
|
[
|
||||||
// ]
|
'vite-plugin-checker',
|
||||||
|
{
|
||||||
|
vueTsc: {
|
||||||
|
tsconfigPath: 'tsconfig.vue-tsc.json',
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
lintCommand: 'eslint "./**/*.{js,ts,mjs,cjs,vue}"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ server: false },
|
||||||
|
],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
|
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
|
||||||
@@ -92,6 +101,19 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
|
'/api/v1/realtime': {
|
||||||
|
target: 'wss://apidev.bab.toal.ca',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
secure: false,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
// '/function': {
|
||||||
|
// target: 'https://6640382951eacb568371.f.appwrite.toal.ca/',
|
||||||
|
// changeOrigin: true,
|
||||||
|
// secure: false,
|
||||||
|
// rewrite: (path) => path.replace(/^\/function/, ''),
|
||||||
|
// },
|
||||||
},
|
},
|
||||||
// For reverse-proxying via haproxy
|
// For reverse-proxying via haproxy
|
||||||
// hmr: {
|
// hmr: {
|
||||||
@@ -104,7 +126,7 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
|
||||||
framework: {
|
framework: {
|
||||||
config: {
|
config: {
|
||||||
autoImportComponentCase: 'combined', // or 'kebab' (default) or 'combined'
|
autoImportComponentCase: 'kebab', // or 'kebab' (default) or 'combined'
|
||||||
},
|
},
|
||||||
|
|
||||||
// iconSet: 'material-icons', // Quasar icon set
|
// iconSet: 'material-icons', // Quasar icon set
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"orientation": "portrait",
|
"orientation": "natural",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#027be3",
|
"theme_color": "#027be3",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
@@ -9,33 +9,35 @@ register(process.env.SERVICE_WORKER_FILE, {
|
|||||||
// to ServiceWorkerContainer.register()
|
// to ServiceWorkerContainer.register()
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
|
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
|
||||||
|
|
||||||
// registrationOptions: { scope: './' },
|
registrationOptions: { scope: './' },
|
||||||
|
|
||||||
ready (/* registration */) {
|
ready(/* registration */) {
|
||||||
// console.log('Service worker is active.')
|
console.log('Service worker is active.');
|
||||||
},
|
},
|
||||||
|
|
||||||
registered (/* registration */) {
|
registered(/* registration */) {
|
||||||
// console.log('Service worker has been registered.')
|
console.log('Service worker has been registered.');
|
||||||
},
|
},
|
||||||
|
|
||||||
cached (/* registration */) {
|
cached(/* registration */) {
|
||||||
// console.log('Content has been cached for offline use.')
|
console.log('Content has been cached for offline use.');
|
||||||
},
|
},
|
||||||
|
|
||||||
updatefound (/* registration */) {
|
updatefound(/* registration */) {
|
||||||
// console.log('New content is downloading.')
|
console.log('New content is downloading.');
|
||||||
},
|
},
|
||||||
|
|
||||||
updated (/* registration */) {
|
updated(/* registration */) {
|
||||||
// console.log('New content is available; please refresh.')
|
console.log('New content is available; please refresh.');
|
||||||
},
|
},
|
||||||
|
|
||||||
offline () {
|
offline() {
|
||||||
// console.log('No internet connection found. App is running in offline mode.')
|
console.log(
|
||||||
|
'No internet connection found. App is running in offline mode.'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
error (/* err */) {
|
error(err) {
|
||||||
// console.error('Error during service worker registration:', err)
|
console.error('Error during service worker registration:', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
11
src/App.vue
11
src/App.vue
@@ -2,10 +2,15 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent, onMounted } from 'vue';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
|
||||||
export default defineComponent({
|
defineComponent({
|
||||||
name: 'OYS Borrow-a-Boat',
|
name: 'OYS Borrow-a-Boat',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await useAuthStore().init();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,91 @@
|
|||||||
import { boot } from 'quasar/wrappers';
|
import { boot } from 'quasar/wrappers';
|
||||||
import { Client, Account, Databases, ID } from 'appwrite';
|
import {
|
||||||
|
Client,
|
||||||
|
Account,
|
||||||
|
Databases,
|
||||||
|
Functions,
|
||||||
|
ID,
|
||||||
|
AppwriteException,
|
||||||
|
Teams,
|
||||||
|
} from 'appwrite';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
import { Dialog, Notify } from 'quasar';
|
import { Dialog, Notify } from 'quasar';
|
||||||
import type { Router } from 'vue-router';
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
|
||||||
// appwrite.io SaaS
|
const API_ENDPOINT = import.meta.env.VITE_APPWRITE_API_ENDPOINT;
|
||||||
// client
|
const API_PROJECT = import.meta.env.VITE_APPWRITE_API_PROJECT;
|
||||||
// .setEndpoint('https://api.bab.toal.ca/v1')
|
|
||||||
// .setProject('653ef6f76baf06d68034');
|
|
||||||
// const appDatabaseId = '654ac5044d1c446feb71';
|
|
||||||
|
|
||||||
// Private self-hosted appwrite
|
if (API_ENDPOINT && API_PROJECT) {
|
||||||
client
|
client.setEndpoint(API_ENDPOINT).setProject(API_PROJECT);
|
||||||
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
|
} else {
|
||||||
.setProject(process.env.APPWRITE_API_PROJECT);
|
console.error(
|
||||||
//TODO move this to config file
|
'Must configure VITE_APPWRITE_API_ENDPOINT and VITE_APPWRITE_API_PROJECT'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const AppwriteIds = {
|
type AppwriteIDConfig = {
|
||||||
databaseId: '65ee1cbf9c2493faf15f',
|
databaseId: string;
|
||||||
collectionIdTask: '65ee1cd5b550023fae4f',
|
collection: {
|
||||||
collectionIdTaskTags: '65ee21d72d5c8007c34c',
|
boat: string;
|
||||||
collectionIdSkillTags: '66072582a74d94a4bd01',
|
reservation: string;
|
||||||
|
skillTags: string;
|
||||||
|
task: string;
|
||||||
|
taskTags: string;
|
||||||
|
interval: string;
|
||||||
|
intervalTemplate: string;
|
||||||
|
};
|
||||||
|
function: {
|
||||||
|
userinfo: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let AppwriteIds = <AppwriteIDConfig>{};
|
||||||
|
|
||||||
|
console.log(API_ENDPOINT);
|
||||||
|
if (
|
||||||
|
API_ENDPOINT === 'https://apidev.bab.toal.ca/v1' ||
|
||||||
|
API_ENDPOINT === 'http://localhost:4000/api/v1'
|
||||||
|
) {
|
||||||
|
AppwriteIds = {
|
||||||
|
databaseId: '65ee1cbf9c2493faf15f',
|
||||||
|
collection: {
|
||||||
|
boat: 'boat',
|
||||||
|
reservation: 'reservation',
|
||||||
|
skillTags: 'skillTags',
|
||||||
|
task: 'task',
|
||||||
|
taskTags: 'taskTags',
|
||||||
|
interval: 'interval',
|
||||||
|
intervalTemplate: 'intervalTemplate',
|
||||||
|
},
|
||||||
|
function: {
|
||||||
|
userinfo: 'userinfo',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (API_ENDPOINT === 'https://appwrite.oys.undock.ca/v1') {
|
||||||
|
AppwriteIds = {
|
||||||
|
databaseId: 'bab_prod',
|
||||||
|
collection: {
|
||||||
|
boat: 'boat',
|
||||||
|
reservation: 'reservation',
|
||||||
|
skillTags: 'skillTags',
|
||||||
|
task: 'task',
|
||||||
|
taskTags: 'taskTags',
|
||||||
|
interval: 'interval',
|
||||||
|
intervalTemplate: 'intervalTemplate',
|
||||||
|
},
|
||||||
|
function: {
|
||||||
|
userinfo: '664038294b5473ef0c8d',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const account = new Account(client);
|
const account = new Account(client);
|
||||||
const databases = new Databases(client);
|
const databases = new Databases(client);
|
||||||
|
const functions = new Functions(client);
|
||||||
|
const teams = new Teams(client);
|
||||||
|
|
||||||
let appRouter: Router;
|
let appRouter: Router;
|
||||||
|
|
||||||
export default boot(async ({ router }) => {
|
export default boot(async ({ router }) => {
|
||||||
@@ -56,7 +115,7 @@ async function logout() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
const notification = Notify.create({
|
const notification = Notify.create({
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -66,30 +125,57 @@ function login(email: string, password: string) {
|
|||||||
group: false,
|
group: false,
|
||||||
});
|
});
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
authStore
|
try {
|
||||||
.login(email, password)
|
await authStore.login(email, password);
|
||||||
.then(() => {
|
notification({
|
||||||
notification({
|
type: 'positive',
|
||||||
type: 'positive',
|
message: 'Logged in!',
|
||||||
message: 'Logged in!',
|
timeout: 2000,
|
||||||
timeout: 2000,
|
spinner: false,
|
||||||
spinner: false,
|
icon: 'check_circle',
|
||||||
icon: 'check_circle',
|
});
|
||||||
});
|
appRouter.replace({ name: 'index' });
|
||||||
console.log('Redirecting to index page');
|
} catch (error: unknown) {
|
||||||
appRouter.replace({ name: 'index' });
|
console.log(error);
|
||||||
})
|
if (error instanceof AppwriteException) {
|
||||||
.catch(function (reason: Error) {
|
if (error.type === 'user_session_already_exists') {
|
||||||
notification({
|
appRouter.replace({ name: 'index' });
|
||||||
type: 'negative',
|
notification({
|
||||||
message: 'Login failed.',
|
type: 'positive',
|
||||||
timeout: 1,
|
message: 'Already Logged in!',
|
||||||
});
|
timeout: 2000,
|
||||||
|
spinner: false,
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
Dialog.create({
|
Dialog.create({
|
||||||
title: 'Login Error!',
|
title: 'Login Error!',
|
||||||
message: reason.message,
|
message: error.message,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
notification({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Login failed.',
|
||||||
|
timeout: 2000,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export { client, account, databases, ID, AppwriteIds, login, logout };
|
|
||||||
|
async function resetPassword(email: string) {
|
||||||
|
await account.createRecovery(email, window.location.origin + '/pwreset');
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
client,
|
||||||
|
account,
|
||||||
|
teams,
|
||||||
|
databases,
|
||||||
|
functions,
|
||||||
|
ID,
|
||||||
|
AppwriteIds,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
resetPassword,
|
||||||
|
};
|
||||||
|
|||||||
285
src/components/BoatReservationComponent.vue
Normal file
285
src/components/BoatReservationComponent.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<div class="q-pa-xs row q-gutter-xs">
|
||||||
|
<q-card
|
||||||
|
flat
|
||||||
|
class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h5 q-mt-none q-mb-xs">
|
||||||
|
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-list class="q-px-xs">
|
||||||
|
<q-item
|
||||||
|
class="q-pa-none"
|
||||||
|
clickable
|
||||||
|
@click="boatSelect = true">
|
||||||
|
<q-card
|
||||||
|
v-if="boat"
|
||||||
|
class="col-12">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img
|
||||||
|
:src="boat.imgSrc"
|
||||||
|
:fit="'scale-down'">
|
||||||
|
<div class="row absolute-top">
|
||||||
|
<div class="col text-h7 text-left">
|
||||||
|
{{ boat.name }}
|
||||||
|
</div>
|
||||||
|
<div class="col text-right text-caption">
|
||||||
|
{{ boat.class }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-card-section class="col-9">
|
||||||
|
<q-list
|
||||||
|
dense
|
||||||
|
class="row">
|
||||||
|
<q-item class="q-ma-none col-12">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge
|
||||||
|
color="primary"
|
||||||
|
label="Start" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-body2">
|
||||||
|
{{ formatDate(bookingForm.interval?.start) }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-ma-none col-12">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge
|
||||||
|
color="primary"
|
||||||
|
label="End" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
class="text-body2"
|
||||||
|
style="min-width: 150px">
|
||||||
|
{{ formatDate(bookingForm.interval?.end) }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator vertical />
|
||||||
|
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||||
|
{{ bookingDuration.hours }} hours
|
||||||
|
<div v-if="bookingDuration.minutes">
|
||||||
|
<q-separator />
|
||||||
|
{{ bookingDuration.minutes }} mins
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="col-12">
|
||||||
|
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||||
|
</div>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-px-none">
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
v-model="bookingForm.reason"
|
||||||
|
:options="reason_options"
|
||||||
|
label="Reason for sail" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-px-none">
|
||||||
|
<q-item-section>
|
||||||
|
<q-input
|
||||||
|
v-model="bookingForm.comment"
|
||||||
|
clearable
|
||||||
|
autogrow
|
||||||
|
filled
|
||||||
|
label="Additional Comments (optional)" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
label="Delete"
|
||||||
|
color="negative"
|
||||||
|
size="lg"
|
||||||
|
v-if="reservation?.$id"
|
||||||
|
@click="onDelete" />
|
||||||
|
<q-btn
|
||||||
|
label="Reset"
|
||||||
|
@click="onReset"
|
||||||
|
size="lg"
|
||||||
|
color="secondary" />
|
||||||
|
<q-btn
|
||||||
|
label="Submit"
|
||||||
|
@click="onSubmit"
|
||||||
|
size="lg"
|
||||||
|
color="primary" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<q-dialog
|
||||||
|
v-model="boatSelect"
|
||||||
|
full-width>
|
||||||
|
<BoatScheduleTableComponent
|
||||||
|
:model-value="bookingForm.interval"
|
||||||
|
@update:model-value="updateInterval" />
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
|
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||||
|
import { formatDate } from 'src/utils/schedule';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
interface BookingForm {
|
||||||
|
$id?: string;
|
||||||
|
user?: string;
|
||||||
|
interval?: Interval | null;
|
||||||
|
reason?: string;
|
||||||
|
members?: string[];
|
||||||
|
guests?: string[];
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
|
||||||
|
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const newForm = {
|
||||||
|
user: auth.currentUser?.$id,
|
||||||
|
interval: {} as Interval,
|
||||||
|
reason: 'Open Sail',
|
||||||
|
members: [],
|
||||||
|
guests: [],
|
||||||
|
comment: '',
|
||||||
|
};
|
||||||
|
const reservation = defineModel<Reservation>();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const boatSelect = ref(false);
|
||||||
|
const bookingForm = ref<BookingForm>({ ...newForm });
|
||||||
|
const $q = useQuasar();
|
||||||
|
const $router = useRouter();
|
||||||
|
|
||||||
|
watch(reservation, (newReservation) => {
|
||||||
|
if (!newReservation) {
|
||||||
|
bookingForm.value = newForm;
|
||||||
|
} else {
|
||||||
|
const updatedReservation = {
|
||||||
|
...newReservation,
|
||||||
|
user: auth.currentUser?.$id,
|
||||||
|
interval: {
|
||||||
|
start: newReservation.start,
|
||||||
|
end: newReservation.end,
|
||||||
|
resource: newReservation.resource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
bookingForm.value = updatedReservation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInterval = (interval: Interval | null) => {
|
||||||
|
bookingForm.value.interval = interval;
|
||||||
|
boatSelect.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||||
|
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
|
||||||
|
const start = new Date(bookingForm.value.interval.start).getTime();
|
||||||
|
const end = new Date(bookingForm.value.interval.end).getTime();
|
||||||
|
const delta = Math.abs(end - start) / 1000;
|
||||||
|
const hours = Math.floor(delta / 3600) % 24;
|
||||||
|
const minutes = Math.floor(delta - hours * 3600) % 60;
|
||||||
|
return { hours: hours, minutes: minutes };
|
||||||
|
}
|
||||||
|
return { hours: 0, minutes: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingName = computed(() =>
|
||||||
|
auth.getUserNameById(bookingForm.value?.user)
|
||||||
|
);
|
||||||
|
|
||||||
|
const boat = computed((): Boat | null => {
|
||||||
|
const boatId = bookingForm.value.interval?.resource;
|
||||||
|
return boatStore.getBoatById(boatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
reservationStore.deleteReservation(reservation.value?.$id);
|
||||||
|
$router.go(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
bookingForm.value.interval = null;
|
||||||
|
bookingForm.value = reservation.value
|
||||||
|
? {
|
||||||
|
...reservation.value,
|
||||||
|
interval: {
|
||||||
|
start: reservation.value.start,
|
||||||
|
end: reservation.value.end,
|
||||||
|
resource: reservation.value.resource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { ...newForm };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const booking = bookingForm.value;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
booking.interval &&
|
||||||
|
booking.interval.resource &&
|
||||||
|
booking.interval.start &&
|
||||||
|
booking.interval.end &&
|
||||||
|
auth.currentUser
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// TODO: Make a proper validator
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newReservation = <Reservation>{
|
||||||
|
resource: booking.interval.resource,
|
||||||
|
start: booking.interval.start,
|
||||||
|
end: booking.interval.end,
|
||||||
|
user: auth.currentUser.$id,
|
||||||
|
status: 'confirmed',
|
||||||
|
reason: booking.reason,
|
||||||
|
comment: booking.comment,
|
||||||
|
$id: reservation.value?.$id,
|
||||||
|
};
|
||||||
|
const status = $q.notify({
|
||||||
|
color: 'secondary',
|
||||||
|
textColor: 'white',
|
||||||
|
message: 'Submitting Reservation',
|
||||||
|
spinner: true,
|
||||||
|
closeBtn: 'Dismiss',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 0,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const r = await reservationStore.createOrUpdateReservation(newReservation);
|
||||||
|
status({
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'cloud_done',
|
||||||
|
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
|
||||||
|
boatStore.getBoatById(r.resource)?.name
|
||||||
|
} at ${formatDate(r.start)}`,
|
||||||
|
spinner: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
status({
|
||||||
|
color: 'negative',
|
||||||
|
icon: 'error',
|
||||||
|
spinner: false,
|
||||||
|
message: 'Failed to book!' + e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$router.go(-1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
icon="calendar_month"
|
icon="calendar_month"
|
||||||
to="/schedule"
|
to="/schedule"
|
||||||
></q-route-tab>
|
></q-route-tab>
|
||||||
<q-route-tab
|
<!-- <q-route-tab
|
||||||
name="Checklists"
|
name="Checklists"
|
||||||
icon="checklist"
|
icon="checklist"
|
||||||
to="/checklist"
|
to="/checklist"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
></q-route-tab>
|
></q-route-tab>
|
||||||
<q-route-tab name="Tasks" icon="build" to="/task">
|
<q-route-tab name="Tasks" icon="build" to="/task">
|
||||||
<q-badge color="red" floating> NEW </q-badge>
|
<q-badge color="red" floating> NEW </q-badge>
|
||||||
</q-route-tab>
|
</q-route-tab> -->
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
19
src/components/DiscordOauthComponent.vue
Normal file
19
src/components/DiscordOauthComponent.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<q-btn
|
||||||
|
@click="auth.discordLogin()"
|
||||||
|
style="width: 300px">
|
||||||
|
<q-avatar
|
||||||
|
left
|
||||||
|
size="sm"
|
||||||
|
class="q-ma-xs">
|
||||||
|
<img
|
||||||
|
src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/636e0a6a49cf127bf92de1e2_icon_clyde_blurple_RGB.png" />
|
||||||
|
</q-avatar>
|
||||||
|
Login with Discord
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
const auth = useAuthStore();
|
||||||
|
</script>
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<p>{{ title }}</p>
|
|
||||||
<ul>
|
|
||||||
<li v-for="todo in todos" :key="todo.id" @click="increment">
|
|
||||||
{{ todo.id }} - {{ todo.content }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
|
|
||||||
<p>Active: {{ active ? 'yes' : 'no' }}</p>
|
|
||||||
<p>Clicks on todos: {{ clickCount }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
defineComponent,
|
|
||||||
PropType,
|
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
toRef,
|
|
||||||
Ref,
|
|
||||||
} from 'vue';
|
|
||||||
import { Todo, Meta } from './models';
|
|
||||||
|
|
||||||
function useClickCount() {
|
|
||||||
const clickCount = ref(0);
|
|
||||||
function increment() {
|
|
||||||
clickCount.value += 1
|
|
||||||
return clickCount.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { clickCount, increment };
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDisplayTodo(todos: Ref<Todo[]>) {
|
|
||||||
const todoCount = computed(() => todos.value.length);
|
|
||||||
return { todoCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'ExampleComponent',
|
|
||||||
props: {
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
todos: {
|
|
||||||
type: Array as PropType<Todo[]>,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
type: Object as PropType<Meta>,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
type: Boolean
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup (props) {
|
|
||||||
return { ...useClickCount(), ...useDisplayTodo(toRef(props, 'todos')) };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click="auth.googleLogin()">Login with Google</div>
|
<q-btn
|
||||||
|
@click="auth.googleLogin()"
|
||||||
|
style="width: 300px">
|
||||||
|
<q-avatar
|
||||||
|
left
|
||||||
|
class="q-ma-xs"
|
||||||
|
size="sm">
|
||||||
|
<img
|
||||||
|
src="https://lh3.googleusercontent.com/COxitqgJr1sJnIDe8-jiKhxDx1FrYbtRHKJ9z_hELisAlapwE9LUPh6fcXIfb5vwpbMl4xl9H9TRFPc5NOO8Sb3VSgIBrfRYvW6cUA" />
|
||||||
|
</q-avatar>
|
||||||
|
<div>Login with Google</div>
|
||||||
|
</q-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -4,22 +4,57 @@
|
|||||||
show-if-above
|
show-if-above
|
||||||
:width="200"
|
:width="200"
|
||||||
:breakpoint="1024"
|
:breakpoint="1024"
|
||||||
@update:model-value="$emit('drawer-toggle')"
|
@update:model-value="$emit('drawer-toggle')">
|
||||||
>
|
|
||||||
<q-scroll-area class="fit">
|
<q-scroll-area class="fit">
|
||||||
<q-list padding class="menu-list">
|
<q-list
|
||||||
<template v-for="link in links" :key="link.name">
|
padding
|
||||||
<q-item clickable v-ripple :to="link.to">
|
class="menu-list">
|
||||||
|
<template
|
||||||
|
v-for="link in enabledLinks"
|
||||||
|
:key="link.name">
|
||||||
|
<!-- TODO: Template this to be DRY -->
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
:to="link.to">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon :name="link.icon" />
|
<q-icon :name="link.icon" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section> {{ link.name }} </q-item-section>
|
<q-item-section>
|
||||||
|
<span :class="link.color ? `text-${link.color}` : ''">
|
||||||
|
{{ link.name }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-list v-if="link.sublinks">
|
||||||
|
<div
|
||||||
|
v-for="sublink in link.sublinks"
|
||||||
|
:key="sublink.name">
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
:to="sublink.to"
|
||||||
|
class="q-ml-md">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="sublink.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section>
|
||||||
|
<span :class="sublink.color ? `text-${sublink.color}` : ''">
|
||||||
|
{{ sublink.name }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</q-list>
|
||||||
</template>
|
</template>
|
||||||
<q-item clickable v-ripple @click="logout()">
|
<q-item
|
||||||
<q-item-section avatar><q-icon name="logout" /></q-item-section
|
clickable
|
||||||
><q-item-section>Logout</q-item-section>
|
v-ripple
|
||||||
|
@click="logout()">
|
||||||
|
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||||
|
<q-item-section>Logout</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-scroll-area>
|
</q-scroll-area>
|
||||||
@@ -28,7 +63,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { links } from 'src/router/navlinks.js';
|
import { enabledLinks } from 'src/router/navlinks.js';
|
||||||
import { logout } from 'boot/appwrite';
|
import { logout } from 'boot/appwrite';
|
||||||
|
|
||||||
defineProps(['drawer']);
|
defineProps(['drawer']);
|
||||||
|
|||||||
62
src/components/NewPasswordComponent.vue
Normal file
62
src/components/NewPasswordComponent.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<q-card-section class="q-ma-sm">
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
color="darkblue"
|
||||||
|
:rules="[validatePasswordStrength]"
|
||||||
|
lazy-rules
|
||||||
|
filled></q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
label="Confirm New Password"
|
||||||
|
type="password"
|
||||||
|
color="darkblue"
|
||||||
|
:rules="[validatePasswordStrength]"
|
||||||
|
lazy-rules
|
||||||
|
filled></q-input>
|
||||||
|
<div class="text-caption q-py-md">Enter a new password.</div>
|
||||||
|
</q-card-section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
|
const newPassword = defineModel();
|
||||||
|
|
||||||
|
const validatePasswordStrength = (val: string) => {
|
||||||
|
const hasUpperCase = /[A-Z]/.test(val);
|
||||||
|
const hasLowerCase = /[a-z]/.test(val);
|
||||||
|
const hasNumbers = /[0-9]/.test(val);
|
||||||
|
const hasNonAlphas = /[\W_]/.test(val);
|
||||||
|
const isValidLength = val.length >= 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hasUpperCase &&
|
||||||
|
hasLowerCase &&
|
||||||
|
hasNumbers &&
|
||||||
|
hasNonAlphas &&
|
||||||
|
isValidLength) ||
|
||||||
|
'Password must be at least 8 characters long and include uppercase, lowercase, number, and special character.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePasswordsMatch = (val: string) => {
|
||||||
|
return val === password.value || 'Passwords do not match.';
|
||||||
|
};
|
||||||
|
|
||||||
|
watch([password, confirmPassword], ([newpw, newpw1]) => {
|
||||||
|
if (
|
||||||
|
validatePasswordStrength(newpw) === true &&
|
||||||
|
validatePasswordsMatch(newpw1) === true
|
||||||
|
) {
|
||||||
|
newPassword.value = newpw;
|
||||||
|
} else {
|
||||||
|
newPassword.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -12,21 +12,20 @@
|
|||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
class="q-button"
|
class="q-button"
|
||||||
style="cursor: pointer; user-select: none"
|
style="cursor: pointer; user-select: none"
|
||||||
@click="onPrev"
|
@click="onPrev">
|
||||||
><</span
|
<
|
||||||
>
|
</span>
|
||||||
{{ formattedMonth }}
|
{{ formattedMonth }}
|
||||||
<span
|
<span
|
||||||
class="q-button"
|
class="q-button"
|
||||||
style="cursor: pointer; user-select: none"
|
style="cursor: pointer; user-select: none"
|
||||||
@click="onNext"
|
@click="onNext">
|
||||||
>></span
|
>
|
||||||
>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<div style="display: flex; width: 100%">
|
<div style="display: flex; width: 100%">
|
||||||
<q-calendar-month
|
<q-calendar-month
|
||||||
ref="calendar"
|
ref="calendar"
|
||||||
@@ -48,10 +46,10 @@
|
|||||||
date-type="rounded"
|
date-type="rounded"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@moved="onMoved"
|
@moved="onMoved"
|
||||||
@click-date="onClickDate"
|
@click-date="onClickDate" />
|
||||||
/>
|
</div>
|
||||||
</div></div
|
</div>
|
||||||
></q-card-section>
|
</q-card-section>
|
||||||
<q-calendar-resource
|
<q-calendar-resource
|
||||||
v-model="selectedDate"
|
v-model="selectedDate"
|
||||||
:model-resources="boatStore.boats"
|
:model-resources="boatStore.boats"
|
||||||
@@ -73,18 +71,25 @@
|
|||||||
@click-time="onClickTime"
|
@click-time="onClickTime"
|
||||||
@click-resource="onClickResource"
|
@click-resource="onClickResource"
|
||||||
@click-head-resources="onClickHeadResources"
|
@click-head-resources="onClickHeadResources"
|
||||||
@click-interval="onClickInterval"
|
@click-interval="onClickInterval">
|
||||||
>
|
|
||||||
<template #resource-intervals="{ scope }">
|
<template #resource-intervals="{ scope }">
|
||||||
<template v-for="(event, index) in getEvents(scope)" :key="index">
|
<template
|
||||||
<q-badge outline :label="event.title" :style="getStyle(event)" />
|
v-for="(event, index) in getEvents(scope)"
|
||||||
|
:key="index">
|
||||||
|
<q-badge
|
||||||
|
outline
|
||||||
|
:label="event.title"
|
||||||
|
:style="getStyle(event)" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #resource-label="{ scope: { resource } }">
|
<template #resource-label="{ scope: { resource } }">
|
||||||
<div class="col-12 .col-md-auto">
|
<div class="col-12 .col-md-auto">
|
||||||
{{ resource.displayName }}
|
{{ resource.displayName }}
|
||||||
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
<q-icon
|
||||||
|
v-if="resource.defects"
|
||||||
|
name="warning"
|
||||||
|
color="warning" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</q-calendar-resource>
|
</q-calendar-resource>
|
||||||
@@ -97,9 +102,10 @@
|
|||||||
dense
|
dense
|
||||||
@update:model-value="onUpdateDuration"
|
@update:model-value="onUpdateDuration"
|
||||||
label="Duration (hours)"
|
label="Duration (hours)"
|
||||||
stack-label
|
stack-label>
|
||||||
><template v-slot:append><q-icon name="timelapse" /></template></q-select
|
<template v-slot:append><q-icon name="timelapse" /></template>
|
||||||
></q-card-section>
|
</q-select>
|
||||||
|
</q-card-section>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
@@ -107,16 +113,18 @@ import {
|
|||||||
QCalendarResource,
|
QCalendarResource,
|
||||||
TimestampOrNull,
|
TimestampOrNull,
|
||||||
today,
|
today,
|
||||||
parseDate,
|
|
||||||
parseTimestamp,
|
parseTimestamp,
|
||||||
addToDate,
|
addToDate,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
|
parsed,
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
import { useScheduleStore } from 'src/stores/schedule';
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
import { date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { StatusTypes } from 'src/stores/schedule';
|
import type { StatusTypes } from 'src/stores/schedule.types';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
event: object;
|
event: object;
|
||||||
@@ -145,8 +153,8 @@ const statusLookup = {
|
|||||||
|
|
||||||
const calendar = ref();
|
const calendar = ref();
|
||||||
const boatStore = useBoatStore();
|
const boatStore = useBoatStore();
|
||||||
const scheduleStore = useScheduleStore();
|
const reservationStore = useReservationStore();
|
||||||
const selectedDate = ref(today());
|
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||||
const duration = ref(1);
|
const duration = ref(1);
|
||||||
|
|
||||||
const formattedMonth = computed(() => {
|
const formattedMonth = computed(() => {
|
||||||
@@ -171,14 +179,14 @@ function monthFormatter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEvents(scope: ResourceIntervalScope) {
|
function getEvents(scope: ResourceIntervalScope) {
|
||||||
const resourceEvents = scheduleStore.getBoatReservations(
|
const resourceEvents = reservationStore.getReservationsByDate(
|
||||||
date.extractDate(selectedDate.value, 'YYYY-MM-DD'),
|
selectedDate.value,
|
||||||
scope.resource.$id
|
scope.resource.$id
|
||||||
);
|
);
|
||||||
|
|
||||||
return resourceEvents.map((event) => {
|
return resourceEvents.value.map((event) => {
|
||||||
return {
|
return {
|
||||||
left: scope.timeStartPosX(parseDate(event.start)),
|
left: scope.timeStartPosX(parsed(event.start)),
|
||||||
width: scope.timeDurationWidth(
|
width: scope.timeDurationWidth(
|
||||||
date.getDateDiff(event.end, event.start, 'minutes')
|
date.getDateDiff(event.end, event.start, 'minutes')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,21 +7,22 @@
|
|||||||
round
|
round
|
||||||
icon="menu"
|
icon="menu"
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
@click="toggleLeftDrawer"
|
@click="toggleLeftDrawer" />
|
||||||
/>
|
|
||||||
|
|
||||||
<q-toolbar-title> {{ pageTitle }} </q-toolbar-title>
|
<q-toolbar-title>{{ pageTitle }}</q-toolbar-title>
|
||||||
<q-tabs shrink>
|
<q-space />
|
||||||
<q-tab> </q-tab>
|
<div>v{{ APP_VERSION }}</div>
|
||||||
</q-tabs>
|
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
<LeftDrawer :drawer="leftDrawerOpen" @drawer-toggle="toggleLeftDrawer" />
|
<LeftDrawer
|
||||||
|
:drawer="leftDrawerOpen"
|
||||||
|
@drawer-toggle="toggleLeftDrawer" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import LeftDrawer from 'components/LeftDrawer.vue';
|
import LeftDrawer from 'components/LeftDrawer.vue';
|
||||||
|
import { APP_VERSION } from 'src/version';
|
||||||
|
|
||||||
const leftDrawerOpen = ref(false);
|
const leftDrawerOpen = ref(false);
|
||||||
function toggleLeftDrawer() {
|
function toggleLeftDrawer() {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-img v-if="boat?.iconsrc" :src="boat?.iconsrc" />
|
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
|
||||||
<q-icon v-else name="sailing" />
|
<q-icon v-else name="sailing" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
<div v-if="boats">
|
||||||
<q-card-section>
|
<q-card
|
||||||
<q-img :src="boat.imgsrc" :fit="'scale-down'">
|
v-for="boat in boats"
|
||||||
<div class="row absolute-top">
|
:key="boat.id"
|
||||||
<div class="col text-h5 text-left">{{ boat.name }}</div>
|
class="q-ma-sm">
|
||||||
<div class="col text-right">{{ boat.class }}</div>
|
<q-card-section>
|
||||||
</div>
|
<q-img
|
||||||
</q-img>
|
:src="boat.imgSrc"
|
||||||
</q-card-section>
|
:fit="'scale-down'">
|
||||||
|
<div class="row absolute-top">
|
||||||
|
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||||
|
<div class="col text-right">{{ boat.class }}</div>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-card-actions align="evenly">
|
<!-- <q-card-actions align="evenly">
|
||||||
<q-btn flat>Info</q-btn>
|
<q-btn flat>Info</q-btn>
|
||||||
<q-btn flat>Book</q-btn>
|
<q-btn flat>Book</q-btn>
|
||||||
<q-btn flat>Check-Out</q-btn>
|
<q-btn flat>Check-Out</q-btn>
|
||||||
<q-btn flat>Check-In</q-btn>
|
<q-btn flat>Check-In</q-btn>
|
||||||
</q-card-actions>
|
</q-card-actions> -->
|
||||||
</q-card>
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
173
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
173
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<q-expansion-item
|
||||||
|
expand-icon-toggle
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, template)"
|
||||||
|
v-model="expanded">
|
||||||
|
<template v-slot:header>
|
||||||
|
<q-item-section>
|
||||||
|
<q-input
|
||||||
|
label="Template name"
|
||||||
|
:borderless="!edit"
|
||||||
|
dense
|
||||||
|
v-model="template.name"
|
||||||
|
v-if="edit" />
|
||||||
|
<q-item-label
|
||||||
|
v-if="!edit"
|
||||||
|
class="cursor-pointer">
|
||||||
|
{{ template.name }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</template>
|
||||||
|
<q-card flat>
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-card-section class="q-pt-xs">
|
||||||
|
<q-list dense>
|
||||||
|
<q-item
|
||||||
|
v-for="(item, index) in template.timeTuples"
|
||||||
|
:key="item[0]">
|
||||||
|
<q-input
|
||||||
|
class="q-mx-sm"
|
||||||
|
dense
|
||||||
|
v-model="item[0]"
|
||||||
|
type="time"
|
||||||
|
label="Start"
|
||||||
|
:borderless="!edit"
|
||||||
|
:readonly="!edit" />
|
||||||
|
<q-input
|
||||||
|
class="q-mx-sm"
|
||||||
|
dense
|
||||||
|
v-model="item[1]"
|
||||||
|
type="time"
|
||||||
|
label="End"
|
||||||
|
:borderless="!edit"
|
||||||
|
:readonly="!edit">
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="delete"
|
||||||
|
@click="template.timeTuples.splice(index, 1)" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
label="Add interval"
|
||||||
|
@click="template.timeTuples.push(['00:00', '00:00'])" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions vertical>
|
||||||
|
<q-btn
|
||||||
|
v-if="!edit"
|
||||||
|
color="primary"
|
||||||
|
icon="edit"
|
||||||
|
label="Edit"
|
||||||
|
@click="toggleEdit" />
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
color="primary"
|
||||||
|
icon="save"
|
||||||
|
label="Save"
|
||||||
|
@click="saveTemplate($event, template)" />
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
color="secondary"
|
||||||
|
icon="cancel"
|
||||||
|
label="Cancel"
|
||||||
|
@click="revert" />
|
||||||
|
<q-btn
|
||||||
|
color="negative"
|
||||||
|
icon="delete"
|
||||||
|
label="Delete"
|
||||||
|
v-if="template.$id !== ''"
|
||||||
|
@click="deleteTemplate($event, template)" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-dialog v-model="alert">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Overlapped blocks!</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
<q-chip
|
||||||
|
square
|
||||||
|
icon="schedule"
|
||||||
|
v-for="item in overlapped"
|
||||||
|
:key="item.start">
|
||||||
|
{{ item.start }}-{{ item.end }}
|
||||||
|
</q-chip>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="OK"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||||
|
import { IntervalTemplate } from 'src/stores/schedule.types';
|
||||||
|
import { copyIntervalTemplate, timeTuplesOverlapped } from 'src/utils/schedule';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
const alert = ref(false);
|
||||||
|
const overlapped = ref();
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||||
|
const edit = ref(props.edit);
|
||||||
|
const expanded = ref(props.edit);
|
||||||
|
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||||
|
|
||||||
|
const revert = () => {
|
||||||
|
template.value = copyIntervalTemplate(props.modelValue);
|
||||||
|
edit.value = false;
|
||||||
|
emit('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEdit = () => {
|
||||||
|
edit.value = !edit.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTemplate = (
|
||||||
|
event: Event,
|
||||||
|
template: IntervalTemplate | undefined
|
||||||
|
) => {
|
||||||
|
if (template?.$id) intervalTemplateStore.deleteIntervalTemplate(template.$id);
|
||||||
|
};
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, template: IntervalTemplate) {
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('ID', template.$id || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saveTemplate = (evt: Event, template: IntervalTemplate | undefined) => {
|
||||||
|
if (!template) return false;
|
||||||
|
overlapped.value = timeTuplesOverlapped(template.timeTuples);
|
||||||
|
if (overlapped.value.length > 0) {
|
||||||
|
alert.value = true;
|
||||||
|
} else {
|
||||||
|
edit.value = false;
|
||||||
|
if (template.$id && template.$id !== 'unsaved') {
|
||||||
|
intervalTemplateStore.updateIntervalTemplate(template, template.$id);
|
||||||
|
} else {
|
||||||
|
intervalTemplateStore.createIntervalTemplate(template);
|
||||||
|
emit('saved');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
19
src/components/scheduling/NavigationBar.vue
Normal file
19
src/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div class="q-pa-md q-gutter-sm row">
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">
|
||||||
|
Today
|
||||||
|
</q-btn>
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">
|
||||||
|
< Prev
|
||||||
|
</q-btn>
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">
|
||||||
|
Next >
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits(['today', 'prev', 'next']);
|
||||||
|
</script>
|
||||||
116
src/components/scheduling/ReservationCardComponent.vue
Normal file
116
src/components/scheduling/ReservationCardComponent.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<q-card
|
||||||
|
bordered
|
||||||
|
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
|
||||||
|
class="q-ma-md">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-h6">
|
||||||
|
{{ boatStore.getBoatById(reservation.resource)?.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-subtitle2">
|
||||||
|
<p>
|
||||||
|
Start: {{ formatDate(reservation.start) }}
|
||||||
|
<br />
|
||||||
|
End: {{ formatDate(reservation.end) }}
|
||||||
|
<br />
|
||||||
|
Type: {{ reservation.reason }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
color="grey-7"
|
||||||
|
round
|
||||||
|
flat
|
||||||
|
icon="more_vert">
|
||||||
|
<q-menu
|
||||||
|
cover
|
||||||
|
auto-close>
|
||||||
|
<q-list>
|
||||||
|
<q-item clickable>
|
||||||
|
<q-item-section>remove card</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable>
|
||||||
|
<q-item-section>send feedback</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable>
|
||||||
|
<q-item-section>share</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- <q-card-section>Some more information here...</q-card-section> -->
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-card-actions v-if="!isPast(reservation.end)">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
:to="{ name: 'edit-reservation', params: { id: reservation.$id } }">
|
||||||
|
Modify
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
@click="cancelReservation()">
|
||||||
|
Delete
|
||||||
|
</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<q-dialog v-model="cancelDialog">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="row items-center">
|
||||||
|
<q-avatar
|
||||||
|
icon="warning"
|
||||||
|
color="negative"
|
||||||
|
text-color="white" />
|
||||||
|
<span class="q-ml-md">Warning!</span>
|
||||||
|
<p class="q-pt-md">
|
||||||
|
This will delete your reservation for
|
||||||
|
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
|
||||||
|
{{ formatDate(reservation?.start) }}
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
label="Cancel"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
label="Delete"
|
||||||
|
color="negative"
|
||||||
|
@click="reservationStore.deleteReservation(reservation)"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import type { Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { formatDate, isPast } from 'src/utils/schedule';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const cancelDialog = ref(false);
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
|
||||||
|
const reservation = defineModel<Reservation>({ required: true });
|
||||||
|
|
||||||
|
const cancelReservation = () => {
|
||||||
|
cancelDialog.value = true;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,67 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<CalendarHeaderComponent v-model="selectedDate" />
|
<q-card>
|
||||||
<div class="boat-schedule-table-component">
|
<q-toolbar>
|
||||||
<QCalendarDay
|
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||||
ref="calendar"
|
<q-btn
|
||||||
class="q-pa-xs"
|
icon="close"
|
||||||
flat
|
flat
|
||||||
animated
|
round
|
||||||
dense
|
dense
|
||||||
:disabled-before="disabledBefore"
|
v-close-popup />
|
||||||
interval-height="24"
|
</q-toolbar>
|
||||||
interval-count="18"
|
<q-separator />
|
||||||
interval-start="06:00"
|
<CalendarHeaderComponent v-model="selectedDate" />
|
||||||
:short-interval-label="true"
|
<div class="boat-schedule-table-component">
|
||||||
v-model="selectedDate"
|
<QCalendarDay
|
||||||
:column-count="boatData.length"
|
ref="calendar"
|
||||||
@change="changeEvent"
|
class="q-pa-xs"
|
||||||
v-touch-swipe.left.right="handleSwipe"
|
flat
|
||||||
>
|
animated
|
||||||
<template #head-day="{ scope }">
|
dense
|
||||||
<div style="text-align: center; font-weight: 800">
|
:disabled-before="disabledBefore"
|
||||||
{{ boatData[scope.columnIndex].displayName }}
|
interval-height="24"
|
||||||
</div>
|
interval-count="18"
|
||||||
</template>
|
interval-start="06:00"
|
||||||
|
:short-interval-label="true"
|
||||||
|
v-model="selectedDate"
|
||||||
|
:column-count="boats.length"
|
||||||
|
v-touch-swipe.left.right="handleSwipe">
|
||||||
|
<template #head-day="{ scope }">
|
||||||
|
<div style="text-align: center; font-weight: 800">
|
||||||
|
{{ getBoatDisplayName(scope) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #day-body="{ scope }">
|
<template #day-body="{ scope }">
|
||||||
<div
|
|
||||||
v-for="block in boatData[scope.columnIndex].blocks"
|
|
||||||
:key="block.id"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="timeblock"
|
v-for="block in getAvailableIntervals(
|
||||||
:class="selectedBlock?.id === block.id ? 'selected' : ''"
|
scope.timestamp,
|
||||||
:style="
|
boats[scope.columnIndex]
|
||||||
blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)
|
).value"
|
||||||
"
|
:key="block.$id">
|
||||||
:id="block.id"
|
<div
|
||||||
@click="selectBlock($event, scope, block)"
|
class="timeblock"
|
||||||
>
|
:disabled="beforeNow(new Date(block.end))"
|
||||||
{{ boatData[scope.columnIndex].name }}<br />
|
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||||
{{ selectedBlock?.id === block.id ? 'Selected' : 'Available' }}
|
:style="
|
||||||
|
blockStyles(
|
||||||
|
block,
|
||||||
|
scope.timeStartPos,
|
||||||
|
scope.timeDurationHeight
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:id="block.id"
|
||||||
|
@click="selectBlock($event, scope, block)">
|
||||||
|
{{ boats[scope.columnIndex].name }}
|
||||||
|
<br />
|
||||||
|
{{
|
||||||
|
selectedBlock?.$id === block.$id ? 'Selected' : 'Available'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="r in boatData[scope.columnIndex].reservations"
|
|
||||||
:key="r.id"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="reservation"
|
v-for="reservation in getBoatReservations(scope)"
|
||||||
:style="
|
:key="reservation.$id">
|
||||||
reservationStyles(
|
<div
|
||||||
r,
|
class="reservation column"
|
||||||
scope.timeStartPos,
|
:style="
|
||||||
scope.timeDurationHeight
|
reservationStyles(
|
||||||
)
|
reservation,
|
||||||
"
|
scope.timeStartPos,
|
||||||
>
|
scope.timeDurationHeight
|
||||||
{{ r.user }}
|
)
|
||||||
|
">
|
||||||
|
{{ getUserName(reservation.user) || 'loading...' }}
|
||||||
|
<br />
|
||||||
|
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</QCalendarDay>
|
||||||
</QCalendarDay>
|
</div>
|
||||||
</div>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -71,33 +90,40 @@ import {
|
|||||||
Timestamp,
|
Timestamp,
|
||||||
diffTimestamp,
|
diffTimestamp,
|
||||||
today,
|
today,
|
||||||
parsed,
|
|
||||||
parseTimestamp,
|
parseTimestamp,
|
||||||
parseDate,
|
parseDate,
|
||||||
addToDate,
|
addToDate,
|
||||||
makeDateTime,
|
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||||
|
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
import { useScheduleStore } from 'src/stores/schedule';
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
import { Reservation, Timeblock } from 'src/stores/schedule.types';
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
import { date } from 'quasar';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
|
||||||
interface BoatData extends Boat {
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
blocks?: Timeblock[];
|
const reservationStore = useReservationStore();
|
||||||
reservations?: Reservation[];
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
}
|
const selectedBlock = defineModel<Interval | null>();
|
||||||
|
|
||||||
const scheduleStore = useScheduleStore();
|
|
||||||
const boatStore = useBoatStore();
|
|
||||||
const selectedBlock = defineModel<Timeblock | null>();
|
|
||||||
const selectedDate = ref(today());
|
const selectedDate = ref(today());
|
||||||
|
const { getAvailableIntervals } = useIntervalStore();
|
||||||
const boatData = ref<BoatData[]>(boatStore.boats);
|
|
||||||
|
|
||||||
const calendar = ref<QCalendarDay | null>(null);
|
const calendar = ref<QCalendarDay | null>(null);
|
||||||
|
const now = ref(new Date());
|
||||||
|
let intervalId: string | number | NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await useBoatStore().fetchBoats();
|
||||||
|
await intervalTemplateStore.fetchIntervalTemplates();
|
||||||
|
intervalId = setInterval(function () {
|
||||||
|
now.value = new Date();
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => clearInterval(intervalId));
|
||||||
|
|
||||||
function handleSwipe({ ...event }) {
|
function handleSwipe({ ...event }) {
|
||||||
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||||
@@ -108,26 +134,40 @@ function reservationStyles(
|
|||||||
timeDurationHeight: (d: number) => string
|
timeDurationHeight: (d: number) => string
|
||||||
) {
|
) {
|
||||||
return genericBlockStyle(
|
return genericBlockStyle(
|
||||||
parseDate(reservation.start) as Timestamp,
|
parseDate(new Date(reservation.start)) as Timestamp,
|
||||||
parseDate(reservation.end) as Timestamp,
|
parseDate(new Date(reservation.end)) as Timestamp,
|
||||||
timeStartPos,
|
timeStartPos,
|
||||||
timeDurationHeight
|
timeDurationHeight
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserName(userid: string) {
|
||||||
|
return useAuthStore().getUserNameById(userid);
|
||||||
|
}
|
||||||
|
|
||||||
function blockStyles(
|
function blockStyles(
|
||||||
block: Timeblock,
|
block: Interval,
|
||||||
timeStartPos: (t: string) => string,
|
timeStartPos: (t: string) => string,
|
||||||
timeDurationHeight: (d: number) => string
|
timeDurationHeight: (d: number) => string
|
||||||
) {
|
) {
|
||||||
return genericBlockStyle(
|
return genericBlockStyle(
|
||||||
parsed(block.start) as Timestamp,
|
parseDate(new Date(block.start)) as Timestamp,
|
||||||
parsed(block.end) as Timestamp,
|
parseDate(new Date(block.end)) as Timestamp,
|
||||||
timeStartPos,
|
timeStartPos,
|
||||||
timeDurationHeight
|
timeDurationHeight
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBoatDisplayName(scope: DayBodyScope) {
|
||||||
|
return boats && boats.value[scope.columnIndex]
|
||||||
|
? boats.value[scope.columnIndex].displayName
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeNow(time: Date) {
|
||||||
|
return time < now.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
function genericBlockStyle(
|
function genericBlockStyle(
|
||||||
start: Timestamp,
|
start: Timestamp,
|
||||||
end: Timestamp,
|
end: Timestamp,
|
||||||
@@ -148,9 +188,6 @@ function genericBlockStyle(
|
|||||||
1 +
|
1 +
|
||||||
'px';
|
'px';
|
||||||
}
|
}
|
||||||
// if (selectedBlock.value?.id === block.id) {
|
|
||||||
// s.opacity = '1.0';
|
|
||||||
// }
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,40 +198,24 @@ interface DayBodyScope {
|
|||||||
timestamp: Timestamp;
|
timestamp: Timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Timeblock) {
|
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||||
// TODO: Disable blocks before today with updateDisabled and/or comparison
|
if (scope.timestamp.disabled || new Date(block.end) < new Date())
|
||||||
selectedBlock.value === block
|
return false;
|
||||||
? (selectedBlock.value = null)
|
selectedBlock.value = block;
|
||||||
: (selectedBlock.value = block);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeEvent({ start }: { start: string }) {
|
const boatReservations = computed((): Record<string, Reservation[]> => {
|
||||||
const newBlocks = scheduleStore.getTimeblocksForDate(start);
|
return reservationStore
|
||||||
const reservations = scheduleStore.getBoatReservations(
|
.getReservationsByDate(selectedDate.value)
|
||||||
parsed(start) as Timestamp
|
.value.reduce((result, reservation) => {
|
||||||
);
|
if (!result[reservation.resource]) result[reservation.resource] = [];
|
||||||
boatData.value.map((boat) => {
|
result[reservation.resource].push(reservation);
|
||||||
boat.reservations = reservations.filter(
|
return result;
|
||||||
(reservation) => reservation.resource === boat
|
}, <Record<string, Reservation[]>>{});
|
||||||
);
|
});
|
||||||
boat.blocks = newBlocks.filter(
|
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||||
(block) =>
|
const boat = boats.value[scope.columnIndex];
|
||||||
block.boatId === boat.$id &&
|
return boat ? boatReservations.value[boat.$id] : [];
|
||||||
boat.reservations?.filter(
|
|
||||||
(r) =>
|
|
||||||
r.start <
|
|
||||||
date.addToDate(makeDateTime(parsed(block.end) as Timestamp), {
|
|
||||||
hours: 4,
|
|
||||||
}) &&
|
|
||||||
r.end >
|
|
||||||
date.addToDate(makeDateTime(parsed(block.start) as Timestamp), {
|
|
||||||
hours: 4,
|
|
||||||
})
|
|
||||||
).length == 0
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => calendar.value?.scrollToTime('09:00'), 100); // Should figure out why we need this setTimeout...
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledBefore = computed(() => {
|
const disabledBefore = computed(() => {
|
||||||
@@ -244,4 +265,7 @@ const disabledBefore = computed(() => {
|
|||||||
font-size: 0.8em
|
font-size: 0.8em
|
||||||
.q-calendar-day__day.q-current-day
|
.q-calendar-day__day.q-current-day
|
||||||
padding: 1px
|
padding: 1px
|
||||||
|
.q-calendar-day__head--days__column
|
||||||
|
background: $primary
|
||||||
|
color: white
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { ref, reactive, computed } from 'vue';
|
|||||||
|
|
||||||
const selectedDate = defineModel<string>();
|
const selectedDate = defineModel<string>();
|
||||||
|
|
||||||
const weekdays = reactive([0, 1, 2, 3, 4, 5, 6]),
|
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]),
|
||||||
locale = ref('en-CA'),
|
locale = ref('en-CA'),
|
||||||
monthFormatter = monthFormatterFunc(),
|
monthFormatter = monthFormatterFunc(),
|
||||||
dayFormatter = dayFormatterFunc(),
|
dayFormatter = dayFormatterFunc(),
|
||||||
@@ -124,8 +124,14 @@ function dayClass(day: Timestamp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function monthFormatterFunc() {
|
function monthFormatterFunc() {
|
||||||
const longOptions = { timeZone: 'UTC', month: 'long' };
|
const longOptions: Intl.DateTimeFormatOptions = {
|
||||||
const shortOptions = { timeZone: 'UTC', month: 'short' };
|
timeZone: 'UTC',
|
||||||
|
month: 'long',
|
||||||
|
};
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
month: 'short',
|
||||||
|
};
|
||||||
|
|
||||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||||
short ? shortOptions : longOptions
|
short ? shortOptions : longOptions
|
||||||
@@ -133,17 +139,28 @@ function monthFormatterFunc() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function weekdayFormatterFunc() {
|
function weekdayFormatterFunc() {
|
||||||
const longOptions = { timeZone: 'UTC', weekday: 'long' };
|
const longOptions: Intl.DateTimeFormatOptions = {
|
||||||
const shortOptions = { timeZone: 'UTC', weekday: 'short' };
|
timeZone: 'UTC',
|
||||||
|
weekday: 'long',
|
||||||
|
};
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
weekday: 'short',
|
||||||
|
};
|
||||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||||
short ? shortOptions : longOptions
|
short ? shortOptions : longOptions
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayFormatterFunc() {
|
function dayFormatterFunc() {
|
||||||
const longOptions = { timeZone: 'UTC', day: '2-digit' };
|
const longOptions: Intl.DateTimeFormatOptions = {
|
||||||
const shortOptions = { timeZone: 'UTC', day: 'numeric' };
|
timeZone: 'UTC',
|
||||||
|
day: '2-digit',
|
||||||
|
};
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
day: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||||
short ? shortOptions : longOptions
|
short ? shortOptions : longOptions
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
import { defineProps } from 'vue';
|
import { defineProps } from 'vue';
|
||||||
import type { Task } from 'src/stores/task';
|
import type { Task } from 'src/stores/task';
|
||||||
|
|
||||||
const props = defineProps<{ task: Task }>();
|
defineProps<{ task: Task }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
|
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
|
||||||
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
|
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
|
||||||
import { date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
|
||||||
const props = defineProps<{ taskId?: string }>();
|
const props = defineProps<{ taskId?: string }>();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
@@ -187,7 +187,7 @@ const targetTask = taskId && taskStore.tasks.find((t) => t.$id === taskId);
|
|||||||
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
|
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
|
||||||
|
|
||||||
let tasks = taskStore.tasks;
|
let tasks = taskStore.tasks;
|
||||||
const boatList = ref<Boat[]>(useBoatStore().boats);
|
const boatList = useBoatStore().boats;
|
||||||
|
|
||||||
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||||
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||||
@@ -252,14 +252,11 @@ const dateRule = (val: string) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
//console.log(modifiedTask);
|
|
||||||
try {
|
try {
|
||||||
if (modifiedTask.$id) {
|
if (modifiedTask.$id) {
|
||||||
await taskStore.updateTask(modifiedTask);
|
await taskStore.updateTask(modifiedTask);
|
||||||
console.log('Updated Task: ' + modifiedTask.$id);
|
|
||||||
} else {
|
} else {
|
||||||
await taskStore.addTask(modifiedTask);
|
await taskStore.addTask(modifiedTask);
|
||||||
console.log('Created Task');
|
|
||||||
}
|
}
|
||||||
router.go(-1);
|
router.go(-1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import type { Task } from 'src/stores/task';
|
import type { Task } from 'src/stores/task';
|
||||||
import TaskCardComponent from './TaskCardComponent.vue';
|
import TaskCardComponent from './TaskCardComponent.vue';
|
||||||
|
|
||||||
const props = defineProps<{ tasks: Task[] }>();
|
defineProps<{ tasks: Task[] }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
<q-separator />
|
<q-separator />
|
||||||
<q-list dense>
|
<q-list dense>
|
||||||
<q-item
|
<q-item
|
||||||
v-for="col in props.cols.filter((col) => col.name !== 'desc')"
|
v-for="col in props.cols.filter((col:Boat) => col.name !== 'desc')"
|
||||||
:key="col.name"
|
:key="col.name"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
@@ -215,10 +215,8 @@
|
|||||||
import { computed, defineProps, ref } from 'vue';
|
import { computed, defineProps, ref } from 'vue';
|
||||||
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
|
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
|
||||||
import { QTableProps, date, useQuasar } from 'quasar';
|
import { QTableProps, date, useQuasar } from 'quasar';
|
||||||
import { useBoatStore } from 'src/stores/boat';
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const selected = ref([]);
|
const selected = ref([]);
|
||||||
const loading = ref(false); // Placeholder
|
const loading = ref(false); // Placeholder
|
||||||
const fabShow = ref(false);
|
const fabShow = ref(false);
|
||||||
@@ -301,44 +299,51 @@ const columns = <QTableProps['columns']>[
|
|||||||
{ name: 'actions', align: 'center', label: 'Actions', field: '$id' },
|
{ name: 'actions', align: 'center', label: 'Actions', field: '$id' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const props = defineProps<{ tasks: Task[] }>();
|
defineProps<{ tasks: Task[] }>();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
|
|
||||||
const searchFilter = ref({
|
interface SearchObject {
|
||||||
|
title: string;
|
||||||
|
skillTags: SkillTag[];
|
||||||
|
taskTags: TaskTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilter = ref<SearchObject>({
|
||||||
title: '',
|
title: '',
|
||||||
skillTags: <SkillTag[]>[],
|
skillTags: [],
|
||||||
taskTags: <TaskTag[]>[],
|
taskTags: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||||
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||||
|
|
||||||
function onRowClick(evt: Event, row: Task) {
|
// function onRowClick(evt: Event, row: Task) {
|
||||||
router.push({ name: 'edit-task', params: { id: row.$id } });
|
// router.push({ name: 'edit-task', params: { id: row.$id } });
|
||||||
}
|
// }
|
||||||
// TODO: Implement server side search
|
// TODO: Implement server side search
|
||||||
const filterRows = computed(
|
const filterRows = computed(
|
||||||
() => (rows: readonly Task[], terms: any, cols: any, cellValueFn: any) => {
|
() => (rows: readonly Task[], terms: SearchObject) => {
|
||||||
let result = rows;
|
return rows
|
||||||
result = rows.filter((row) =>
|
.filter((row) =>
|
||||||
terms.title
|
terms.title
|
||||||
? row.title.toLowerCase().includes(terms.title.toLowerCase())
|
? row.title.toLowerCase().includes(terms.title.toLowerCase())
|
||||||
: true
|
: true
|
||||||
);
|
)
|
||||||
result = result.filter((row) =>
|
.filter((row) =>
|
||||||
terms.skillTags && terms.skillTags.length > 0
|
terms.skillTags && terms.skillTags.length > 0
|
||||||
? row.required_skills.some((req_skill) =>
|
? row.required_skills.some((req_skill) =>
|
||||||
terms.skillTags.map((t) => t.$id).includes(req_skill)
|
terms.skillTags.map((t) => t.$id).includes(req_skill)
|
||||||
)
|
)
|
||||||
: true
|
: true
|
||||||
);
|
)
|
||||||
result = result.filter((row) =>
|
.filter((row) =>
|
||||||
terms.taskTags && terms.taskTags.length > 0
|
terms.taskTags && terms.taskTags.length > 0
|
||||||
? row.tags.some((tag) => terms.taskTags.map((t) => t.$id).includes(tag))
|
? row.tags.some((tag) =>
|
||||||
: true
|
terms.taskTags.map((t) => t.$id).includes(tag)
|
||||||
);
|
)
|
||||||
return result;
|
: true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
|
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
|
||||||
import { ref } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { useBoatStore } from 'src/stores/boat';
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
const boats = ref(useBoatStore().boats);
|
const boatStore = useBoatStore();
|
||||||
|
const { boats } = storeToRefs(boatStore);
|
||||||
|
|
||||||
|
onMounted(() => boatStore.fetchBoats());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
|
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
|
||||||
<q-list class="full-width mobile-only">
|
<q-list class="full-width mobile-only">
|
||||||
<q-item
|
<q-item
|
||||||
v-for="link in links.filter((x) => x.front_links)"
|
v-for="link in enabledLinks.filter((x) => x.front_links)"
|
||||||
:key="link.name"
|
:key="link.name"
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -23,6 +23,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { links } from 'src/router/navlinks.js';
|
import { enabledLinks } from 'src/router/navlinks.js';
|
||||||
import ToolbarComponent from 'components/ToolbarComponent.vue';
|
import ToolbarComponent from 'components/ToolbarComponent.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,48 +3,57 @@
|
|||||||
<q-page-container>
|
<q-page-container>
|
||||||
<q-page class="flex bg-image flex-center">
|
<q-page class="flex bg-image flex-center">
|
||||||
<q-card
|
<q-card
|
||||||
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }"
|
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
>
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-img fit="scale-down" src="~assets/oysqn_logo.png" />
|
<q-img
|
||||||
|
fit="scale-down"
|
||||||
|
src="~assets/oysqn_logo.png" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-center q-pt-sm">
|
<div class="text-center q-pt-sm">
|
||||||
<div class="col text-h6">Log in</div>
|
<div class="col text-h6">Log in</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-form @keydown.enter.prevent="doTokenLogin">
|
||||||
<q-form class="q-gutter-md">
|
<q-card-section class="q-gutter-md">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="email"
|
v-model="email"
|
||||||
label="E-Mail"
|
label="E-Mail"
|
||||||
type="email"
|
type="email"
|
||||||
color="darkblue"
|
color="darkblue"
|
||||||
filled
|
filled></q-input>
|
||||||
></q-input>
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="password"
|
v-if="userId"
|
||||||
label="Password"
|
v-model="token"
|
||||||
type="password"
|
label="6-digit code"
|
||||||
|
type="number"
|
||||||
color="darkblue"
|
color="darkblue"
|
||||||
filled
|
filled></q-input>
|
||||||
></q-input>
|
</q-card-section>
|
||||||
|
</q-form>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<div class="row justify-center q-ma-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
type="submit"
|
|
||||||
@click="login(email, password)"
|
|
||||||
label="Login"
|
|
||||||
color="primary"
|
|
||||||
></q-btn>
|
|
||||||
<!-- <q-btn
|
|
||||||
type="button"
|
type="button"
|
||||||
@click="register"
|
@click="doTokenLogin"
|
||||||
color="secondary"
|
color="primary"
|
||||||
label="Register"
|
label="Login with E-mail"
|
||||||
|
style="width: 300px" />
|
||||||
|
</div>
|
||||||
|
<div class="row justify-center q-ma-sm">
|
||||||
|
<GoogleOauthComponent />
|
||||||
|
</div>
|
||||||
|
<div class="row justify-center q-ma-sm">
|
||||||
|
<DiscordOauthComponent />
|
||||||
|
</div>
|
||||||
|
<div class="row justify-center">
|
||||||
|
<q-btn
|
||||||
flat
|
flat
|
||||||
></q-btn> -->
|
color="secondary"
|
||||||
</q-form>
|
to="/pwreset"
|
||||||
|
label="Forgot Password?" />
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section><GoogleOauthComponent /></q-card-section>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
@@ -68,9 +77,77 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { login } from 'boot/appwrite';
|
|
||||||
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
||||||
|
import DiscordOauthComponent from 'src/components/DiscordOauthComponent.vue';
|
||||||
|
import { Dialog, Notify } from 'quasar';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { AppwriteException } from 'appwrite';
|
||||||
|
import { APP_VERSION } from 'src/version.js';
|
||||||
|
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const token = ref('');
|
||||||
|
const userId = ref();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
console.log('version:' + APP_VERSION);
|
||||||
|
|
||||||
|
const doTokenLogin = async () => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (!userId.value) {
|
||||||
|
try {
|
||||||
|
const sessionToken = await authStore.createTokenSession(email.value);
|
||||||
|
userId.value = sessionToken.userId;
|
||||||
|
Dialog.create({ message: 'Check your e-mail for your login code.' });
|
||||||
|
} catch (e) {
|
||||||
|
Dialog.create({
|
||||||
|
message: 'An error occurred. Please ask for help in Discord',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const notification = Notify.create({
|
||||||
|
type: 'primary',
|
||||||
|
position: 'top',
|
||||||
|
spinner: true,
|
||||||
|
message: 'Logging you in...',
|
||||||
|
timeout: 8000,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await authStore.tokenLogin(userId.value, token.value);
|
||||||
|
notification({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Logged in!',
|
||||||
|
timeout: 2000,
|
||||||
|
spinner: false,
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
router.replace({ name: 'index' });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof AppwriteException) {
|
||||||
|
if (error.type === 'user_session_already_exists') {
|
||||||
|
useRouter().replace({ name: 'index' });
|
||||||
|
notification({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Already Logged in!',
|
||||||
|
timeout: 2000,
|
||||||
|
spinner: false,
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dialog.create({
|
||||||
|
title: 'Login Error!',
|
||||||
|
message: error.message,
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
notification({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Login failed.',
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,163 +1,173 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-layout>
|
||||||
<h1>Privacy Policy for bab.toal.ca</h1>
|
<q-page-container>
|
||||||
|
<q-page padding>
|
||||||
|
<h1>Privacy Policy for Undock.ca</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
At OYS BAB Test, accessible from https://bab.toal.ca, one of our main
|
At Undock, accessible from https://undock.ca, one of our main
|
||||||
priorities is the privacy of our visitors. This Privacy Policy document
|
priorities is the privacy of our visitors. This Privacy Policy
|
||||||
contains types of information that is collected and recorded by OYS BAB
|
document contains types of information that is collected and recorded
|
||||||
Test and how we use it.
|
by Undock and how we use it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
If you have additional questions or require more information about our
|
If you have additional questions or require more information about our
|
||||||
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
|
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
|
||||||
generated with the help of
|
generated with the help of
|
||||||
<a href="https://www.gdprprivacypolicy.net/"
|
<a href="https://www.gdprprivacypolicy.net/">
|
||||||
>GDPR Privacy Policy Generator</a
|
GDPR Privacy Policy Generator
|
||||||
>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>General Data Protection Regulation (GDPR)</h2>
|
<h2>General Data Protection Regulation (GDPR)</h2>
|
||||||
<p>We are a Data Controller of your information.</p>
|
<p>We are a Data Controller of your information.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca legal basis for collecting and using the personal information
|
Undock's legal basis for collecting and using the personal information
|
||||||
described in this Privacy Policy depends on the Personal Information we
|
described in this Privacy Policy depends on the Personal Information
|
||||||
collect and the specific context in which we collect the information:
|
we collect and the specific context in which we collect the
|
||||||
</p>
|
information:
|
||||||
<ul>
|
</p>
|
||||||
<li>bab.toal.ca needs to perform a contract with you</li>
|
<ul>
|
||||||
<li>You have given bab.toal.ca permission to do so</li>
|
<li>Undock needs to perform a contract with you</li>
|
||||||
<li>
|
<li>You have given Undock permission to do so</li>
|
||||||
Processing your personal information is in bab.toal.ca legitimate
|
<li>
|
||||||
interests
|
Processing your personal information is in Undock legitimate
|
||||||
</li>
|
interests
|
||||||
<li>bab.toal.ca needs to comply with the law</li>
|
</li>
|
||||||
</ul>
|
<li>Undock needs to comply with the law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca will retain your personal information only for as long as is
|
Undock will retain your personal information only for as long as is
|
||||||
necessary for the purposes set out in this Privacy Policy. We will retain
|
necessary for the purposes set out in this Privacy Policy. We will
|
||||||
and use your information to the extent necessary to comply with our legal
|
retain and use your information to the extent necessary to comply with
|
||||||
obligations, resolve disputes, and enforce our policies.
|
our legal obligations, resolve disputes, and enforce our policies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
If you are a resident of the European Economic Area (EEA), you have
|
If you are a resident of the European Economic Area (EEA), you have
|
||||||
certain data protection rights. If you wish to be informed what Personal
|
certain data protection rights. If you wish to be informed what
|
||||||
Information we hold about you and if you want it to be removed from our
|
Personal Information we hold about you and if you want it to be
|
||||||
systems, please contact us.
|
removed from our systems, please contact us.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
In certain circumstances, you have the following data protection rights:
|
In certain circumstances, you have the following data protection
|
||||||
</p>
|
rights:
|
||||||
<ul>
|
</p>
|
||||||
<li>
|
<ul>
|
||||||
The right to access, update or to delete the information we have on you.
|
<li>
|
||||||
</li>
|
The right to access, update or to delete the information we have on
|
||||||
<li>The right of rectification.</li>
|
you.
|
||||||
<li>The right to object.</li>
|
</li>
|
||||||
<li>The right of restriction.</li>
|
<li>The right of rectification.</li>
|
||||||
<li>The right to data portability</li>
|
<li>The right to object.</li>
|
||||||
<li>The right to withdraw consent</li>
|
<li>The right of restriction.</li>
|
||||||
</ul>
|
<li>The right to data portability</li>
|
||||||
|
<li>The right to withdraw consent</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>Log Files</h2>
|
<h2>Log Files</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
OYS BAB Test follows a standard procedure of using log files. These files
|
Undock follows a standard procedure of using log files. These files
|
||||||
log visitors when they visit websites. All hosting companies do this and a
|
log visitors when they visit websites. All hosting companies do this
|
||||||
part of hosting services' analytics. The information collected by log
|
and a part of hosting services' analytics. The information collected
|
||||||
files include internet protocol (IP) addresses, browser type, Internet
|
by log files include internet protocol (IP) addresses, browser type,
|
||||||
Service Provider (ISP), date and time stamp, referring/exit pages, and
|
Internet Service Provider (ISP), date and time stamp, referring/exit
|
||||||
possibly the number of clicks. These are not linked to any information
|
pages, and possibly the number of clicks. These are not linked to any
|
||||||
that is personally identifiable. The purpose of the information is for
|
information that is personally identifiable. The purpose of the
|
||||||
analyzing trends, administering the site, tracking users' movement on the
|
information is for analyzing trends, administering the site, tracking
|
||||||
website, and gathering demographic information.
|
users' movement on the website, and gathering demographic information.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Cookies and Web Beacons</h2>
|
<h2>Cookies and Web Beacons</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Like any other website, OYS BAB Test uses "cookies". These cookies are
|
Like any other website, Undock uses "cookies". These cookies are used
|
||||||
used to store information including visitors' preferences, and the pages
|
to store information including visitors' preferences, and the pages on
|
||||||
on the website that the visitor accessed or visited. The information is
|
the website that the visitor accessed or visited. The information is
|
||||||
used to optimize the users' experience by customizing our web page content
|
used to optimize the users' experience by customizing our web page
|
||||||
based on visitors' browser type and/or other information.
|
content based on visitors' browser type and/or other information.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Privacy Policies</h2>
|
<h2>Privacy Policies</h2>
|
||||||
|
|
||||||
<P
|
<P>
|
||||||
>You may consult this list to find the Privacy Policy for each of the
|
You may consult this list to find the Privacy Policy for each of the
|
||||||
advertising partners of OYS BAB Test.</P
|
advertising partners of Undock.
|
||||||
>
|
</P>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Third-party ad servers or ad networks uses technologies like cookies,
|
Third-party ad servers or ad networks uses technologies like cookies,
|
||||||
JavaScript, or Web Beacons that are used in their respective
|
JavaScript, or Web Beacons that are used in their respective
|
||||||
advertisements and links that appear on OYS BAB Test, which are sent
|
advertisements and links that appear on Undock, which are sent
|
||||||
directly to users' browser. They automatically receive your IP address
|
directly to users' browser. They automatically receive your IP address
|
||||||
when this occurs. These technologies are used to measure the effectiveness
|
when this occurs. These technologies are used to measure the
|
||||||
of their advertising campaigns and/or to personalize the advertising
|
effectiveness of their advertising campaigns and/or to personalize the
|
||||||
content that you see on websites that you visit.
|
advertising content that you see on websites that you visit.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Note that OYS BAB Test has no access to or control over these cookies that
|
Note that Undock has no access to or control over these cookies that
|
||||||
are used by third-party advertisers.
|
are used by third-party advertisers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Third Party Privacy Policies</h2>
|
<h2>Third Party Privacy Policies</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
OYS BAB Test's Privacy Policy does not apply to other advertisers or
|
Undock's Privacy Policy does not apply to other advertisers or
|
||||||
websites. Thus, we are advising you to consult the respective Privacy
|
websites. Thus, we are advising you to consult the respective Privacy
|
||||||
Policies of these third-party ad servers for more detailed information. It
|
Policies of these third-party ad servers for more detailed
|
||||||
may include their practices and instructions about how to opt-out of
|
information. It may include their practices and instructions about how
|
||||||
certain options.
|
to opt-out of certain options.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
You can choose to disable cookies through your individual browser options.
|
You can choose to disable cookies through your individual browser
|
||||||
To know more detailed information about cookie management with specific
|
options. To know more detailed information about cookie management
|
||||||
web browsers, it can be found at the browsers' respective websites.
|
with specific web browsers, it can be found at the browsers'
|
||||||
</p>
|
respective websites.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Children's Information</h2>
|
<h2>Children's Information</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Another part of our priority is adding protection for children while using
|
Another part of our priority is adding protection for children while
|
||||||
the internet. We encourage parents and guardians to observe, participate
|
using the internet. We encourage parents and guardians to observe,
|
||||||
in, and/or monitor and guide their online activity.
|
participate in, and/or monitor and guide their online activity.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
OYS BAB Test does not knowingly collect any Personal Identifiable
|
Undock does not knowingly collect any Personal Identifiable
|
||||||
Information from children under the age of 13. If you think that your
|
Information from children under the age of 13. If you think that your
|
||||||
child provided this kind of information on our website, we strongly
|
child provided this kind of information on our website, we strongly
|
||||||
encourage you to contact us immediately and we will do our best efforts to
|
encourage you to contact us immediately and we will do our best
|
||||||
promptly remove such information from our records.
|
efforts to promptly remove such information from our records.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Online Privacy Policy Only</h2>
|
<h2>Online Privacy Policy Only</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Our Privacy Policy applies only to our online activities and is valid for
|
Our Privacy Policy applies only to our online activities and is valid
|
||||||
visitors to our website with regards to the information that they shared
|
for visitors to our website with regards to the information that they
|
||||||
and/or collect in OYS BAB Test. This policy is not applicable to any
|
shared and/or collect in Undock. This policy is not applicable to any
|
||||||
information collected offline or via channels other than this website.
|
information collected offline or via channels other than this website.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Consent</h2>
|
<h2>Consent</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
By using our website, you hereby consent to our Privacy Policy and agree
|
By using our website, you hereby consent to our Privacy Policy and
|
||||||
to its terms.
|
agree to its
|
||||||
</p>
|
<a href="/terms-of-service">terms</a>
|
||||||
</q-page>
|
.
|
||||||
|
</p>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts"></script>
|
||||||
|
|||||||
@@ -1,39 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<toolbar-component pageTitle="Member Profile" />
|
<toolbar-component pageTitle="Member Profile" />
|
||||||
<q-page padding>
|
<q-page
|
||||||
<q-list bordered>
|
padding
|
||||||
|
class="row">
|
||||||
|
<q-list class="col-sm-4 col-12">
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-item>
|
<q-item>
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-avatar icon="person" />
|
<q-avatar icon="person" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
Ricky Gervais
|
|
||||||
<q-item-label caption>Name</q-item-label>
|
<q-item-label caption>Name</q-item-label>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="newName"
|
||||||
|
@keydown.enter.prevent="editName"
|
||||||
|
v-if="newName !== undefined" />
|
||||||
|
<div v-else>
|
||||||
|
{{ authStore.currentUser?.name }}
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-btn
|
||||||
|
square
|
||||||
|
@click="editName"
|
||||||
|
:icon="newName !== undefined ? 'check' : 'edit'" />
|
||||||
|
<q-btn
|
||||||
|
v-if="newName !== undefined"
|
||||||
|
square
|
||||||
|
color="negative"
|
||||||
|
@click="newName = undefined"
|
||||||
|
icon="cancel" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item>
|
<q-item>
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-avatar icon="numbers" />
|
<q-avatar icon="email" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
123456
|
<q-item-label caption>E-mail</q-item-label>
|
||||||
<q-item-label caption>Member ID</q-item-label>
|
{{ authStore.currentUser?.email }}
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-item>
|
<q-item>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label overline>Certifications</q-item-label>
|
<q-item-label overline>Certifications</q-item-label>
|
||||||
<q-chip square icon="verified" color="green" text-color="white"
|
<div>
|
||||||
>J/27</q-chip
|
<q-chip
|
||||||
>
|
square
|
||||||
<q-chip square icon="verified" color="blue" text-color="white"
|
icon="verified"
|
||||||
>Capri25</q-chip
|
color="green"
|
||||||
>
|
text-color="white">
|
||||||
<q-chip square icon="verified" color="red" text-color="white"
|
J/27
|
||||||
>Night</q-chip
|
</q-chip>
|
||||||
>
|
<q-chip
|
||||||
|
square
|
||||||
|
icon="verified"
|
||||||
|
color="blue"
|
||||||
|
text-color="white">
|
||||||
|
Capri25
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
square
|
||||||
|
icon="verified"
|
||||||
|
color="grey-9"
|
||||||
|
text-color="white">
|
||||||
|
Night
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
@@ -42,4 +77,22 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const newName = ref();
|
||||||
|
|
||||||
|
const editName = async () => {
|
||||||
|
if (newName.value) {
|
||||||
|
try {
|
||||||
|
await authStore.updateName(newName.value);
|
||||||
|
newName.value = undefined;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newName.value = authStore.currentUser?.name || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
139
src/pages/ResetPassword.vue
Normal file
139
src/pages/ResetPassword.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex bg-image flex-center">
|
||||||
|
<q-card
|
||||||
|
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img
|
||||||
|
fit="scale-down"
|
||||||
|
src="~assets/oysqn_logo.png" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-pt-sm">
|
||||||
|
<div class="col text-h6">Reset Password</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-form v-if="!isPasswordResetLink()">
|
||||||
|
<q-card-section class="q-ma-sm">
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
color="darkblue"
|
||||||
|
@keydown.enter.prevent="resetPw"
|
||||||
|
filled></q-input>
|
||||||
|
<div class="text-caption q-py-md">
|
||||||
|
Enter your e-mail address. If we have an account with that
|
||||||
|
address on file, you will be e-mailed a link to reset your
|
||||||
|
password.
|
||||||
|
</div>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
type="button"
|
||||||
|
@click="resetPw"
|
||||||
|
label="Send Reset Link"
|
||||||
|
color="primary"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card-section>
|
||||||
|
</q-form>
|
||||||
|
<div v-else-if="validResetLink()">
|
||||||
|
<q-form
|
||||||
|
@submit="submitNewPw"
|
||||||
|
@keydown.enter.prevent="resetPw">
|
||||||
|
<NewPasswordComponent v-model="newPassword" />
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
label="Reset Password"
|
||||||
|
color="primary"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-form>
|
||||||
|
</div>
|
||||||
|
<q-card
|
||||||
|
v-else
|
||||||
|
class="text-center">
|
||||||
|
<span class="text-h5">Invalid reset link.</span>
|
||||||
|
</q-card>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-image {
|
||||||
|
background-image: url('/src/assets/oys_lighthouse.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position-x: center;
|
||||||
|
background-size: cover;
|
||||||
|
/* background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#ed232a 0%,
|
||||||
|
#ffffff 75%,
|
||||||
|
#14539a 100%
|
||||||
|
); */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { account, resetPassword } from 'boot/appwrite';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Dialog } from 'quasar';
|
||||||
|
import NewPasswordComponent from 'components/NewPasswordComponent.vue';
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const router = useRouter();
|
||||||
|
const newPassword = ref();
|
||||||
|
|
||||||
|
function validResetLink(): boolean {
|
||||||
|
const query = router.currentRoute.value.query;
|
||||||
|
const expire = query.expire ? new Date(query.expire + 'Z') : null;
|
||||||
|
return Boolean(
|
||||||
|
query && expire && query.secret && query.userId && new Date() < expire
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordResetLink() {
|
||||||
|
const query = router.currentRoute.value.query;
|
||||||
|
return query && query.secret && query.userId && query.expire;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitNewPw() {
|
||||||
|
const query = router.currentRoute.value.query;
|
||||||
|
if (newPassword.value) {
|
||||||
|
account
|
||||||
|
.updateRecovery(
|
||||||
|
query.userId as string,
|
||||||
|
query.secret as string,
|
||||||
|
newPassword.value
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
Dialog.create({ message: 'Password Changed!' }).onOk(() =>
|
||||||
|
router.replace('/login')
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
Dialog.create({
|
||||||
|
message: 'Password change failed! Error: ' + e.message,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Dialog.create({
|
||||||
|
message: 'Invalid password. Try again',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPw() {
|
||||||
|
resetPassword(email.value)
|
||||||
|
.then(() => router.replace('/login'))
|
||||||
|
.finally(() =>
|
||||||
|
Dialog.create({
|
||||||
|
message:
|
||||||
|
'If your address is in our system, you should receive an e-mail shortly.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
87
src/pages/SignupPage.vue
Normal file
87
src/pages/SignupPage.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex bg-image flex-center">
|
||||||
|
<q-card
|
||||||
|
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img
|
||||||
|
fit="scale-down"
|
||||||
|
src="~assets/oysqn_logo.png" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-pt-sm">
|
||||||
|
<div class="col text-h6">Sign Up</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-form>
|
||||||
|
<q-card-section class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
color="darkblue"
|
||||||
|
:rules="['email']"
|
||||||
|
filled></q-input>
|
||||||
|
<NewPasswordComponent v-model="password" />
|
||||||
|
<q-card-actions>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
type="button"
|
||||||
|
@click="doRegister"
|
||||||
|
label="Sign Up"
|
||||||
|
color="primary"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card-section>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-image {
|
||||||
|
background-image: url('/src/assets/oys_lighthouse.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position-x: center;
|
||||||
|
background-size: cover;
|
||||||
|
/* background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#ed232a 0%,
|
||||||
|
#ffffff 75%,
|
||||||
|
#14539a 100%
|
||||||
|
); */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import NewPasswordComponent from 'src/components/NewPasswordComponent.vue';
|
||||||
|
import { Dialog } from 'quasar';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { APP_VERSION } from 'src/version.js';
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
console.log('version:' + APP_VERSION);
|
||||||
|
|
||||||
|
const doRegister = async () => {
|
||||||
|
if (email.value && password.value) {
|
||||||
|
try {
|
||||||
|
await useAuthStore().register(email.value, password.value);
|
||||||
|
Dialog.create({
|
||||||
|
message: 'Account Created! Now log-in with your e-mail / password.',
|
||||||
|
}).onOk(() => router.replace('/login'));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
Dialog.create({
|
||||||
|
message: 'An error occurred. Please ask for support in Discord',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,119 +1,128 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-layout>
|
||||||
<h1>Website Terms and Conditions of Use</h1>
|
<q-page-container>
|
||||||
|
<q-page padding>
|
||||||
|
<h1>Website Terms and Conditions of Use</h1>
|
||||||
|
|
||||||
<h2>1. Terms</h2>
|
<h2>1. Terms</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
By accessing this Website, accessible from https://bab.toal.ca, you are
|
By accessing this Website, accessible from https://undock.ca, you are
|
||||||
agreeing to be bound by these Website Terms and Conditions of Use and
|
agreeing to be bound by these Website Terms and Conditions of Use and
|
||||||
agree that you are responsible for the agreement with any applicable local
|
agree that you are responsible for the agreement with any applicable
|
||||||
laws. If you disagree with any of these terms, you are prohibited from
|
local laws. If you disagree with any of these terms, you are
|
||||||
accessing this site. The materials contained in this Website are protected
|
prohibited from accessing this site. The materials contained in this
|
||||||
by copyright and trade mark law.
|
Website are protected by copyright and trade mark law.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>2. Use License</h2>
|
<h2>2. Use License</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Permission is granted to temporarily download one copy of the materials on
|
Permission is granted to temporarily download one copy of the
|
||||||
bab.toal.ca's Website for personal, non-commercial transitory viewing
|
materials on undock.ca's Website for personal, non-commercial
|
||||||
only. This is the grant of a license, not a transfer of title, and under
|
transitory viewing only. This is the grant of a license, not a
|
||||||
this license you may not:
|
transfer of title, and under this license you may not:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>modify or copy the materials;</li>
|
<li>modify or copy the materials;</li>
|
||||||
<li>
|
<li>
|
||||||
use the materials for any commercial purpose or for any public display;
|
use the materials for any commercial purpose or for any public
|
||||||
</li>
|
display;
|
||||||
<li>
|
</li>
|
||||||
attempt to reverse engineer any software contained on bab.toal.ca's
|
<li>
|
||||||
Website;
|
attempt to reverse engineer any software contained on undock.ca's
|
||||||
</li>
|
Website;
|
||||||
<li>
|
</li>
|
||||||
remove any copyright or other proprietary notations from the materials;
|
<li>
|
||||||
or
|
remove any copyright or other proprietary notations from the
|
||||||
</li>
|
materials; or
|
||||||
<li>
|
</li>
|
||||||
transferring the materials to another person or "mirror" the materials
|
<li>
|
||||||
on any other server.
|
transferring the materials to another person or "mirror" the
|
||||||
</li>
|
materials on any other server.
|
||||||
</ul>
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
This will let bab.toal.ca to terminate upon violations of any of these
|
This will let undock.ca to terminate upon violations of any of these
|
||||||
restrictions. Upon termination, your viewing right will also be terminated
|
restrictions. Upon termination, your viewing right will also be
|
||||||
and you should destroy any downloaded materials in your possession whether
|
terminated and you should destroy any downloaded materials in your
|
||||||
it is printed or electronic format. These Terms of Service has been
|
possession whether it is printed or electronic format. These Terms of
|
||||||
created with the help of the
|
Service has been created with the help of the
|
||||||
<a href="https://www.termsofservicegenerator.net"
|
<a href="https://www.termsofservicegenerator.net">
|
||||||
>Terms Of Service Generator</a
|
Terms Of Service Generator
|
||||||
>.
|
</a>
|
||||||
</p>
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>3. Disclaimer</h2>
|
<h2>3. Disclaimer</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
All the materials on bab.toal.ca's Website are provided "as is".
|
All the materials on undock.ca's Website are provided "as is".
|
||||||
bab.toal.ca makes no warranties, may it be expressed or implied, therefore
|
undock.ca makes no warranties, may it be expressed or implied,
|
||||||
negates all other warranties. Furthermore, bab.toal.ca does not make any
|
therefore negates all other warranties. Furthermore, undock.ca does
|
||||||
representations concerning the accuracy or reliability of the use of the
|
not make any representations concerning the accuracy or reliability of
|
||||||
materials on its Website or otherwise relating to such materials or any
|
the use of the materials on its Website or otherwise relating to such
|
||||||
sites linked to this Website.
|
materials or any sites linked to this Website.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>4. Limitations</h2>
|
<h2>4. Limitations</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca or its suppliers will not be hold accountable for any damages
|
undock.ca or its suppliers will not be hold accountable for any
|
||||||
that will arise with the use or inability to use the materials on
|
damages that will arise with the use or inability to use the materials
|
||||||
bab.toal.ca's Website, even if bab.toal.ca or an authorize representative
|
on undock.ca's Website, even if bab.toal.ca or an authorize
|
||||||
of this Website has been notified, orally or written, of the possibility
|
representative of this Website has been notified, orally or written,
|
||||||
of such damage. Some jurisdiction does not allow limitations on implied
|
of the possibility of such damage. Some jurisdiction does not allow
|
||||||
warranties or limitations of liability for incidental damages, these
|
limitations on implied warranties or limitations of liability for
|
||||||
limitations may not apply to you.
|
incidental damages, these limitations may not apply to you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>5. Revisions and Errata</h2>
|
<h2>5. Revisions and Errata</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
The materials appearing on bab.toal.ca's Website may include technical,
|
The materials appearing on undock.ca's Website may include technical,
|
||||||
typographical, or photographic errors. bab.toal.ca will not promise that
|
typographical, or photographic errors. undock.ca will not promise that
|
||||||
any of the materials in this Website are accurate, complete, or current.
|
any of the materials in this Website are accurate, complete, or
|
||||||
bab.toal.ca may change the materials contained on its Website at any time
|
current. undock.ca may change the materials contained on its Website
|
||||||
without notice. bab.toal.ca does not make any commitment to update the
|
at any time without notice. undock.ca does not make any commitment to
|
||||||
materials.
|
update the materials.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>6. Links</h2>
|
<h2>6. Links</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca has not reviewed all of the sites linked to its Website and is
|
undock.ca has not reviewed all of the sites linked to its Website and
|
||||||
not responsible for the contents of any such linked site. The presence of
|
is not responsible for the contents of any such linked site. The
|
||||||
any link does not imply endorsement by bab.toal.ca of the site. The use of
|
presence of any link does not imply endorsement by undock.ca of the
|
||||||
any linked website is at the user's own risk.
|
site. The use of any linked website is at the user's own risk.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>7. Site Terms of Use Modifications</h2>
|
<h2>7. Site Terms of Use Modifications</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca may revise these Terms of Use for its Website at any time
|
undock.ca may revise these Terms of Use for its Website at any time
|
||||||
without prior notice. By using this Website, you are agreeing to be bound
|
without prior notice. By using this Website, you are agreeing to be
|
||||||
by the current version of these Terms and Conditions of Use.
|
bound by the current version of these Terms and Conditions of Use.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>8. Your Privacy</h2>
|
<h2>8. Your Privacy</h2>
|
||||||
|
|
||||||
<p>Please read our Privacy Policy.</p>
|
<p>
|
||||||
|
Please read our
|
||||||
|
<a href="/privacy-policy">Privacy Policy.</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>9. Governing Law</h2>
|
<h2>9. Governing Law</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Any claim related to bab.toal.ca's Website shall be governed by the laws
|
Any claim related to undock.ca's Website shall be governed by the laws
|
||||||
of ca without regards to its conflict of law provisions.
|
of ca without regards to its conflict of law provisions.
|
||||||
</p>
|
</p>
|
||||||
</q-page>
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts"></script>
|
||||||
|
|||||||
@@ -1,179 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page>
|
<BoatReservationComponent v-model="newReservation" />
|
||||||
<q-list>
|
|
||||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-sm">
|
|
||||||
<q-item>
|
|
||||||
<q-item-section :avatar="true">
|
|
||||||
<q-icon name="person"
|
|
||||||
/></q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label> Name: {{ bookingForm.name }} </q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
v-model="resourceView"
|
|
||||||
icon="calendar_month"
|
|
||||||
label="Boat and Time"
|
|
||||||
default-opened
|
|
||||||
class="q-mt-none"
|
|
||||||
:caption="bookingSummary"
|
|
||||||
>
|
|
||||||
<q-separator />
|
|
||||||
<q-banner :class="$q.dark.isActive ? 'bg-grey-9' : 'bg-grey-3'">
|
|
||||||
Use the calendar to pick a date. Select an available boat and
|
|
||||||
timeslot below.
|
|
||||||
</q-banner>
|
|
||||||
<BoatScheduleTableComponent v-model="timeblock" />
|
|
||||||
|
|
||||||
<q-banner
|
|
||||||
rounded
|
|
||||||
class="bg-warning text-grey-10"
|
|
||||||
style="max-width: 95vw; margin: auto"
|
|
||||||
v-if="bookingForm.boat?.defects"
|
|
||||||
>
|
|
||||||
<template v-slot:avatar>
|
|
||||||
<q-icon name="warning" color="grey-10" />
|
|
||||||
</template>
|
|
||||||
{{ bookingForm.boat.name }} currently has the following notices:
|
|
||||||
<ol>
|
|
||||||
<li
|
|
||||||
v-for="defect in bookingForm.boat.defects"
|
|
||||||
:key="defect.description"
|
|
||||||
>
|
|
||||||
{{ defect.description }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</q-banner>
|
|
||||||
<q-card-section>
|
|
||||||
<q-btn
|
|
||||||
color="primary"
|
|
||||||
class="full-width"
|
|
||||||
icon="keyboard_arrow_down"
|
|
||||||
icon-right="keyboard_arrow_down"
|
|
||||||
label="Next: Crew & Passengers"
|
|
||||||
@click="resourceView = false"
|
|
||||||
/></q-card-section>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="people"
|
|
||||||
label="Crew and Passengers"
|
|
||||||
default-opened
|
|
||||||
><q-banner v-if="bookingForm.boat"
|
|
||||||
>Passengers:
|
|
||||||
{{ bookingForm.members.length + bookingForm.guests.length }} /
|
|
||||||
{{ bookingForm.boat.booking?.maxPassengers }}</q-banner
|
|
||||||
>
|
|
||||||
<q-item
|
|
||||||
class="q-my-sm"
|
|
||||||
v-for="passenger in [...bookingForm.members, ...bookingForm.guests]"
|
|
||||||
:key="passenger.name"
|
|
||||||
>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-avatar color="primary" text-color="white" size="sm">
|
|
||||||
{{
|
|
||||||
passenger.name
|
|
||||||
.split(' ')
|
|
||||||
.map((i) => i.charAt(0))
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
}}
|
|
||||||
</q-avatar>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>{{ passenger.name }}</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-btn color="negative" flat dense round icon="cancel" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-separator />
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
<q-item-section>
|
|
||||||
<q-btn label="Submit" type="submit" color="primary" />
|
|
||||||
</q-item-section> </q-form
|
|
||||||
></q-list>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
import { date } from 'quasar';
|
import { ref } from 'vue';
|
||||||
import { useScheduleStore } from 'src/stores/schedule';
|
import { useRoute } from 'vue-router';
|
||||||
import { Timeblock } from 'src/stores/schedule.types';
|
|
||||||
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
|
||||||
|
|
||||||
interface BookingForm {
|
const $route = useRoute();
|
||||||
bookingId: string;
|
const newReservation = ref<Reservation>();
|
||||||
name?: string;
|
|
||||||
boat?: Boat;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
members: { name: string }[];
|
|
||||||
guests: { name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
if (typeof $route.query.interval === 'string') {
|
||||||
const dateFormat = 'MMM D, YYYY h:mm A';
|
useIntervalStore()
|
||||||
const resourceView = ref(true);
|
.fetchInterval($route.query.interval)
|
||||||
const scheduleStore = useScheduleStore();
|
.then(
|
||||||
const timeblock = ref<Timeblock>();
|
(interval: Interval) =>
|
||||||
const bookingForm = ref<BookingForm>({
|
(newReservation.value = <Reservation>{
|
||||||
bookingId: scheduleStore.getNewId(),
|
resource: interval.resource,
|
||||||
name: auth.currentUser?.name,
|
start: interval.start,
|
||||||
boat: <Boat | undefined>undefined,
|
end: interval.end,
|
||||||
startDate: date.formatDate(new Date(), dateFormat),
|
})
|
||||||
endDate: date.formatDate(new Date(), dateFormat),
|
|
||||||
members: [{ name: 'Karen Henrikso' }, { name: "Rich O'hare" }],
|
|
||||||
guests: [{ name: 'Bob Barker' }, { name: 'Taylor Swift' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(timeblock, (tb_new) => {
|
|
||||||
console.log('Hi');
|
|
||||||
bookingForm.value.boat = useBoatStore().boats.find(
|
|
||||||
(b) => b.$id === tb_new?.boatId
|
|
||||||
);
|
|
||||||
bookingForm.value.startDate = date.formatDate(tb_new?.start, dateFormat);
|
|
||||||
bookingForm.value.endDate = date.formatDate(tb_new?.end, dateFormat);
|
|
||||||
console.log(tb_new);
|
|
||||||
});
|
|
||||||
|
|
||||||
// //TODO: Turn this into a validator.
|
|
||||||
// scheduleStore.isReservationOverlapped(newRes)
|
|
||||||
// ? Dialog.create({ message: 'This booking overlaps another!' })
|
|
||||||
// : scheduleStore.addOrCreateReservation(newRes);
|
|
||||||
|
|
||||||
const onReset = () => {
|
|
||||||
// TODO
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
|
||||||
// TODO
|
|
||||||
};
|
|
||||||
|
|
||||||
const bookingDuration = computed(() => {
|
|
||||||
if (bookingForm.value.endDate && bookingForm.value.startDate) {
|
|
||||||
const diff = date.getDateDiff(
|
|
||||||
bookingForm.value.endDate,
|
|
||||||
bookingForm.value.startDate,
|
|
||||||
'minutes'
|
|
||||||
);
|
);
|
||||||
return diff <= 0
|
}
|
||||||
? 'Invalid'
|
|
||||||
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
|
|
||||||
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
|
|
||||||
} else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookingSummary = computed(() => {
|
|
||||||
return bookingForm.value.boat &&
|
|
||||||
bookingForm.value.startDate &&
|
|
||||||
bookingForm.value.endDate
|
|
||||||
? `${bookingForm.value.boat.name} @ ${bookingForm.value.startDate} for ${bookingDuration.value}`
|
|
||||||
: '';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,145 +1,163 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page>
|
||||||
<div class="subcontent">
|
<div class="col">
|
||||||
<!-- <navigation-bar @today="onToday" @prev="onPrev" @next="onNext" /> -->
|
<navigation-bar
|
||||||
|
@today="onToday"
|
||||||
<div class="row justify-center">
|
@prev="onPrev"
|
||||||
<q-calendar-day
|
@next="onNext" />
|
||||||
ref="calendar"
|
</div>
|
||||||
v-model="selectedDate"
|
<div class="col q-ma-sm">
|
||||||
view="day"
|
<q-calendar-scheduler
|
||||||
:max-days="3"
|
ref="calendar"
|
||||||
bordered
|
v-model="selectedDate"
|
||||||
animated
|
v-model:model-resources="boatStore.boats"
|
||||||
transition-next="slide-left"
|
resource-key="$id"
|
||||||
transition-prev="slide-right"
|
resource-label="displayName"
|
||||||
@change="onChange"
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
@moved="onMoved"
|
:view="$q.screen.gt.md ? 'week' : 'day'"
|
||||||
@click-date="onClickDate"
|
v-touch-swipe.mouse.left.right="handleSwipe"
|
||||||
@click-time="onClickTime"
|
:max-days="$q.screen.lt.sm ? 3 : 7"
|
||||||
@click-interval="onClickInterval"
|
animated
|
||||||
@click-head-day="onClickHeadDay"
|
bordered
|
||||||
>
|
style="--calendar-resources-width: 40px">
|
||||||
<template
|
<template #day="{ scope }">
|
||||||
#day-body="{
|
<div
|
||||||
scope: { timestamp, timeStartPos, timeDurationHeight },
|
v-for="interval in getSortedIntervals(
|
||||||
}"
|
scope.timestamp,
|
||||||
>
|
scope.resource
|
||||||
<template
|
)"
|
||||||
v-for="event in reservationEvents(timestamp)"
|
:key="interval.$id"
|
||||||
:key="event.id"
|
class="q-pb-xs row"
|
||||||
>
|
@click="createReservationFromInterval(interval)">
|
||||||
<div
|
<q-badge
|
||||||
v-if="event.start !== undefined"
|
multi-line
|
||||||
class="booking-event"
|
:class="!interval.user ? 'cursor-pointer' : null"
|
||||||
:style="slotStyle(event, timeStartPos, timeDurationHeight)"
|
class="col-12 q-pa-sm"
|
||||||
>
|
:transparent="interval.user != undefined"
|
||||||
<span class="title q-calendar__ellipsis">
|
:color="interval.user ? 'secondary' : 'primary'"
|
||||||
{{ event.user }}
|
:outline="!interval.user"
|
||||||
<q-tooltip>{{
|
:id="interval.id">
|
||||||
event.start + ' - ' + event.resource.name
|
{{
|
||||||
}}</q-tooltip>
|
interval.user
|
||||||
</span>
|
? useAuthStore().getUserNameById(interval.user)
|
||||||
</div>
|
: 'Available'
|
||||||
</template>
|
}}
|
||||||
</template>
|
<br />
|
||||||
</q-calendar-day>
|
{{ formatTime(interval.start) }} to
|
||||||
</div>
|
<br />
|
||||||
|
{{ formatTime(interval.end) }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-scheduler>
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Reservation, useScheduleStore } from 'src/stores/schedule';
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
const scheduleStore = useScheduleStore();
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
import {
|
|
||||||
TimestampOrNull,
|
const reservationStore = useReservationStore();
|
||||||
makeDateTime,
|
import { getDate } from '@quasar/quasar-ui-qcalendar';
|
||||||
makeDate,
|
import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||||
parseDate,
|
|
||||||
today,
|
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
|
||||||
import { QCalendarDay } from '@quasar/quasar-ui-qcalendar';
|
|
||||||
import { date } from 'quasar';
|
|
||||||
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { formatTime } from 'src/utils/schedule';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
const selectedDate = ref(today());
|
const boatStore = useBoatStore();
|
||||||
|
const calendar = ref();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const $router = useRouter();
|
||||||
|
const { getAvailableIntervals } = useIntervalStore();
|
||||||
|
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||||
|
const currentUser = useAuthStore().currentUser;
|
||||||
|
|
||||||
// Use ref to get a reference to the QCalendarDay component
|
// interface DayScope {
|
||||||
const calendarRef = ref(QCalendarDay);
|
// timestamp: Timestamp;
|
||||||
|
// columnIndex: number;
|
||||||
|
// resource: object;
|
||||||
|
// resourceIndex: number;
|
||||||
|
// indentLevel: number;
|
||||||
|
// activeDate: boolean;
|
||||||
|
// droppable: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
|
||||||
|
return getAvailableIntervals(timestamp, boat)
|
||||||
|
.value.concat(boatReservationEvents(timestamp, boat))
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||||
|
};
|
||||||
// Method declarations
|
// Method declarations
|
||||||
|
|
||||||
function slotStyle(
|
// function slotStyle(
|
||||||
event: Reservation,
|
// event: Reservation,
|
||||||
timeStartPos: (time: TimestampOrNull) => string,
|
// timeStartPos: (time: TimestampOrNull) => string,
|
||||||
timeDurationHeight: (minutes: number) => string
|
// timeDurationHeight: (minutes: number) => string
|
||||||
) {
|
// ) {
|
||||||
const s = {
|
// const s = {
|
||||||
top: '',
|
// top: '',
|
||||||
height: '',
|
// height: '',
|
||||||
'align-items': 'flex-start',
|
// 'align-items': 'flex-start',
|
||||||
};
|
// };
|
||||||
if (timeStartPos && timeDurationHeight) {
|
// if (timeStartPos && timeDurationHeight) {
|
||||||
s.top = timeStartPos(parseDate(event.start)) + 'px';
|
// s.top = timeStartPos(parsed(event.start)) + 'px';
|
||||||
s.height =
|
// s.height =
|
||||||
timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
|
// timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
|
||||||
'px';
|
// 'px';
|
||||||
|
// }
|
||||||
|
// return s;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const createReservationFromInterval = (interval: Interval | Reservation) => {
|
||||||
|
if (interval.user) {
|
||||||
|
if (interval.user === currentUser?.$id) {
|
||||||
|
$router.push({ name: 'edit-reservation', params: { id: interval.$id } });
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$router.push({
|
||||||
|
name: 'reserve-boat',
|
||||||
|
query: { interval: interval.$id },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return s;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function reservationEvents(timestamp: Timestamp) {
|
function handleSwipe({ ...event }) {
|
||||||
return scheduleStore.getBoatReservations(timestamp);
|
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||||
}
|
}
|
||||||
|
const boatReservationEvents = (
|
||||||
|
timestamp: Timestamp,
|
||||||
|
resource: Boat | undefined
|
||||||
|
): Reservation[] => {
|
||||||
|
if (!resource) return [] as Reservation[];
|
||||||
|
return reservationStore.getReservationsByDate(
|
||||||
|
getDate(timestamp),
|
||||||
|
(resource as Boat).$id
|
||||||
|
).value;
|
||||||
|
};
|
||||||
function onToday() {
|
function onToday() {
|
||||||
calendarRef.value.moveToToday();
|
calendar.value.moveToToday();
|
||||||
}
|
}
|
||||||
function onPrev() {
|
function onPrev() {
|
||||||
calendarRef.value.prev();
|
calendar.value.prev();
|
||||||
}
|
}
|
||||||
function onNext() {
|
function onNext() {
|
||||||
calendarRef.value.next();
|
calendar.value.next();
|
||||||
}
|
|
||||||
function onMoved(data) {
|
|
||||||
console.log('onMoved', data);
|
|
||||||
}
|
|
||||||
function onChange(data) {
|
|
||||||
console.log('onChange', data);
|
|
||||||
}
|
|
||||||
function onClickDate(data) {
|
|
||||||
console.log('onClickDate', data);
|
|
||||||
}
|
|
||||||
function onClickTime(data) {
|
|
||||||
console.log('onClickTime', data);
|
|
||||||
}
|
|
||||||
function onClickInterval(data) {
|
|
||||||
console.log('onClickInterval', data);
|
|
||||||
}
|
|
||||||
function onClickHeadDay(data) {
|
|
||||||
console.log('onClickHeadDay', data);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
<style lang="sass">
|
||||||
.booking-event
|
.q-calendar-scheduler__resource
|
||||||
position: absolute
|
background-color: $primary
|
||||||
font-size: 12px
|
|
||||||
justify-content: space-evenly
|
|
||||||
margin: 0 1px
|
|
||||||
text-overflow: ellipsis
|
|
||||||
overflow: hidden
|
|
||||||
color: white
|
color: white
|
||||||
max-width: 100%
|
font-weight: bold
|
||||||
background: #027BE3FF
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
.title
|
|
||||||
position: relative
|
|
||||||
display: flex
|
|
||||||
justify-content: center
|
|
||||||
align-items: center
|
|
||||||
height: 100%
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
86
src/pages/schedule/ListReservationsPage.vue
Normal file
86
src/pages/schedule/ListReservationsPage.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<q-tabs
|
||||||
|
v-model="tab"
|
||||||
|
inline-label
|
||||||
|
class="text-primary">
|
||||||
|
<q-tab
|
||||||
|
name="upcoming"
|
||||||
|
icon="schedule"
|
||||||
|
label="Upcoming" />
|
||||||
|
<q-tab
|
||||||
|
name="past"
|
||||||
|
icon="history"
|
||||||
|
label="Past" />
|
||||||
|
</q-tabs>
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-tab-panels
|
||||||
|
v-model="tab"
|
||||||
|
animated>
|
||||||
|
<q-tab-panel
|
||||||
|
name="upcoming"
|
||||||
|
class="q-pa-none">
|
||||||
|
<q-card
|
||||||
|
clas="q-ma-md"
|
||||||
|
v-if="!reservationStore.futureUserReservations.length">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">You don't have any upcoming bookings!</div>
|
||||||
|
<div class="text-h8">Why don't you go make one?</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="event"
|
||||||
|
:size="`1.25em`"
|
||||||
|
label="Book Now"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
:align="'left'"
|
||||||
|
to="/schedule/book" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="reservation in reservationStore.futureUserReservations"
|
||||||
|
:key="reservation.$id">
|
||||||
|
<ReservationCardComponent :modelValue="reservation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel
|
||||||
|
name="past"
|
||||||
|
class="q-pa-none">
|
||||||
|
<div
|
||||||
|
v-for="reservation in reservationStore.pastUserReservations"
|
||||||
|
:key="reservation.$id">
|
||||||
|
<ReservationCardComponent :modelValue="reservation" />
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import ReservationCardComponent from 'src/components/scheduling/ReservationCardComponent.vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
|
||||||
|
onMounted(() => useReservationStore().fetchUserReservations());
|
||||||
|
|
||||||
|
const tab = ref('upcoming');
|
||||||
|
|
||||||
|
// const showMarker = (
|
||||||
|
// index: number,
|
||||||
|
// items: Reservation[] | undefined
|
||||||
|
// ): boolean => {
|
||||||
|
// if (!items) return false;
|
||||||
|
|
||||||
|
// const currentItemDate = new Date(items[index].start);
|
||||||
|
// const nextItemDate = index > 0 ? new Date(items[index - 1].start) : null;
|
||||||
|
|
||||||
|
// // Show marker if current item is past and the next item is future or vice versa
|
||||||
|
// return (
|
||||||
|
// isPast(currentItemDate) && (nextItemDate === null || !isPast(nextItemDate))
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
</script>
|
||||||
324
src/pages/schedule/ManageCalendar.vue
Normal file
324
src/pages/schedule/ManageCalendar.vue
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fit row wrap justify-start items-start content-start">
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div
|
||||||
|
class="scheduler"
|
||||||
|
style="max-width: 1200px">
|
||||||
|
<NavigationBar
|
||||||
|
@next="onNext"
|
||||||
|
@today="onToday"
|
||||||
|
@prev="onPrev" />
|
||||||
|
<q-calendar-scheduler
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
v-model:model-resources="boats"
|
||||||
|
resource-key="$id"
|
||||||
|
resource-label="name"
|
||||||
|
view="week"
|
||||||
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
:drag-enter-func="onDragEnter"
|
||||||
|
:drag-over-func="onDragOver"
|
||||||
|
:drag-leave-func="onDragLeave"
|
||||||
|
:drop-func="onDrop"
|
||||||
|
day-min-height="50px"
|
||||||
|
cell-width="150px">
|
||||||
|
<template #day="{ scope }">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
filteredIntervals(scope.timestamp, scope.resource).value.length
|
||||||
|
"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
">
|
||||||
|
<template
|
||||||
|
v-for="block in sortedIntervals(scope.timestamp, scope.resource)
|
||||||
|
.value"
|
||||||
|
:key="block.id">
|
||||||
|
<q-chip class="cursor-pointer">
|
||||||
|
{{ date.formatDate(block.start, 'HH:mm') }} -
|
||||||
|
{{ date.formatDate(block.end, 'HH:mm') }}
|
||||||
|
<!-- <q-popup-edit
|
||||||
|
:model-value="block"
|
||||||
|
v-slot="scope"
|
||||||
|
buttons
|
||||||
|
@save="saveInterval"
|
||||||
|
>
|
||||||
|
TODO: Why isn't this saving?
|
||||||
|
<q-input
|
||||||
|
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
|
||||||
|
dense
|
||||||
|
autofocus
|
||||||
|
type="time"
|
||||||
|
label="start"
|
||||||
|
@keyup.enter="scope.set"
|
||||||
|
@update:model-value="
|
||||||
|
(t) => {
|
||||||
|
block.start = new Date(
|
||||||
|
scope.value.start.split('T')[0] + 'T' + t
|
||||||
|
).toISOString();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
TODO: Clean this up
|
||||||
|
<q-input
|
||||||
|
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
|
||||||
|
dense
|
||||||
|
type="time"
|
||||||
|
label="end"
|
||||||
|
@keyup.enter="scope.set"
|
||||||
|
@update:model-value="
|
||||||
|
(t) =>
|
||||||
|
(block.end = new Date(
|
||||||
|
scope.value.end.split('T')[0] + 'T' + t
|
||||||
|
).toISOString())
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</q-popup-edit>-->
|
||||||
|
</q-chip>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
round
|
||||||
|
@click="deleteBlock(block)" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-scheduler>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="q-pa-md"
|
||||||
|
style="width: 400px">
|
||||||
|
<q-list
|
||||||
|
padding
|
||||||
|
bordered
|
||||||
|
class="rounded-borders">
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>Availability Templates</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
Drag and drop a template to a boat / date to create booking
|
||||||
|
availability
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
label="Add Template"
|
||||||
|
color="primary"
|
||||||
|
@click="createTemplate" />
|
||||||
|
</q-card-actions>
|
||||||
|
<q-item v-if="newTemplate.$id === 'unsaved'">
|
||||||
|
<IntervalTemplateComponent
|
||||||
|
:model-value="newTemplate"
|
||||||
|
:edit="true"
|
||||||
|
@cancel="resetNewTemplate"
|
||||||
|
@saved="resetNewTemplate" />
|
||||||
|
</q-item>
|
||||||
|
<q-separator spaced />
|
||||||
|
<IntervalTemplateComponent
|
||||||
|
v-for="template in intervalTemplates"
|
||||||
|
:key="template.$id"
|
||||||
|
:model-value="template" />
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-dialog v-model="alert">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Warning!</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
Conflicting times! Please delete overlapped items!
|
||||||
|
<q-chip
|
||||||
|
v-for="item in overlapped"
|
||||||
|
:key="item.index">
|
||||||
|
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
|
||||||
|
{{ date.formatDate(item.start, 'hh:mm') }} -
|
||||||
|
{{ date.formatDate(item.end, 'hh:mm') }}
|
||||||
|
</q-chip>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="OK"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
QCalendarScheduler,
|
||||||
|
Timestamp,
|
||||||
|
today,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
Interval,
|
||||||
|
IntervalTemplate,
|
||||||
|
TimeTuple,
|
||||||
|
} from 'src/stores/schedule.types';
|
||||||
|
import { date } from 'quasar';
|
||||||
|
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
|
||||||
|
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
|
||||||
|
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||||
|
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
const { fetchBoats } = useBoatStore();
|
||||||
|
const intervalStore = useIntervalStore();
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
|
||||||
|
const calendar = ref();
|
||||||
|
const overlapped = ref();
|
||||||
|
const alert = ref(false);
|
||||||
|
const newTemplate = ref<IntervalTemplate>({
|
||||||
|
$id: '',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* TODOS:
|
||||||
|
* Need more validation:
|
||||||
|
- Interval start < end
|
||||||
|
- Intervals don't overlap
|
||||||
|
* Need to handle case of overnight blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchBoats();
|
||||||
|
await intervalTemplateStore.fetchIntervalTemplates();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredIntervals = (date: Timestamp, boat: Boat) => {
|
||||||
|
return intervalStore.getIntervals(date, boat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedIntervals = (date: Timestamp, boat: Boat) => {
|
||||||
|
return computed(() =>
|
||||||
|
filteredIntervals(date, boat).value.sort(
|
||||||
|
(a, b) => Date.parse(a.start) - Date.parse(b.start)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetNewTemplate() {
|
||||||
|
newTemplate.value = {
|
||||||
|
$id: 'unsaved',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function createTemplate() {
|
||||||
|
newTemplate.value.$id = 'unsaved';
|
||||||
|
}
|
||||||
|
function createIntervals(boat: Boat, templateId: string, date: string) {
|
||||||
|
const intervals = intervalsFromTemplate(boat, templateId, date);
|
||||||
|
intervals.forEach((interval) => intervalStore.createInterval(interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntervals(date: Timestamp, boat: Boat) {
|
||||||
|
return intervalStore.getIntervals(date, boat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalsFromTemplate(
|
||||||
|
boat: Boat,
|
||||||
|
templateId: string,
|
||||||
|
date: string
|
||||||
|
): Interval[] {
|
||||||
|
const template = intervalTemplateStore
|
||||||
|
.getIntervalTemplates()
|
||||||
|
.value.find((t) => t.$id === templateId);
|
||||||
|
return template
|
||||||
|
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||||
|
buildInterval(boat, timeTuple, date)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlock(block: Interval) {
|
||||||
|
if (block.$id) {
|
||||||
|
intervalStore.deleteInterval(block.$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.add('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(
|
||||||
|
//TODO: Move all overlap checking to the store. This is too messy right now.
|
||||||
|
e: DragEvent,
|
||||||
|
type: string,
|
||||||
|
scope: { resource: Boat; timestamp: Timestamp }
|
||||||
|
) {
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
|
||||||
|
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
|
||||||
|
const templateId = e.dataTransfer.getData('ID');
|
||||||
|
const date = scope.timestamp.date;
|
||||||
|
const resource = scope.resource;
|
||||||
|
const existingIntervals = getIntervals(scope.timestamp, resource);
|
||||||
|
const boatsToApply = type === 'head-day' ? boats.value : [resource];
|
||||||
|
overlapped.value = boatsToApply
|
||||||
|
.map((boat) =>
|
||||||
|
intervalsOverlapped(
|
||||||
|
existingIntervals.value.concat(
|
||||||
|
intervalsFromTemplate(boat, templateId, date)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat(1);
|
||||||
|
if (overlapped.value.length === 0) {
|
||||||
|
boatsToApply.map((b) => createIntervals(b, templateId, date));
|
||||||
|
} else {
|
||||||
|
alert.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToday() {
|
||||||
|
calendar.value.moveToToday();
|
||||||
|
}
|
||||||
|
function onPrev() {
|
||||||
|
calendar.value.prev();
|
||||||
|
}
|
||||||
|
function onNext() {
|
||||||
|
calendar.value.next();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
18
src/pages/schedule/ModifyBoatReservation.vue
Normal file
18
src/pages/schedule/ModifyBoatReservation.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<BoatReservationComponent v-model="reservation" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const reservation = ref<Reservation>();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = useRoute().params.id as string;
|
||||||
|
reservation.value = await useReservationStore().getReservationById(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<q-item v-for="link in navlinks" :key="link.label">
|
<q-item
|
||||||
|
v-for="link in navlinks"
|
||||||
|
:key="link.name">
|
||||||
<q-btn
|
<q-btn
|
||||||
:icon="link.icon"
|
:icon="link.icon"
|
||||||
color="primary"
|
:color="link.color ? link.color : 'primary'"
|
||||||
size="1.25em"
|
size="1.25em"
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
:label="link.label"
|
:label="link.name"
|
||||||
rounded
|
rounded
|
||||||
class="full-width"
|
class="full-width"
|
||||||
align="left"
|
align="left" />
|
||||||
/>
|
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const navlinks = [
|
import { enabledLinks } from 'src/router/navlinks';
|
||||||
{
|
|
||||||
icon: 'more_time',
|
const navlinks = enabledLinks.find(
|
||||||
to: '/schedule/book',
|
(link) => link.name === 'Schedule'
|
||||||
label: 'Create a Reservation',
|
)?.sublinks;
|
||||||
},
|
|
||||||
{ icon: 'calendar_month', to: '/schedule/view', label: 'View Schedule' },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolbarComponent pageTitle="Tasks" />
|
<ToolbarComponent pageTitle="Tasks" />
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div class="q-pa-md" style="max-width: 400px">
|
<div
|
||||||
|
class="q-pa-md"
|
||||||
|
style="max-width: 400px">
|
||||||
<TaskEditComponent :taskId="taskId" />
|
<TaskEditComponent :taskId="taskId" />
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
@@ -9,7 +11,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const taskId = useRoute().params.id as string;
|
const taskId = useRoute().params.id as string;
|
||||||
console.log(taskId);
|
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|||||||
@@ -1,163 +1,173 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-layout>
|
||||||
<h1>Privacy Policy for bab.toal.ca</h1>
|
<q-page-container>
|
||||||
|
<q-page padding>
|
||||||
|
<h1>Privacy Policy for Undock</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
At OYS BAB Test, accessible from https://bab.toal.ca, one of our main
|
At Undock, accessible from https://Undock, one of our main priorities
|
||||||
priorities is the privacy of our visitors. This Privacy Policy document
|
is the privacy of our visitors. This Privacy Policy document contains
|
||||||
contains types of information that is collected and recorded by OYS BAB
|
types of information that is collected and recorded by OYS BAB Test
|
||||||
Test and how we use it.
|
and how we use it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
If you have additional questions or require more information about our
|
If you have additional questions or require more information about our
|
||||||
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
|
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
|
||||||
generated with the help of
|
generated with the help of
|
||||||
<a href="https://www.gdprprivacypolicy.net/"
|
<a href="https://www.gdprprivacypolicy.net/">
|
||||||
>GDPR Privacy Policy Generator</a
|
GDPR Privacy Policy Generator
|
||||||
>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>General Data Protection Regulation (GDPR)</h2>
|
<h2>General Data Protection Regulation (GDPR)</h2>
|
||||||
<p>We are a Data Controller of your information.</p>
|
<p>We are a Data Controller of your information.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca legal basis for collecting and using the personal information
|
Undock legal basis for collecting and using the personal information
|
||||||
described in this Privacy Policy depends on the Personal Information we
|
described in this Privacy Policy depends on the Personal Information
|
||||||
collect and the specific context in which we collect the information:
|
we collect and the specific context in which we collect the
|
||||||
</p>
|
information:
|
||||||
<ul>
|
</p>
|
||||||
<li>bab.toal.ca needs to perform a contract with you</li>
|
<ul>
|
||||||
<li>You have given bab.toal.ca permission to do so</li>
|
<li>Undock needs to perform a contract with you</li>
|
||||||
<li>
|
<li>You have given Undock permission to do so</li>
|
||||||
Processing your personal information is in bab.toal.ca legitimate
|
<li>
|
||||||
interests
|
Processing your personal information is in Undock legitimate
|
||||||
</li>
|
interests
|
||||||
<li>bab.toal.ca needs to comply with the law</li>
|
</li>
|
||||||
</ul>
|
<li>Undock needs to comply with the law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
bab.toal.ca will retain your personal information only for as long as is
|
Undock will retain your personal information only for as long as is
|
||||||
necessary for the purposes set out in this Privacy Policy. We will retain
|
necessary for the purposes set out in this Privacy Policy. We will
|
||||||
and use your information to the extent necessary to comply with our legal
|
retain and use your information to the extent necessary to comply with
|
||||||
obligations, resolve disputes, and enforce our policies.
|
our legal obligations, resolve disputes, and enforce our policies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
If you are a resident of the European Economic Area (EEA), you have
|
If you are a resident of the European Economic Area (EEA), you have
|
||||||
certain data protection rights. If you wish to be informed what Personal
|
certain data protection rights. If you wish to be informed what
|
||||||
Information we hold about you and if you want it to be removed from our
|
Personal Information we hold about you and if you want it to be
|
||||||
systems, please contact us.
|
removed from our systems, please contact us.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
In certain circumstances, you have the following data protection rights:
|
In certain circumstances, you have the following data protection
|
||||||
</p>
|
rights:
|
||||||
<ul>
|
</p>
|
||||||
<li>
|
<ul>
|
||||||
The right to access, update or to delete the information we have on you.
|
<li>
|
||||||
</li>
|
The right to access, update or to delete the information we have on
|
||||||
<li>The right of rectification.</li>
|
you.
|
||||||
<li>The right to object.</li>
|
</li>
|
||||||
<li>The right of restriction.</li>
|
<li>The right of rectification.</li>
|
||||||
<li>The right to data portability</li>
|
<li>The right to object.</li>
|
||||||
<li>The right to withdraw consent</li>
|
<li>The right of restriction.</li>
|
||||||
</ul>
|
<li>The right to data portability</li>
|
||||||
|
<li>The right to withdraw consent</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>Log Files</h2>
|
<h2>Log Files</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
OYS BAB Test follows a standard procedure of using log files. These files
|
Undock follows a standard procedure of using log files. These files
|
||||||
log visitors when they visit websites. All hosting companies do this and a
|
log visitors when they visit websites. All hosting companies do this
|
||||||
part of hosting services' analytics. The information collected by log
|
and a part of hosting services' analytics. The information collected
|
||||||
files include internet protocol (IP) addresses, browser type, Internet
|
by log files include internet protocol (IP) addresses, browser type,
|
||||||
Service Provider (ISP), date and time stamp, referring/exit pages, and
|
Internet Service Provider (ISP), date and time stamp, referring/exit
|
||||||
possibly the number of clicks. These are not linked to any information
|
pages, and possibly the number of clicks. These are not linked to any
|
||||||
that is personally identifiable. The purpose of the information is for
|
information that is personally identifiable. The purpose of the
|
||||||
analyzing trends, administering the site, tracking users' movement on the
|
information is for analyzing trends, administering the site, tracking
|
||||||
website, and gathering demographic information.
|
users' movement on the website, and gathering demographic information.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Cookies and Web Beacons</h2>
|
<h2>Cookies and Web Beacons</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Like any other website, OYS BAB Test uses "cookies". These cookies are
|
Like any other website, Undock uses "cookies". These cookies are used
|
||||||
used to store information including visitors' preferences, and the pages
|
to store information including visitors' preferences, and the pages on
|
||||||
on the website that the visitor accessed or visited. The information is
|
the website that the visitor accessed or visited. The information is
|
||||||
used to optimize the users' experience by customizing our web page content
|
used to optimize the users' experience by customizing our web page
|
||||||
based on visitors' browser type and/or other information.
|
content based on visitors' browser type and/or other information.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Privacy Policies</h2>
|
<h2>Privacy Policies</h2>
|
||||||
|
|
||||||
<P
|
<p>
|
||||||
>You may consult this list to find the Privacy Policy for each of the
|
You may consult this list to find the Privacy Policy for each of the
|
||||||
advertising partners of OYS BAB Test.</P
|
advertising partners of Undock.
|
||||||
>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Third-party ad servers or ad networks uses technologies like cookies,
|
Third-party ad servers or ad networks uses technologies like cookies,
|
||||||
JavaScript, or Web Beacons that are used in their respective
|
JavaScript, or Web Beacons that are used in their respective
|
||||||
advertisements and links that appear on OYS BAB Test, which are sent
|
advertisements and links that appear on Undock, which are sent
|
||||||
directly to users' browser. They automatically receive your IP address
|
directly to users' browser. They automatically receive your IP address
|
||||||
when this occurs. These technologies are used to measure the effectiveness
|
when this occurs. These technologies are used to measure the
|
||||||
of their advertising campaigns and/or to personalize the advertising
|
effectiveness of their advertising campaigns and/or to personalize the
|
||||||
content that you see on websites that you visit.
|
advertising content that you see on websites that you visit.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Note that OYS BAB Test has no access to or control over these cookies that
|
Note that Undock has no access to or control over these cookies that
|
||||||
are used by third-party advertisers.
|
are used by third-party advertisers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Third Party Privacy Policies</h2>
|
<h2>Third Party Privacy Policies</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
OYS BAB Test's Privacy Policy does not apply to other advertisers or
|
Undock's Privacy Policy does not apply to other advertisers or
|
||||||
websites. Thus, we are advising you to consult the respective Privacy
|
websites. Thus, we are advising you to consult the respective Privacy
|
||||||
Policies of these third-party ad servers for more detailed information. It
|
Policies of these third-party ad servers for more detailed
|
||||||
may include their practices and instructions about how to opt-out of
|
information. It may include their practices and instructions about how
|
||||||
certain options.
|
to opt-out of certain options.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
You can choose to disable cookies through your individual browser options.
|
You can choose to disable cookies through your individual browser
|
||||||
To know more detailed information about cookie management with specific
|
options. To know more detailed information about cookie management
|
||||||
web browsers, it can be found at the browsers' respective websites.
|
with specific web browsers, it can be found at the browsers'
|
||||||
</p>
|
respective websites.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Children's Information</h2>
|
<h2>Children's Information</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Another part of our priority is adding protection for children while using
|
Another part of our priority is adding protection for children while
|
||||||
the internet. We encourage parents and guardians to observe, participate
|
using the internet. We encourage parents and guardians to observe,
|
||||||
in, and/or monitor and guide their online activity.
|
participate in, and/or monitor and guide their online activity.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
OYS BAB Test does not knowingly collect any Personal Identifiable
|
Undock does not knowingly collect any Personal Identifiable
|
||||||
Information from children under the age of 13. If you think that your
|
Information from children under the age of 13. If you think that your
|
||||||
child provided this kind of information on our website, we strongly
|
child provided this kind of information on our website, we strongly
|
||||||
encourage you to contact us immediately and we will do our best efforts to
|
encourage you to contact us immediately and we will do our best
|
||||||
promptly remove such information from our records.
|
efforts to promptly remove such information from our records.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Online Privacy Policy Only</h2>
|
<h2>Online Privacy Policy Only</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Our Privacy Policy applies only to our online activities and is valid for
|
Our Privacy Policy applies only to our online activities and is valid
|
||||||
visitors to our website with regards to the information that they shared
|
for visitors to our website with regards to the information that they
|
||||||
and/or collect in OYS BAB Test. This policy is not applicable to any
|
shared and/or collect in Undock. This policy is not applicable to any
|
||||||
information collected offline or via channels other than this website.
|
information collected offline or via channels other than this website.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Consent</h2>
|
<h2>Consent</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
By using our website, you hereby consent to our Privacy Policy and agree
|
By using our website, you hereby consent to our Privacy Policy and
|
||||||
to its terms.
|
agree to its terms.
|
||||||
</p>
|
</p>
|
||||||
</q-page>
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script
|
||||||
|
setup
|
||||||
|
lang="ts"></script>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
|
||||||
|
const publicRoutes = routes
|
||||||
|
.filter((route) => route.meta?.publicRoute)
|
||||||
|
.map((r) => r.path);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If not building with SSR mode, you can
|
* If not building with SSR mode, you can
|
||||||
* directly export the Router instantiation;
|
* directly export the Router instantiation;
|
||||||
@@ -35,17 +39,37 @@ export default route(function (/* { store, ssrContext } */) {
|
|||||||
history: createHistory(process.env.VUE_ROUTER_BASE),
|
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||||
});
|
});
|
||||||
|
|
||||||
Router.beforeEach((to) => {
|
Router.beforeEach(async (to, from, next) => {
|
||||||
const auth = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const currentUser = authStore.currentUser;
|
||||||
|
const authRequired = !publicRoutes.includes(to.path);
|
||||||
|
const requiredRoles = to.meta?.requiredRoles as string[];
|
||||||
|
|
||||||
if (!auth.ready) {
|
if (authRequired && !currentUser) {
|
||||||
return false;
|
return next('/login');
|
||||||
}
|
}
|
||||||
if (auth.currentUser) {
|
|
||||||
return to.meta.accountRoute ? { name: 'index' } : true;
|
if (to.name === 'login' && currentUser) {
|
||||||
} else {
|
return next('/');
|
||||||
return to.name == 'login' ? true : { name: 'login' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiredRoles) {
|
||||||
|
if (!currentUser) {
|
||||||
|
return next('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasRole = authStore.hasRequiredRole(requiredRoles);
|
||||||
|
if (!hasRole) {
|
||||||
|
return next(from);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user teams:', error);
|
||||||
|
return next('/error'); // Redirect to an error page or handle it as needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
return Router;
|
return Router;
|
||||||
|
|||||||
@@ -1,50 +1,121 @@
|
|||||||
export const links = [
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
name: string;
|
||||||
|
to: string;
|
||||||
|
icon: string;
|
||||||
|
front_links?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
sublinks?: Link[];
|
||||||
|
requiredRoles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const links = <Link[]>[
|
||||||
{
|
{
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
to: '/',
|
to: '/',
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
front_links: false,
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
to: '/profile',
|
to: '/profile',
|
||||||
icon: 'account_circle',
|
icon: 'account_circle',
|
||||||
front_links: false,
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Boats',
|
name: 'Boats',
|
||||||
to: '/boat',
|
to: '/boat',
|
||||||
icon: 'sailing',
|
icon: 'sailing',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Schedule',
|
name: 'Schedule',
|
||||||
to: '/schedule',
|
to: '/schedule',
|
||||||
icon: 'calendar_month',
|
icon: 'calendar_month',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: true,
|
||||||
|
sublinks: [
|
||||||
|
{
|
||||||
|
name: 'My View',
|
||||||
|
to: '/schedule/list',
|
||||||
|
icon: 'list',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Book',
|
||||||
|
to: '/schedule/book',
|
||||||
|
icon: 'more_time',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Calendar',
|
||||||
|
to: '/schedule/view',
|
||||||
|
icon: 'calendar_month',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Manage',
|
||||||
|
to: '/schedule/manage',
|
||||||
|
icon: 'edit_calendar',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
color: 'accent',
|
||||||
|
requiredRoles: ['Schedule Admins'],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Certifications',
|
name: 'Certifications',
|
||||||
to: '/certification',
|
to: '/certification',
|
||||||
icon: 'verified',
|
icon: 'verified',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Checklists',
|
name: 'Checklists',
|
||||||
to: '/checklist',
|
to: '/checklist',
|
||||||
icon: 'checklist',
|
icon: 'checklist',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Reference',
|
name: 'Reference',
|
||||||
to: '/reference',
|
to: '/reference',
|
||||||
icon: 'info_outline',
|
icon: 'info_outline',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tasks',
|
name: 'Tasks',
|
||||||
to: '/task',
|
to: '/task',
|
||||||
icon: 'build',
|
icon: 'build',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
function hasRole(roles: string[] | undefined) {
|
||||||
|
if (roles === undefined) return true;
|
||||||
|
const hasRole = authStore.hasRequiredRole(roles);
|
||||||
|
return hasRole;
|
||||||
|
}
|
||||||
|
export const enabledLinks = links
|
||||||
|
.filter((link) => link.enabled)
|
||||||
|
.map((link) => {
|
||||||
|
if (link.sublinks) {
|
||||||
|
link.sublinks = link.sublinks.filter(
|
||||||
|
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
||||||
name: 'boat-schedule',
|
name: 'boat-schedule',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'list',
|
||||||
|
component: () =>
|
||||||
|
import('src/pages/schedule/ListReservationsPage.vue'),
|
||||||
|
name: 'list-reservations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit/:id',
|
||||||
|
component: () =>
|
||||||
|
import('src/pages/schedule/ModifyBoatReservation.vue'),
|
||||||
|
name: 'edit-reservation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manage',
|
||||||
|
component: () => import('src/pages/schedule/ManageCalendar.vue'),
|
||||||
|
name: 'manage-schedule',
|
||||||
|
meta: { requiredRoles: ['Schedule Admins'] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,6 +114,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
component: () => import('layouts/AdminLayout.vue'),
|
component: () => import('layouts/AdminLayout.vue'),
|
||||||
|
meta: { requiredRoles: ['admin'] },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
@@ -117,6 +136,22 @@ const routes: RouteRecordRaw[] = [
|
|||||||
publicRoute: true,
|
publicRoute: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/pwreset',
|
||||||
|
component: () => import('pages/ResetPassword.vue'),
|
||||||
|
name: 'pwreset',
|
||||||
|
meta: {
|
||||||
|
publicRoute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
component: () => import('pages/LoginPage.vue'),
|
||||||
|
name: 'login',
|
||||||
|
meta: {
|
||||||
|
publicRoute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/terms-of-service',
|
path: '/terms-of-service',
|
||||||
component: () => import('pages/TermsOfServicePage.vue'),
|
component: () => import('pages/TermsOfServicePage.vue'),
|
||||||
@@ -133,14 +168,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
publicRoute: true,
|
publicRoute: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// path: '/register',
|
path: '/signup',
|
||||||
// component: () => import('pages/RegisterPage.vue'),
|
component: () => import('pages/SignupPage.vue'),
|
||||||
// name: 'register'
|
name: 'signup',
|
||||||
// meta: {
|
meta: {
|
||||||
// accountRoute: true,
|
publicRoute: true,
|
||||||
// }
|
},
|
||||||
// },
|
},
|
||||||
// Always leave this as last one,
|
// Always leave this as last one,
|
||||||
// but you can also remove it
|
// but you can also remove it
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,41 +1,125 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ID, account } from 'boot/appwrite';
|
import { ID, account, functions, teams } from 'boot/appwrite';
|
||||||
import type { Models } from 'appwrite';
|
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useBoatStore } from './boat';
|
||||||
|
import { useReservationStore } from './reservation';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
||||||
const ready = ref(false);
|
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const userNames = ref<Record<string, string>>({});
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
currentUser.value = await account.get();
|
currentUser.value = await account.get();
|
||||||
|
currentUserTeams.value = await teams.list();
|
||||||
|
await useBoatStore().fetchBoats();
|
||||||
|
await useReservationStore().fetchUserReservations();
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
|
currentUserTeams.value = null;
|
||||||
}
|
}
|
||||||
ready.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUserTeamNames = computed(() =>
|
||||||
|
currentUserTeams.value
|
||||||
|
? currentUserTeams.value.teams.map((team) => team.name)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRequiredRole = (requiredRoles: string[]): boolean => {
|
||||||
|
return requiredRoles.some((role) =>
|
||||||
|
currentUserTeamNames.value.includes(role)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
async function register(email: string, password: string) {
|
async function register(email: string, password: string) {
|
||||||
await account.create(ID.unique(), email, password);
|
await account.create(ID.unique(), email, password);
|
||||||
return await login(email, password);
|
return await login(email, password);
|
||||||
}
|
}
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
await account.createEmailSession(email, password);
|
await account.createEmailPasswordSession(email, password);
|
||||||
currentUser.value = await account.get();
|
await init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createTokenSession(email: string) {
|
||||||
|
return await account.createEmailToken(ID.unique(), email);
|
||||||
|
}
|
||||||
|
|
||||||
async function googleLogin() {
|
async function googleLogin() {
|
||||||
account.createOAuth2Session(
|
await account.createOAuth2Session(
|
||||||
'google',
|
OAuthProvider.Google,
|
||||||
'https://bab.toal.ca/',
|
'https://oys.undock.ca',
|
||||||
'https://bab.toal.ca/#/login'
|
'https://oys.undock.ca/login'
|
||||||
);
|
);
|
||||||
currentUser.value = await account.get();
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discordLogin() {
|
||||||
|
await account.createOAuth2Session(
|
||||||
|
OAuthProvider.Discord,
|
||||||
|
'https://oys.undock.ca',
|
||||||
|
'https://oys.undock.ca/login'
|
||||||
|
);
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenLogin(userId: string, token: string) {
|
||||||
|
await account.createSession(userId, token);
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserNameById(id: string | undefined | null): string {
|
||||||
|
if (!id) return 'No User';
|
||||||
|
try {
|
||||||
|
if (!userNames.value[id]) {
|
||||||
|
userNames.value[id] = 'Loading...';
|
||||||
|
functions
|
||||||
|
.createExecution(
|
||||||
|
'userinfo',
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
'/userinfo/' + id,
|
||||||
|
ExecutionMethod.GET
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.responseBody) {
|
||||||
|
userNames.value[id] = JSON.parse(res.responseBody).name;
|
||||||
|
} else {
|
||||||
|
console.error(res, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get username. Error: ' + e);
|
||||||
|
}
|
||||||
|
return userNames.value[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
return account.deleteSession('current').then((currentUser.value = null));
|
return account.deleteSession('current').then((currentUser.value = null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { currentUser, register, login, googleLogin, logout, init, ready };
|
async function updateName(name: string) {
|
||||||
|
await account.updateName(name);
|
||||||
|
currentUser.value = await account.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUser,
|
||||||
|
getUserNameById,
|
||||||
|
hasRequiredRole,
|
||||||
|
register,
|
||||||
|
updateName,
|
||||||
|
login,
|
||||||
|
googleLogin,
|
||||||
|
discordLogin,
|
||||||
|
createTokenSession,
|
||||||
|
tokenLogin,
|
||||||
|
logout,
|
||||||
|
init,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
|
import { Models } from 'appwrite';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
// const boatSource = null;
|
// const boatSource = null;
|
||||||
|
|
||||||
export interface Boat {
|
export interface Boat extends Models.Document {
|
||||||
$id: string;
|
$id: string;
|
||||||
name: string;
|
name: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
imgsrc?: string;
|
imgSrc?: string;
|
||||||
iconsrc?: string;
|
iconSrc?: string;
|
||||||
booking?: {
|
bookingAvailable: boolean;
|
||||||
available: boolean;
|
requiredCerts: string[];
|
||||||
requiredCerts: string[];
|
maxPassengers: number;
|
||||||
maxPassengers: number;
|
defects: {
|
||||||
};
|
|
||||||
defects?: {
|
|
||||||
type: string;
|
type: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -23,73 +24,25 @@ export interface Boat {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSampleData = () => [
|
export const useBoatStore = defineStore('boat', () => {
|
||||||
{
|
const boats = ref<Boat[]>([]);
|
||||||
$id: '1',
|
|
||||||
name: 'ProjectX',
|
|
||||||
displayName: 'PX',
|
|
||||||
class: 'J/27',
|
|
||||||
year: 1981,
|
|
||||||
imgsrc: '/tmpimg/j27.png',
|
|
||||||
iconsrc: '/tmpimg/projectx_avatar256.png',
|
|
||||||
booking: { available: true, maxPassengers: 8, requiredCerts: [] },
|
|
||||||
defects: [
|
|
||||||
{
|
|
||||||
type: 'engine',
|
|
||||||
severity: 'moderate',
|
|
||||||
description: 'Fuel line leaks at engine fitting.',
|
|
||||||
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
|
|
||||||
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
|
|
||||||
and rough engine performance.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'rigging',
|
|
||||||
severity: 'moderate',
|
|
||||||
description: 'Tiller extension is broken.',
|
|
||||||
detail:
|
|
||||||
'The tiller extension swivel is broken, and will not attach to the tiller.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$id: '2',
|
|
||||||
name: 'Take5',
|
|
||||||
displayName: 'T5',
|
|
||||||
class: 'J/27',
|
|
||||||
year: 1985,
|
|
||||||
imgsrc: '/tmpimg/j27.png',
|
|
||||||
iconsrc: '/tmpimg/take5_avatar32.png',
|
|
||||||
booking: { available: true, maxPassengers: 8, requiredCerts: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$id: '3',
|
|
||||||
name: 'WeeBeestie',
|
|
||||||
displayName: 'WB',
|
|
||||||
class: 'Capri 25',
|
|
||||||
year: 1989,
|
|
||||||
imgsrc: '/tmpimg/capri25.png',
|
|
||||||
booking: { available: true, maxPassengers: 6, requiredCerts: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$id: '4',
|
|
||||||
name: 'Just My Imagination',
|
|
||||||
displayName: 'JMI',
|
|
||||||
class: 'Capri 25',
|
|
||||||
year: 1989,
|
|
||||||
imgsrc: '/tmpimg/capri25.png',
|
|
||||||
booking: { available: true, maxPassengers: 8, requiredCerts: [] },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useBoatStore = defineStore('boat', {
|
async function fetchBoats() {
|
||||||
state: () => ({
|
try {
|
||||||
boats: getSampleData(),
|
const response = await databases.listDocuments(
|
||||||
}),
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.boat
|
||||||
|
);
|
||||||
|
boats.value = response.documents as Boat[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch boats', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getters: {},
|
const getBoatById = (id: string | null | undefined): Boat | null => {
|
||||||
|
if (!id) return null;
|
||||||
|
return boats.value?.find((b) => b.$id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
actions: {
|
return { boats, fetchBoats, getBoatById };
|
||||||
// update () {
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
161
src/stores/interval.ts
Normal file
161
src/stores/interval.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { Boat } from './boat';
|
||||||
|
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { Interval } from './schedule.types';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { useReservationStore } from './reservation';
|
||||||
|
import { LoadingTypes } from 'src/utils/misc';
|
||||||
|
import { useRealtimeStore } from './realtime';
|
||||||
|
|
||||||
|
export const useIntervalStore = defineStore('interval', () => {
|
||||||
|
const intervals = ref(new Map<string, Interval>()); // Intervals by DocID
|
||||||
|
const dateStatus = ref(new Map<string, LoadingTypes>()); // State of load by date
|
||||||
|
|
||||||
|
const selectedDate = ref<string>(today());
|
||||||
|
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
|
||||||
|
const realtimeStore = useRealtimeStore();
|
||||||
|
|
||||||
|
realtimeStore.register(
|
||||||
|
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
|
||||||
|
(response) => {
|
||||||
|
const payload = response.payload as Interval;
|
||||||
|
if (!payload.$id) return;
|
||||||
|
if (
|
||||||
|
response.events.includes('databases.*.collections.*.documents.*.delete')
|
||||||
|
) {
|
||||||
|
intervals.value.delete(payload.$id);
|
||||||
|
} else {
|
||||||
|
intervals.value.set(payload.$id, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const getIntervals = (date: Timestamp | string, boat?: Boat) => {
|
||||||
|
const searchDate = typeof date === 'string' ? date : date.date;
|
||||||
|
const dayStart = new Date(searchDate + 'T00:00');
|
||||||
|
const dayEnd = new Date(searchDate + 'T23:59');
|
||||||
|
if (dateStatus.value.get(searchDate) === undefined) {
|
||||||
|
dateStatus.value.set(searchDate, 'pending');
|
||||||
|
fetchIntervals(searchDate);
|
||||||
|
}
|
||||||
|
return computed(() => {
|
||||||
|
return Array.from(intervals.value.values()).filter((interval) => {
|
||||||
|
const intervalStart = new Date(interval.start);
|
||||||
|
const intervalEnd = new Date(interval.end);
|
||||||
|
|
||||||
|
const isWithinDay = intervalStart < dayEnd && intervalEnd > dayStart;
|
||||||
|
const matchesBoat = boat ? boat.$id === interval.resource : true;
|
||||||
|
return isWithinDay && matchesBoat;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableIntervals = (date: Timestamp | string, boat?: Boat) => {
|
||||||
|
return computed(() =>
|
||||||
|
getIntervals(date, boat).value.filter((interval) => {
|
||||||
|
return !reservationStore.isResourceTimeOverlapped(
|
||||||
|
interval.resource,
|
||||||
|
new Date(interval.start),
|
||||||
|
new Date(interval.end)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchInterval(id: string): Promise<Interval> {
|
||||||
|
return (await databases.getDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
id
|
||||||
|
)) as Interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIntervals(dateString: string) {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
[
|
||||||
|
Query.greaterThanEqual(
|
||||||
|
'end',
|
||||||
|
new Date(dateString + 'T00:00').toISOString()
|
||||||
|
),
|
||||||
|
Query.lessThanEqual(
|
||||||
|
'start',
|
||||||
|
new Date(dateString + 'T23:59').toISOString()
|
||||||
|
),
|
||||||
|
Query.limit(50), // We are asuming that we won't have more than 50 intervals per day.
|
||||||
|
]
|
||||||
|
);
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
intervals.value.set(d.$id, d as Interval)
|
||||||
|
);
|
||||||
|
dateStatus.value.set(dateString, 'loaded');
|
||||||
|
console.info(`Loaded ${response.documents.length} intervals from server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch intervals', error);
|
||||||
|
dateStatus.value.set(dateString, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
ID.unique(),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
intervals.value.set(response.$id, response as Interval);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
if (interval.$id) {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
interval.$id,
|
||||||
|
{ ...interval, $id: undefined }
|
||||||
|
);
|
||||||
|
intervals.value.set(response.$id, response as Interval);
|
||||||
|
console.info(`Saved Interval: ${interval.$id}`);
|
||||||
|
} else {
|
||||||
|
console.error('Update interval called without an ID');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deleteInterval = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
intervals.value.delete(id);
|
||||||
|
console.info(`Deleted interval: ${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIntervals,
|
||||||
|
getAvailableIntervals,
|
||||||
|
fetchIntervals,
|
||||||
|
fetchInterval,
|
||||||
|
createInterval,
|
||||||
|
updateInterval,
|
||||||
|
deleteInterval,
|
||||||
|
selectedDate,
|
||||||
|
intervals,
|
||||||
|
};
|
||||||
|
});
|
||||||
97
src/stores/intervalTemplate.ts
Normal file
97
src/stores/intervalTemplate.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Ref, ref } from 'vue';
|
||||||
|
import { IntervalTemplate } from './schedule.types';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ID, Models } from 'appwrite';
|
||||||
|
import { arrayToTimeTuples } from 'src/utils/schedule';
|
||||||
|
|
||||||
|
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||||
|
const intervalTemplates = ref<IntervalTemplate[]>([]);
|
||||||
|
|
||||||
|
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
|
||||||
|
// Should subscribe to get new intervaltemplates when they are created
|
||||||
|
if (!intervalTemplates.value) fetchIntervalTemplates();
|
||||||
|
return intervalTemplates;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchIntervalTemplates() {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate
|
||||||
|
);
|
||||||
|
intervalTemplates.value = response.documents.map(
|
||||||
|
(d: Models.Document): IntervalTemplate => {
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
timeTuples: arrayToTimeTuples(d.timeTuple),
|
||||||
|
} as IntervalTemplate;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timeblock templates', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIntervalTemplate = async (template: IntervalTemplate) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
ID.unique(),
|
||||||
|
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||||
|
);
|
||||||
|
intervalTemplates.value.push(response as IntervalTemplate);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deleteIntervalTemplate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
intervalTemplates.value = intervalTemplates.value.filter(
|
||||||
|
(template) => template.$id !== id
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateIntervalTemplate = async (
|
||||||
|
template: IntervalTemplate,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: template.name,
|
||||||
|
timeTuple: template.timeTuples.flat(2),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
intervalTemplates.value = intervalTemplates.value.map((b) =>
|
||||||
|
b.$id !== id
|
||||||
|
? b
|
||||||
|
: ({
|
||||||
|
...response,
|
||||||
|
timeTuples: arrayToTimeTuples(response.timeTuple),
|
||||||
|
} as IntervalTemplate)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIntervalTemplates,
|
||||||
|
fetchIntervalTemplates,
|
||||||
|
createIntervalTemplate,
|
||||||
|
deleteIntervalTemplate,
|
||||||
|
updateIntervalTemplate,
|
||||||
|
};
|
||||||
|
});
|
||||||
21
src/stores/realtime.ts
Normal file
21
src/stores/realtime.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { client } from 'src/boot/appwrite';
|
||||||
|
import { Interval } from './schedule.types';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { RealtimeResponseEvent } from 'appwrite';
|
||||||
|
|
||||||
|
export const useRealtimeStore = defineStore('realtime', () => {
|
||||||
|
const subscriptions = ref<Map<string, () => void>>(new Map());
|
||||||
|
|
||||||
|
const register = (
|
||||||
|
channel: string,
|
||||||
|
fn: (response: RealtimeResponseEvent<Interval>) => void
|
||||||
|
) => {
|
||||||
|
if (subscriptions.value.has(channel)) return; // Already subscribed. But maybe different callback fn?
|
||||||
|
subscriptions.value.set(channel, client.subscribe(channel, fn));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
register,
|
||||||
|
};
|
||||||
|
});
|
||||||
285
src/stores/reservation.ts
Normal file
285
src/stores/reservation.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { Reservation } from './schedule.types';
|
||||||
|
import { ComputedRef, computed, reactive } from 'vue';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { date, useQuasar } from 'quasar';
|
||||||
|
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { LoadingTypes } from 'src/utils/misc';
|
||||||
|
import { useAuthStore } from './auth';
|
||||||
|
import { isPast } from 'src/utils/schedule';
|
||||||
|
import { useRealtimeStore } from './realtime';
|
||||||
|
|
||||||
|
export const useReservationStore = defineStore('reservation', () => {
|
||||||
|
const reservations = reactive<Map<string, Reservation>>(new Map());
|
||||||
|
const datesLoaded = reactive<Record<string, LoadingTypes>>({});
|
||||||
|
const userReservations = reactive<Map<string, Reservation>>(new Map());
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const realtimeStore = useRealtimeStore();
|
||||||
|
|
||||||
|
realtimeStore.register(
|
||||||
|
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
|
||||||
|
(response) => {
|
||||||
|
const payload = response.payload as Reservation;
|
||||||
|
if (payload.$id) {
|
||||||
|
if (
|
||||||
|
response.events.includes(
|
||||||
|
'databases.*.collections.*.documents.*.delete'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
reservations.delete(payload.$id);
|
||||||
|
userReservations.delete(payload.$id);
|
||||||
|
} else {
|
||||||
|
reservations.set(payload.$id, payload);
|
||||||
|
if (payload.user === authStore.currentUser?.$id)
|
||||||
|
userReservations.set(payload.$id, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Fetch reservations for a specific date range
|
||||||
|
const fetchReservationsForDateRange = async (
|
||||||
|
start: string = today(),
|
||||||
|
end: string = start
|
||||||
|
) => {
|
||||||
|
const startDate = new Date(start < end ? start : end + 'T00:00');
|
||||||
|
const endDate = new Date(start < end ? end : start + 'T23:59');
|
||||||
|
|
||||||
|
if (getUnloadedDates(startDate, endDate).length === 0) return;
|
||||||
|
|
||||||
|
setDateLoaded(startDate, endDate, 'pending');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
[
|
||||||
|
Query.greaterThanEqual('end', startDate.toISOString()),
|
||||||
|
Query.lessThanEqual('start', endDate.toISOString()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
reservations.set(d.$id, d as Reservation)
|
||||||
|
);
|
||||||
|
setDateLoaded(startDate, endDate, 'loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservations', error);
|
||||||
|
setDateLoaded(startDate, endDate, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getReservationById = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.getDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return response as Reservation;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservation: ', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOrUpdateReservation = async (
|
||||||
|
reservation: Reservation
|
||||||
|
): Promise<Reservation> => {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
if (reservation.$id) {
|
||||||
|
response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
reservation.$id,
|
||||||
|
reservation
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
ID.unique(),
|
||||||
|
reservation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reservations.set(response.$id, response as Reservation);
|
||||||
|
userReservations.set(response.$id, response as Reservation);
|
||||||
|
console.info('Reservation booked: ', response);
|
||||||
|
return response as Reservation;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating Reservation: ' + e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteReservation = async (
|
||||||
|
reservation: string | Reservation | null | undefined
|
||||||
|
) => {
|
||||||
|
if (!reservation) return false;
|
||||||
|
const id = typeof reservation === 'string' ? reservation : reservation.$id;
|
||||||
|
if (!id) return false;
|
||||||
|
|
||||||
|
const status = $q.notify({
|
||||||
|
color: 'secondary',
|
||||||
|
textColor: 'white',
|
||||||
|
message: 'Deleting Reservation',
|
||||||
|
spinner: true,
|
||||||
|
closeBtn: 'Dismiss',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 0,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
reservations.delete(id);
|
||||||
|
userReservations.delete(id);
|
||||||
|
console.info(`Deleted reservation: ${id}`);
|
||||||
|
status({
|
||||||
|
color: 'warning',
|
||||||
|
message: 'Reservation Deleted',
|
||||||
|
spinner: false,
|
||||||
|
icon: 'delete',
|
||||||
|
timeout: 4000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting reservation: ' + e);
|
||||||
|
status({
|
||||||
|
color: 'negative',
|
||||||
|
message: 'Failed to Delete Reservation',
|
||||||
|
spinner: false,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the loading state for dates
|
||||||
|
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
|
||||||
|
if (start > end) return [];
|
||||||
|
let curDate = start;
|
||||||
|
while (curDate < end) {
|
||||||
|
datesLoaded[(parseDate(curDate) as Timestamp).date] = state;
|
||||||
|
curDate = date.addToDate(curDate, { days: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnloadedDates = (start: Date, end: Date): string[] => {
|
||||||
|
if (start > end) return [];
|
||||||
|
let curDate = start;
|
||||||
|
const unloaded = [];
|
||||||
|
while (curDate < end) {
|
||||||
|
const parsedDate = (parseDate(curDate) as Timestamp).date;
|
||||||
|
if (datesLoaded[parsedDate] === undefined) unloaded.push(parsedDate);
|
||||||
|
curDate = date.addToDate(curDate, { days: 1 });
|
||||||
|
}
|
||||||
|
return unloaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get reservations by date and optionally filter by boat
|
||||||
|
const getReservationsByDate = (
|
||||||
|
searchDate: string,
|
||||||
|
boat?: string
|
||||||
|
): ComputedRef<Reservation[]> => {
|
||||||
|
if (!datesLoaded[searchDate]) {
|
||||||
|
fetchReservationsForDateRange(searchDate);
|
||||||
|
}
|
||||||
|
const dayStart = new Date(searchDate + 'T00:00');
|
||||||
|
const dayEnd = new Date(searchDate + 'T23:59');
|
||||||
|
|
||||||
|
return computed(() => {
|
||||||
|
return Array.from(reservations.values()).filter((reservation) => {
|
||||||
|
const reservationStart = new Date(reservation.start);
|
||||||
|
const reservationEnd = new Date(reservation.end);
|
||||||
|
|
||||||
|
const isWithinDay =
|
||||||
|
reservationStart < dayEnd && reservationEnd > dayStart;
|
||||||
|
const matchesBoat = boat ? boat === reservation.resource : true;
|
||||||
|
return isWithinDay && matchesBoat;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get conflicting reservations for a resource within a time range
|
||||||
|
const getConflictingReservations = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): Reservation[] => {
|
||||||
|
return Array.from(reservations.values()).filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.resource === resource &&
|
||||||
|
new Date(entry.start) < end &&
|
||||||
|
new Date(entry.end) > start
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a resource has time overlap
|
||||||
|
const isResourceTimeOverlapped = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): boolean => {
|
||||||
|
return getConflictingReservations(resource, start, end).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a reservation overlaps with existing reservations
|
||||||
|
const isReservationOverlapped = (res: Reservation): boolean => {
|
||||||
|
return isResourceTimeOverlapped(
|
||||||
|
res.resource,
|
||||||
|
new Date(res.start),
|
||||||
|
new Date(res.end)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserReservations = async () => {
|
||||||
|
if (!authStore.currentUser) return;
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
[Query.equal('user', authStore.currentUser.$id)]
|
||||||
|
);
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
userReservations.set(d.$id, d as Reservation)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservations for user: ', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedUserReservations = computed((): Reservation[] =>
|
||||||
|
[...userReservations.values()].sort(
|
||||||
|
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const futureUserReservations = computed((): Reservation[] => {
|
||||||
|
if (!sortedUserReservations.value) return [];
|
||||||
|
return sortedUserReservations.value.filter((b) => !isPast(b.end));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pastUserReservations = computed((): Reservation[] => {
|
||||||
|
if (!sortedUserReservations.value) return [];
|
||||||
|
return sortedUserReservations.value?.filter((b) => isPast(b.end));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getReservationsByDate,
|
||||||
|
getReservationById,
|
||||||
|
createOrUpdateReservation,
|
||||||
|
deleteReservation,
|
||||||
|
fetchReservationsForDateRange,
|
||||||
|
isReservationOverlapped,
|
||||||
|
isResourceTimeOverlapped,
|
||||||
|
getConflictingReservations,
|
||||||
|
fetchUserReservations,
|
||||||
|
sortedUserReservations,
|
||||||
|
futureUserReservations,
|
||||||
|
pastUserReservations,
|
||||||
|
userReservations,
|
||||||
|
};
|
||||||
|
});
|
||||||
65
src/stores/sampledata/boat.ts
Normal file
65
src/stores/sampledata/boat.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export const getSampleData = () => [
|
||||||
|
{
|
||||||
|
$id: '1',
|
||||||
|
name: 'ProjectX',
|
||||||
|
displayName: 'PX',
|
||||||
|
class: 'J/27',
|
||||||
|
year: 1981,
|
||||||
|
imgSrc: '/tmpimg/projectX.jpg',
|
||||||
|
iconSrc: '/tmpimg/projectx_avatar256.png',
|
||||||
|
bookingAvailable: true,
|
||||||
|
maxPassengers: 8,
|
||||||
|
requiredCerts: [],
|
||||||
|
defects: [
|
||||||
|
{
|
||||||
|
type: 'engine',
|
||||||
|
severity: 'moderate',
|
||||||
|
description: 'Fuel line leaks at engine fitting.',
|
||||||
|
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
|
||||||
|
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
|
||||||
|
and rough engine performance.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'rigging',
|
||||||
|
severity: 'moderate',
|
||||||
|
description: 'Tiller extension is broken.',
|
||||||
|
detail:
|
||||||
|
'The tiller extension swivel is broken, and will not attach to the tiller.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: '2',
|
||||||
|
name: 'Take5',
|
||||||
|
displayName: 'T5',
|
||||||
|
class: 'J/27',
|
||||||
|
year: 1985,
|
||||||
|
imgSrc: '/tmpimg/j27.png',
|
||||||
|
iconsrc: '/tmpimg/take5_avatar32.png',
|
||||||
|
bookingAvailable: true,
|
||||||
|
maxPassengers: 8,
|
||||||
|
requiredCerts: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: '3',
|
||||||
|
name: 'WeeBeestie',
|
||||||
|
displayName: 'WB',
|
||||||
|
class: 'Capri 25',
|
||||||
|
year: 1989,
|
||||||
|
imgSrc: '/tmpimg/capri25.png',
|
||||||
|
bookingAvailable: true,
|
||||||
|
maxPassengers: 6,
|
||||||
|
requiredCerts: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$id: '4',
|
||||||
|
name: 'Just My Imagination',
|
||||||
|
displayName: 'JMI',
|
||||||
|
class: 'Sirius 28',
|
||||||
|
year: 1989,
|
||||||
|
imgSrc: '/tmpimg/JMI.jpg',
|
||||||
|
bookingAvailable: true,
|
||||||
|
maxPassengers: 8,
|
||||||
|
requiredCerts: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -10,24 +10,25 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
StatusTypes,
|
StatusTypes,
|
||||||
Reservation,
|
Reservation,
|
||||||
TimeBlockTemplate,
|
IntervalTemplate,
|
||||||
Timeblock,
|
Interval,
|
||||||
|
TimeTuple,
|
||||||
} from '../schedule.types';
|
} from '../schedule.types';
|
||||||
|
|
||||||
export const templateA: TimeBlockTemplate = {
|
export const templateA: IntervalTemplate = {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'WeekdayBlocks',
|
name: 'WeekdayBlocks',
|
||||||
blocks: [
|
timeTuples: [
|
||||||
['08:00', '12:00'],
|
['08:00', '12:00'],
|
||||||
['12:00', '16:00'],
|
['12:00', '16:00'],
|
||||||
['17:00', '21:00'],
|
['17:00', '21:00'],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const templateB: TimeBlockTemplate = {
|
export const templateB: IntervalTemplate = {
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'WeekendBlocks',
|
name: 'WeekendBlocks',
|
||||||
blocks: [
|
timeTuples: [
|
||||||
['07:00', '10:00'],
|
['07:00', '10:00'],
|
||||||
['10:00', '13:00'],
|
['10:00', '13:00'],
|
||||||
['13:00', '16:00'],
|
['13:00', '16:00'],
|
||||||
@@ -35,21 +36,21 @@ export const templateB: TimeBlockTemplate = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSampleTimeBlocks(): Timeblock[] {
|
export function getSampleIntervals(): Interval[] {
|
||||||
// Hard-code 30 days worth of blocks, for now. Make them random templates
|
// Hard-code 30 days worth of blocks, for now. Make them random templates
|
||||||
const boats = useBoatStore().boats;
|
const boats = useBoatStore().boats;
|
||||||
const result: Timeblock[] = [];
|
const result: Interval[] = [];
|
||||||
const tsToday: Timestamp = parseTimestamp(today()) as Timestamp;
|
const tsToday: Timestamp = parseTimestamp(today()) as Timestamp;
|
||||||
|
|
||||||
for (let i = 0; i <= 30; i++) {
|
for (let i = 0; i <= 30; i++) {
|
||||||
const template = templateB;
|
const template = templateB;
|
||||||
result.push(
|
result.push(
|
||||||
...boats
|
...boats
|
||||||
.map((b): Timeblock[] => {
|
.map((b): Interval[] => {
|
||||||
return template.blocks.map((t): Timeblock => {
|
return template.blocks.map((t: TimeTuple): Interval => {
|
||||||
return {
|
return {
|
||||||
id: 'id' + Math.random().toString(32).slice(2),
|
$id: 'id' + Math.random().toString(32).slice(2),
|
||||||
boatId: b.$id,
|
resource: b.$id,
|
||||||
start: addToDate(tsToday, { day: i }).date + ' ' + t[0],
|
start: addToDate(tsToday, { day: i }).date + ' ' + t[0],
|
||||||
end: addToDate(tsToday, { day: i }).date + ' ' + t[1],
|
end: addToDate(tsToday, { day: i }).date + ' ' + t[1],
|
||||||
};
|
};
|
||||||
@@ -68,47 +69,53 @@ export function getSampleReservations(): Reservation[] {
|
|||||||
user: 'John Smith',
|
user: 'John Smith',
|
||||||
start: '7:00',
|
start: '7:00',
|
||||||
end: '10:00',
|
end: '10:00',
|
||||||
boat: '1',
|
boat: '66359729003825946ae1',
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
|
reason: 'Open Sail',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
user: 'Bob Barker',
|
user: 'Bob Barker',
|
||||||
start: '16:00',
|
start: '16:00',
|
||||||
end: '19:00',
|
end: '19:00',
|
||||||
boat: '1',
|
boat: '66359729003825946ae1',
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
|
reason: 'Open Sail',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
user: 'Peter Parker',
|
user: 'Peter Parker',
|
||||||
start: '7:00',
|
start: '7:00',
|
||||||
end: '13:00',
|
end: '13:00',
|
||||||
boat: '4',
|
boat: '663597030029b71c7a9b',
|
||||||
status: 'tentative',
|
status: 'tentative',
|
||||||
|
reason: 'Open Sail',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
user: 'Vince McMahon',
|
user: 'Vince McMahon',
|
||||||
start: '10:00',
|
start: '10:00',
|
||||||
end: '13:00',
|
end: '13:00',
|
||||||
boat: '2',
|
boat: '663597030029b71c7a9b',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
reason: 'Open Sail',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
user: 'Heather Graham',
|
user: 'Heather Graham',
|
||||||
start: '13:00',
|
start: '13:00',
|
||||||
end: '19:00',
|
end: '19:00',
|
||||||
boat: '4',
|
boat: '663596b9000235ffea55',
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
|
reason: 'Private Sail',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: '6',
|
||||||
user: 'Lawrence Fishburne',
|
user: 'Lawrence Fishburne',
|
||||||
start: '13:00',
|
start: '13:00',
|
||||||
end: '16:00',
|
end: '16:00',
|
||||||
boat: '3',
|
boat: '663596b9000235ffea55',
|
||||||
|
reason: 'Open Sail',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const boatStore = useBoatStore();
|
const boatStore = useBoatStore();
|
||||||
@@ -130,11 +137,15 @@ export function getSampleReservations(): Reservation[] {
|
|||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
user: entry.user,
|
user: entry.user,
|
||||||
start: date.adjustDate(now, makeOpts(splitTime(entry.start))),
|
start: date
|
||||||
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
|
.adjustDate(now, makeOpts(splitTime(entry.start)))
|
||||||
resource: boat,
|
.toISOString(),
|
||||||
|
end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(),
|
||||||
|
resource: boat.$id,
|
||||||
reservationDate: now,
|
reservationDate: now,
|
||||||
|
reason: entry.reason,
|
||||||
status: entry.status as StatusTypes,
|
status: entry.status as StatusTypes,
|
||||||
|
comment: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { Boat } from './boat';
|
|
||||||
import {
|
|
||||||
Timestamp,
|
|
||||||
parseDate,
|
|
||||||
parsed,
|
|
||||||
compareDate,
|
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
|
||||||
|
|
||||||
import { Reservation, Timeblock } from './schedule.types';
|
|
||||||
import {
|
|
||||||
getSampleReservations,
|
|
||||||
getSampleTimeBlocks,
|
|
||||||
} from './sampledata/schedule';
|
|
||||||
|
|
||||||
export const useScheduleStore = defineStore('schedule', () => {
|
|
||||||
// TODO: Implement functions to dynamically pull this data.
|
|
||||||
const reservations = ref<Reservation[]>(getSampleReservations());
|
|
||||||
const timeblocks = ref<Timeblock[]>(getSampleTimeBlocks());
|
|
||||||
|
|
||||||
const getTimeblocksForDate = (date: string): Timeblock[] => {
|
|
||||||
return timeblocks.value.filter((b) =>
|
|
||||||
compareDate(parsed(b.start) as Timestamp, parsed(date) as Timestamp)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBoatReservations = (
|
|
||||||
searchDate: Timestamp,
|
|
||||||
boat?: string
|
|
||||||
): Reservation[] => {
|
|
||||||
return reservations.value.filter((x) => {
|
|
||||||
return (
|
|
||||||
((parseDate(x.start)?.date == searchDate.date ||
|
|
||||||
parseDate(x.end)?.date == searchDate.date) && // Part of reservation falls on day
|
|
||||||
x.resource != undefined && // A boat is defined
|
|
||||||
!boat) ||
|
|
||||||
x.resource.$id == boat // A specific boat has been passed, and matches
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// const getConflicts = (timeblock: Timeblock, boat: Boat) => {
|
|
||||||
// const start = date.buildDate({
|
|
||||||
// hour: timeblock.start.hour,
|
|
||||||
// minute: timeblock.start.minute,
|
|
||||||
// second: 0,
|
|
||||||
// millisecond: 0,
|
|
||||||
// });
|
|
||||||
// const end = date.buildDate({
|
|
||||||
// hour: timeblock.end.hour,
|
|
||||||
// minute: timeblock.end.minute,
|
|
||||||
// second: 0,
|
|
||||||
// millisecond: 0,
|
|
||||||
// });
|
|
||||||
// return scheduleStore.getConflictingReservations(boat, start, end);
|
|
||||||
// };
|
|
||||||
const getConflictingReservations = (
|
|
||||||
resource: Boat,
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
): Reservation[] => {
|
|
||||||
const overlapped = reservations.value.filter(
|
|
||||||
(entry: Reservation) =>
|
|
||||||
entry.resource.$id == resource.$id &&
|
|
||||||
entry.start < end &&
|
|
||||||
entry.end > start
|
|
||||||
);
|
|
||||||
return overlapped;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isResourceTimeOverlapped = (
|
|
||||||
resource: Boat,
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
): boolean => {
|
|
||||||
return getConflictingReservations(resource, start, end).length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReservationOverlapped = (res: Reservation): boolean => {
|
|
||||||
return isResourceTimeOverlapped(res.resource, res.start, res.end);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNewId = (): string => {
|
|
||||||
return [...Array(20)]
|
|
||||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
|
||||||
.join('');
|
|
||||||
// Trivial placeholder
|
|
||||||
//return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addOrCreateReservation = (reservation: Reservation) => {
|
|
||||||
const index = reservations.value.findIndex(
|
|
||||||
(res) => res.id == reservation.id
|
|
||||||
);
|
|
||||||
index != -1
|
|
||||||
? (reservations.value[index] = reservation)
|
|
||||||
: reservations.value.push(reservation);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
reservations,
|
|
||||||
getBoatReservations,
|
|
||||||
getConflictingReservations,
|
|
||||||
getTimeblocksForDate,
|
|
||||||
getNewId,
|
|
||||||
addOrCreateReservation,
|
|
||||||
isReservationOverlapped,
|
|
||||||
isResourceTimeOverlapped,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import type { Boat } from './boat';
|
import { Models } from 'appwrite';
|
||||||
|
|
||||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||||
export interface Reservation {
|
export type Reservation = Interval & {
|
||||||
id: string;
|
|
||||||
user: string;
|
user: string;
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
resource: Boat;
|
|
||||||
reservationDate: Date;
|
|
||||||
status?: StatusTypes;
|
status?: StatusTypes;
|
||||||
}
|
reason: string;
|
||||||
|
comment: string;
|
||||||
|
members?: string[];
|
||||||
|
guests?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||||
// Within 24 hrs, any available slot
|
// Within 24 hrs, any available slot
|
||||||
/* TODO: Figure out how best to separate out where qcalendar bits should be.
|
/* TODO: Figure out how best to separate out where qcalendar bits should be.
|
||||||
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
|
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
|
||||||
objects in here? */
|
objects in here? */
|
||||||
|
|
||||||
export type timeTuple = [start: string, end: string];
|
export type TimeTuple = [start: string, end: string];
|
||||||
export interface Timeblock {
|
|
||||||
id: string;
|
export type Interval = Partial<Models.Document> & {
|
||||||
boatId: string;
|
resource: string;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
selected?: false;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeBlockTemplate {
|
export type IntervalTemplate = Partial<Models.Document> & {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
blocks: timeTuple[];
|
timeTuples: TimeTuple[];
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
await this.fetchSkillTags();
|
await this.fetchSkillTags();
|
||||||
const response = await databases.listDocuments(
|
const response = await databases.listDocuments(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTask
|
AppwriteIds.collection.task
|
||||||
);
|
);
|
||||||
this.tasks = response.documents as Task[];
|
this.tasks = response.documents as Task[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -60,7 +60,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
const response = await databases.listDocuments(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTaskTags
|
AppwriteIds.collection.taskTags
|
||||||
);
|
);
|
||||||
this.taskTags = response.documents as TaskTag[];
|
this.taskTags = response.documents as TaskTag[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,7 +72,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
const response = await databases.listDocuments(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdSkillTags
|
AppwriteIds.collection.skillTags
|
||||||
);
|
);
|
||||||
this.skillTags = response.documents as SkillTag[];
|
this.skillTags = response.documents as SkillTag[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,9 +86,9 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await databases.deleteDocument(
|
await databases.deleteDocument(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTask,
|
AppwriteIds.collection.task,
|
||||||
docId
|
docId
|
||||||
);
|
);
|
||||||
this.tasks = this.tasks.filter((task) => docId !== task.$id);
|
this.tasks = this.tasks.filter((task) => docId !== task.$id);
|
||||||
@@ -102,7 +102,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
try {
|
try {
|
||||||
const response = await databases.createDocument(
|
const response = await databases.createDocument(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTask,
|
AppwriteIds.collection.task,
|
||||||
ID.unique(),
|
ID.unique(),
|
||||||
newTask
|
newTask
|
||||||
);
|
);
|
||||||
@@ -125,7 +125,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
try {
|
try {
|
||||||
const response = await databases.updateDocument(
|
const response = await databases.updateDocument(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTask,
|
AppwriteIds.collection.task,
|
||||||
task.$id,
|
task.$id,
|
||||||
newTask
|
newTask
|
||||||
);
|
);
|
||||||
@@ -151,7 +151,6 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
const result = state.tasks.filter((task) =>
|
const result = state.tasks.filter((task) =>
|
||||||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
console.log(result);
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
9
src/utils/misc.ts
Normal file
9
src/utils/misc.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function getNewId(): string {
|
||||||
|
return [...Array(20)]
|
||||||
|
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||||
|
.join('');
|
||||||
|
// Trivial placeholder
|
||||||
|
//return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;
|
||||||
90
src/utils/schedule.ts
Normal file
90
src/utils/schedule.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { date } from 'quasar';
|
||||||
|
import { Boat } from 'src/stores/boat';
|
||||||
|
import {
|
||||||
|
Interval,
|
||||||
|
IntervalTemplate,
|
||||||
|
TimeTuple,
|
||||||
|
} from 'src/stores/schedule.types';
|
||||||
|
|
||||||
|
export function arrayToTimeTuples(arr: string[]) {
|
||||||
|
const timeTuples: TimeTuple[] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += 2) {
|
||||||
|
timeTuples.push([arr[i], arr[i + 1]]);
|
||||||
|
}
|
||||||
|
return timeTuples;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||||
|
return intervalsOverlapped(
|
||||||
|
tuples.map((tuples) => {
|
||||||
|
return {
|
||||||
|
resource: '',
|
||||||
|
start: '01/01/2001 ' + tuples[0],
|
||||||
|
end: '01/01/2001 ' + tuples[1],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).map((t) => {
|
||||||
|
return { ...t, start: t.start.split(' ')[1], end: t.end.split(' ')[1] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intervalsOverlapped(
|
||||||
|
blocks: Interval[] | Interval[]
|
||||||
|
): Interval[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
blocks
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
|
||||||
|
.reduce((acc: Interval[], block, i, arr) => {
|
||||||
|
if (i > 0 && block.start < arr[i - 1].end)
|
||||||
|
acc.push(arr[i - 1], block);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
|
||||||
|
return tuples.map((t) => Object.assign([], t));
|
||||||
|
}
|
||||||
|
export function copyIntervalTemplate(
|
||||||
|
template: IntervalTemplate
|
||||||
|
): IntervalTemplate {
|
||||||
|
return {
|
||||||
|
...template,
|
||||||
|
timeTuples: copyTimeTuples(template.timeTuples || []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInterval(
|
||||||
|
resource: Boat,
|
||||||
|
time: TimeTuple,
|
||||||
|
blockDate: string
|
||||||
|
): Interval {
|
||||||
|
/* When the time zone offset is absent, date-only forms are interpreted
|
||||||
|
as a UTC time and date-time forms are interpreted as local time. */
|
||||||
|
const result = {
|
||||||
|
resource: resource.$id,
|
||||||
|
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||||
|
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPast = (itemDate: Date | string): boolean => {
|
||||||
|
if (!(itemDate instanceof Date)) {
|
||||||
|
itemDate = new Date(itemDate);
|
||||||
|
}
|
||||||
|
const currentDate = new Date();
|
||||||
|
return itemDate < currentDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDate(inputDate: string | undefined): string {
|
||||||
|
if (!inputDate) return '';
|
||||||
|
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(inputDate: string | undefined): string {
|
||||||
|
if (!inputDate) return '';
|
||||||
|
return date.formatDate(new Date(inputDate), 'hh:mm A');
|
||||||
|
}
|
||||||
6
tsconfig.vue-tsc.json
Normal file
6
tsconfig.vue-tsc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user