Compare commits
95 Commits
v0.0.2
...
dd631b71bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
dd631b71bb
|
|||
|
b0921ccf32
|
|||
|
78211a33ae
|
|||
|
4a273ccb2f
|
|||
|
3a67f2fbb1
|
|||
|
77619b0741
|
|||
|
ea785887a1
|
|||
|
b860e1d977
|
|||
|
274d0193f7
|
|||
|
033993b1b8
|
|||
|
2872fb867e
|
|||
|
8e73650462
|
|||
|
634cff507c
|
|||
|
fa4d83e42d
|
|||
|
c92f737612
|
|||
|
5792e80112
|
|||
|
db0755a368
|
|||
|
2b61d57a8a
|
|||
|
29f9aeaba4
|
|||
|
28600578f1
|
|||
|
b66afb5692
|
|||
|
2f68877ce6
|
|||
|
0de9991a49
|
|||
|
4faff7cc8c
|
|||
|
c297f1f287
|
|||
|
43e68c8ae7
|
|||
|
e1a784ef45
|
|||
|
d9cfa4ab56
|
|||
|
cb2131ae7e
|
|||
|
de04b53914
|
|||
|
1a18881980
|
|||
|
84867875c5
|
|||
|
ea0bc82c49
|
|||
|
15ef8435f6
|
|||
|
4c2cae7149
|
|||
|
ffaf31bbeb
|
|||
|
6ab1aa26b1
|
|||
|
5d9dbb0653
|
|||
|
299ede4aa9
|
|||
|
b91ba39d06
|
|||
|
8464701082
|
|||
|
b3ce8e59cb
|
|||
|
55071318ca
|
|||
|
b66b63101f
|
|||
|
9db1b4d97c
|
|||
|
71a8c2e8d2
|
|||
|
88738715b6
|
|||
|
53c650d4b0
|
|||
|
deb6a0b8ed
|
|||
|
923d09d713
|
|||
|
d752898865
|
|||
|
435438aaa8
|
|||
|
084aadccef
|
|||
|
468569fa27
|
|||
|
0986d04ea6
|
|||
|
6ff1a69e2b
|
|||
|
052cae2c2e
|
|||
|
29170f9e13
|
|||
|
25ed6df62a
|
|||
|
2f86700fb7
|
|||
|
e7a79736b7
|
|||
|
2d585d499e
|
|||
|
284d5ffcb4
|
|||
|
27a476ae00
|
|||
|
ee7f79550c
|
|||
|
2ef801905b
|
|||
|
752421c9fc
|
|||
|
ce169f6a61
|
|||
|
622b9fc82d
|
|||
|
275f23c421
|
|||
|
88ed4caf5b
|
|||
|
346e395e15
|
|||
|
f30848803b
|
|||
|
96dab93483
|
|||
|
a6abee1ddf
|
|||
|
b20f2bffd6
|
|||
|
f6689cbc5c
|
|||
|
8383605115
|
|||
|
f69614d5c7
|
|||
|
f7902011cc
|
|||
|
e86876ba69
|
|||
|
cd6f2e3ba2
|
|||
|
66e2169f45
|
|||
|
489cc2646b
|
|||
|
295f1f7449
|
|||
|
33a1bc24f6
|
|||
|
d18780bb21
|
|||
|
ef569ac3b1
|
|||
|
9390b7035c
|
|||
|
ac1730401a
|
|||
|
bc41b1a7a1
|
|||
|
ea566d4a42
|
|||
|
573e327a0f
|
|||
|
831e81e892
|
|||
|
39a6ab5fcc
|
@@ -1,5 +1,5 @@
|
|||||||
name: Build BAB Application Deployment Artifact
|
name: Build BAB Application Deployment Artifact
|
||||||
run-name: ${{ gitea.actor }} is building an artifact 🚀
|
run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -15,16 +15,37 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
- name: Install yarn
|
- name: Install yarn
|
||||||
run: npm install --global yarn
|
run: npm install --global yarn
|
||||||
|
- name: Install yarn dependencies
|
||||||
|
run: yarn install
|
||||||
- name: Install Quasar CLI
|
- name: Install Quasar CLI
|
||||||
run: npm install -g @quasar/cli
|
run: yarn global add @quasar/cli
|
||||||
|
- name: Temporary - Invoke custom qcalendar build
|
||||||
|
run: quasar ext invoke @quasar/qcalendar
|
||||||
|
- name: Create env file
|
||||||
|
run: |
|
||||||
|
echo "${{ vars.ENV_FILE }}" > .env.local
|
||||||
|
- name: Show env file
|
||||||
|
run: |
|
||||||
|
/bin/cat .env.local
|
||||||
- name: Build Project
|
- name: Build Project
|
||||||
run: quasar build -m pwa
|
run: quasar build -m pwa
|
||||||
# - name: Archive Production Artifact
|
- name: Get Version Number
|
||||||
# uses: actions/upload-artifact@v2
|
id: get_version
|
||||||
# with:
|
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")"
|
||||||
# name: build-artifact
|
- name: Tarfile
|
||||||
# path: dist/pwa
|
run: |
|
||||||
|
cd dist/pwa
|
||||||
|
tar czf ../../build-${{ steps.get_version.outputs.VERSION }}.tar.gz .
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
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
|
||||||
|
uses: https://github.com/distributhor/workflow-webhook@v3
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ vars.WEBHOOK_URL }}
|
||||||
|
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 }}" }'
|
||||||
|
|||||||
4
appwrite.json
Normal file
4
appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "65ede55a213134f2b688",
|
||||||
|
"projectName": ""
|
||||||
|
}
|
||||||
3
backup/.env
Normal file
3
backup/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
APPWRITE_ENDPOINT=https://apidev.bab.toal.ca/v1
|
||||||
|
APPWRITE_PROJECT_ID=65ede55a213134f2b688
|
||||||
|
APPWRITE_API_KEY=71f7f899ca605b39a3f24a80a23b34f580fd7e735316152bc0d5ed042bd452e7116c4d0a7f3c77d343690d6cce229020c76de1733c754a402f15bbe9b2cab5a6cd7b3a7c1c0d66cede4f6aee99cdfac14898b7a2006a5eaae24529bbcb19b4c2f6563adff5688dda9c15357c9e98b449e50b6794dfb8cc6ab61e9f073b08a11e
|
||||||
4
backup/appwrite.json
Normal file
4
backup/appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "65ede55a213134f2b688",
|
||||||
|
"projectName": ""
|
||||||
|
}
|
||||||
8
docs/planning/personas.md
Normal file
8
docs/planning/personas.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Personas
|
||||||
|
|
||||||
|
- BAB Member
|
||||||
|
- Certified Skipper
|
||||||
|
- Program Administrator
|
||||||
|
- Boatswain
|
||||||
|
- Volunteer
|
||||||
|
- Instructor
|
||||||
40
docs/users_roles_permissions.md
Normal file
40
docs/users_roles_permissions.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Users, Roles and Permissions
|
||||||
|
|
||||||
|
This is the design document for https://gitea.toal.ca/oys/bab-app/issues/11
|
||||||
|
|
||||||
|
## Backend Concepts
|
||||||
|
|
||||||
|
Utilizing the AppWrite backend provides us with some basic concepts we can use:
|
||||||
|
|
||||||
|
### Users, Groups, and Labels
|
||||||
|
|
||||||
|
#### Teams
|
||||||
|
|
||||||
|
Teams are AppWrite groups of users. Teams can be assigned roles, which can be assigned permissions. Teams "contain" users. A team has more permissions to manage it's members than labels, which are assigned / removed, rather than 'invited / left'.
|
||||||
|
|
||||||
|
#### Labels
|
||||||
|
|
||||||
|
Labels are AppWrite tags for users. Users have Labels as attributes. Like teams, labels can be used for Role / Permission mapping.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
https://appwrite.io/docs/advanced/platform/permissions
|
||||||
|
|
||||||
|
Permissions are fine-grained access control for users and objects. They follow standard "CRUD" patterns.
|
||||||
|
|
||||||
|
## BAB Concepts
|
||||||
|
|
||||||
|
For teams, there will, to start, be the following:
|
||||||
|
|
||||||
|
- `staff` : Individuals with authority / responsibilities
|
||||||
|
- `maintenance` : Staff responsible for maintenance (eg: Boatswain)
|
||||||
|
- `admin`: Administrators of the program / application
|
||||||
|
- `school` : Members of the Sailing School (Instructors & Students)
|
||||||
|
- `student` role : A student in the school
|
||||||
|
- `instructor` role: An instructor in the school
|
||||||
|
- `bab` : Members of the BAB program
|
||||||
|
- `skipper` role: A member who has passed skipper certification
|
||||||
|
|
||||||
|
The following are the initial labels:
|
||||||
|
|
||||||
|
- TBD
|
||||||
27
package.json
27
package.json
@@ -13,35 +13,42 @@
|
|||||||
"build": "quasar build"
|
"build": "quasar build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.4",
|
"@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",
|
||||||
"quasar": "^2.6.0",
|
"vue": "3",
|
||||||
"vue": "^3.0.0",
|
"vue-router": "4"
|
||||||
"vue-router": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quasar/app-vite": "^1.3.0",
|
"@quasar/app-vite": "^1.9.1",
|
||||||
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.15",
|
|
||||||
"@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",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
"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",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"typescript": "^4.5.4",
|
"quasar": "^2.16.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",
|
||||||
"workbox-expiration": "^7.0.0",
|
"workbox-expiration": "^7.0.0",
|
||||||
"workbox-precaching": "^7.0.0",
|
"workbox-precaching": "^7.0.0",
|
||||||
"workbox-routing": "^7.0.0",
|
"workbox-routing": "^7.0.0",
|
||||||
"workbox-strategies": "^7.0.0"
|
"workbox-strategies": "^7.0.0",
|
||||||
|
"yarn": "^1.22.21"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18 || ^16 || ^14.19",
|
"node": "^20 || ^18 || ^16 || ^14.19",
|
||||||
"npm": ">= 6.13.4",
|
"npm": ">= 6.13.4",
|
||||||
"yarn": ">= 1.21.1"
|
"yarn": ">= 1.21.1"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/tmpimg/JMI.jpg
Normal file
BIN
public/tmpimg/JMI.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -48,11 +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: 'hash', // available values: 'hash', 'history'
|
||||||
// vueRouterBase,
|
// vueRouterBase,
|
||||||
// vueDevtools,
|
// vueDevtools,
|
||||||
@@ -72,9 +72,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
|
||||||
@@ -83,6 +94,21 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
// open: true, // opens browser window automatically
|
// open: true, // opens browser window automatically
|
||||||
port: 4000,
|
port: 4000,
|
||||||
strictport: true,
|
strictport: true,
|
||||||
|
// This works around CORS problems when developing locally, using the Appwrite backend
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apidev.bab.toal.ca/',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
'/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: {
|
||||||
// clientPort: 443,
|
// clientPort: 443,
|
||||||
@@ -93,7 +119,9 @@ 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: 'kebab', // or 'kebab' (default) or 'combined'
|
||||||
|
},
|
||||||
|
|
||||||
// iconSet: 'material-icons', // Quasar icon set
|
// iconSet: 'material-icons', // Quasar icon set
|
||||||
// lang: 'en-US', // Quasar language pack
|
// lang: 'en-US', // Quasar language pack
|
||||||
|
|||||||
18
src/App.vue
18
src/App.vue
@@ -2,10 +2,22 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent, onMounted } from 'vue';
|
||||||
|
import { useScheduleStore } from './stores/schedule';
|
||||||
|
import { useBoatStore } from './stores/boat';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import { useReservationStore } from './stores/reservation';
|
||||||
|
|
||||||
export default defineComponent({
|
defineComponent({
|
||||||
name: 'OYS Borrow-a-Boat',
|
name: 'OYS Borrow-a-Boat',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await useAuthStore().init();
|
||||||
|
await useScheduleStore().fetchIntervalTemplates();
|
||||||
|
await useScheduleStore().fetchIntervals();
|
||||||
|
await useReservationStore().fetchReservations();
|
||||||
|
await useBoatStore().fetchBoats();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
BIN
src/assets/OYS-Burgee_square.png
Normal file
BIN
src/assets/OYS-Burgee_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/oysqn_logo_only_bordered.png
Normal file
BIN
src/assets/oysqn_logo_only_bordered.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import { boot } from 'quasar/wrappers';
|
import { boot } from 'quasar/wrappers';
|
||||||
import { Client, Account, Databases, ID } from 'appwrite';
|
import { Client, Account, Databases, Functions, ID } 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';
|
||||||
@@ -13,14 +13,29 @@ const client = new Client();
|
|||||||
// const appDatabaseId = '654ac5044d1c446feb71';
|
// const appDatabaseId = '654ac5044d1c446feb71';
|
||||||
|
|
||||||
// Private self-hosted appwrite
|
// Private self-hosted appwrite
|
||||||
|
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT)
|
||||||
client
|
client
|
||||||
.setEndpoint('https://apidev.bab.toal.ca/v1')
|
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
|
||||||
.setProject('655a7116479b4d5a815f');
|
.setProject(process.env.APPWRITE_API_PROJECT);
|
||||||
//TODO
|
|
||||||
const appDatabaseId = '';
|
//TODO move this to config file
|
||||||
|
const AppwriteIds = {
|
||||||
|
databaseId: '65ee1cbf9c2493faf15f',
|
||||||
|
collection: {
|
||||||
|
boat: '66341910003e287cd71c',
|
||||||
|
reservation: '663f8847000b8f5e29bb',
|
||||||
|
skillTags: '66072582a74d94a4bd01',
|
||||||
|
task: '65ee1cd5b550023fae4f',
|
||||||
|
taskTags: '65ee21d72d5c8007c34c',
|
||||||
|
timeBlock: '66361869002883fb4c4b',
|
||||||
|
timeBlockTemplate: '66361f480007fdd639af',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
let appRouter: Router;
|
let appRouter: Router;
|
||||||
|
|
||||||
export default boot(async ({ router }) => {
|
export default boot(async ({ router }) => {
|
||||||
@@ -86,4 +101,13 @@ function login(email: string, password: string) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export { client, account, databases, ID, appDatabaseId, login, logout };
|
export {
|
||||||
|
client,
|
||||||
|
account,
|
||||||
|
databases,
|
||||||
|
functions,
|
||||||
|
ID,
|
||||||
|
AppwriteIds,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
<!-- This has been abandoned for now. Going to block-based booking. Will probably need the schedule viewer functionality at some point in the future, though -->
|
||||||
<template>
|
<template>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-caption text-justify">
|
<div class="text-caption text-justify">
|
||||||
@@ -55,11 +56,13 @@
|
|||||||
v-model="selectedDate"
|
v-model="selectedDate"
|
||||||
:model-resources="boatStore.boats"
|
:model-resources="boatStore.boats"
|
||||||
resource-key="id"
|
resource-key="id"
|
||||||
resource-label="name"
|
resource-label="displayName"
|
||||||
:interval-start="12"
|
resource-width="32"
|
||||||
:interval-count="36"
|
:interval-start="6"
|
||||||
:interval-minutes="30"
|
:interval-count="18"
|
||||||
|
:interval-minutes="60"
|
||||||
cell-width="48"
|
cell-width="48"
|
||||||
|
style="--calendar-resources-width: 48px"
|
||||||
resource-min-height="40"
|
resource-min-height="40"
|
||||||
animated
|
animated
|
||||||
bordered
|
bordered
|
||||||
@@ -79,8 +82,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #resource-label="{ scope: { resource } }">
|
<template #resource-label="{ scope: { resource } }">
|
||||||
<div class="col-12">
|
<div class="col-12 .col-md-auto">
|
||||||
{{ resource.name }}
|
{{ 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>
|
||||||
@@ -98,7 +101,6 @@
|
|||||||
><template v-slot:append><q-icon name="timelapse" /></template></q-select
|
><template v-slot:append><q-icon name="timelapse" /></template></q-select
|
||||||
></q-card-section>
|
></q-card-section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
@@ -109,20 +111,32 @@ import {
|
|||||||
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.types';
|
||||||
|
|
||||||
|
interface EventData {
|
||||||
|
event: object;
|
||||||
|
scope: {
|
||||||
|
timestamp: object;
|
||||||
|
columnindex: number;
|
||||||
|
activeDate: boolean;
|
||||||
|
droppable: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||||
|
|
||||||
type ResourceIntervalScope = {
|
interface ResourceIntervalScope {
|
||||||
resource: Boat;
|
resource: Boat;
|
||||||
intervals: [];
|
intervals: [];
|
||||||
timeStartPosX(start: TimestampOrNull): number;
|
timeStartPosX(start: TimestampOrNull): number;
|
||||||
timeDurationWidth(duration: number): number;
|
timeDurationWidth(duration: number): number;
|
||||||
};
|
}
|
||||||
|
|
||||||
const statusLookup = {
|
const statusLookup = {
|
||||||
confirmed: ['#14539a', 'white'],
|
confirmed: ['#14539a', 'white'],
|
||||||
@@ -132,7 +146,7 @@ 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 = ref(today());
|
||||||
const duration = ref(1);
|
const duration = ref(1);
|
||||||
|
|
||||||
@@ -158,14 +172,14 @@ function monthFormatter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEvents(scope: ResourceIntervalScope) {
|
function getEvents(scope: ResourceIntervalScope) {
|
||||||
const resourceEvents = scheduleStore.getBoatReservations(
|
const resourceEvents = reservationStore.getBoatReservations(
|
||||||
scope.resource.id,
|
parseDate(date.extractDate(selectedDate.value, 'YYYY-MM-DD')) as Timestamp,
|
||||||
date.extractDate(selectedDate.value, 'YYYY-MM-DD')
|
scope.resource.$id
|
||||||
);
|
);
|
||||||
|
|
||||||
return resourceEvents.map((event) => {
|
return resourceEvents.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')
|
||||||
),
|
),
|
||||||
@@ -179,7 +193,7 @@ function getStyle(event: {
|
|||||||
left: number;
|
left: number;
|
||||||
width: number;
|
width: number;
|
||||||
title: string;
|
title: string;
|
||||||
status: 'tentative' | 'confirmed' | 'pending';
|
status: StatusTypes;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -200,14 +214,16 @@ function onPrev() {
|
|||||||
function onNext() {
|
function onNext() {
|
||||||
calendar.value.next();
|
calendar.value.next();
|
||||||
}
|
}
|
||||||
function onClickDate(data) {
|
|
||||||
return;
|
function onClickDate(data: EventData) {
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
function onClickTime(data) {
|
|
||||||
|
function onClickTime(data: EventData) {
|
||||||
// TODO: Add a duration picker, here.
|
// TODO: Add a duration picker, here.
|
||||||
emit('onClickTime', data);
|
emit('onClickTime', data);
|
||||||
}
|
}
|
||||||
function onUpdateDuration(value) {
|
function onUpdateDuration(value: EventData) {
|
||||||
emit('onUpdateDuration', value);
|
emit('onUpdateDuration', value);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
|||||||
@@ -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,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div v-if="boats">
|
||||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-img :src="boat.imgsrc" :fit="'scale-down'">
|
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||||
<div class="row absolute-top">
|
<div class="row absolute-top">
|
||||||
<div class="col text-h5 text-left">{{ boat.name }}</div>
|
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||||
<div class="col text-right">{{ boat.class }}</div>
|
<div class="col text-right">{{ boat.class }}</div>
|
||||||
</div>
|
</div>
|
||||||
</q-img>
|
</q-img>
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
<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">
|
||||||
|
|||||||
0
src/components/scheduling/BoatSelection.vue
Normal file
0
src/components/scheduling/BoatSelection.vue
Normal file
172
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
172
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<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 {
|
||||||
|
copyIntervalTemplate,
|
||||||
|
timeTuplesOverlapped,
|
||||||
|
useScheduleStore,
|
||||||
|
} from 'src/stores/schedule';
|
||||||
|
import { IntervalTemplate } from 'src/stores/schedule.types';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
const alert = ref(false);
|
||||||
|
const overlapped = ref();
|
||||||
|
const scheduleStore = useScheduleStore();
|
||||||
|
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) scheduleStore.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') {
|
||||||
|
scheduleStore.updateIntervalTemplate(template, template.$id);
|
||||||
|
} else {
|
||||||
|
scheduleStore.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>
|
||||||
271
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
271
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<CalendarHeaderComponent v-model="selectedDate" />
|
||||||
|
<div class="boat-schedule-table-component">
|
||||||
|
<QCalendarDay
|
||||||
|
ref="calendar"
|
||||||
|
class="q-pa-xs"
|
||||||
|
flat
|
||||||
|
animated
|
||||||
|
dense
|
||||||
|
:disabled-before="disabledBefore"
|
||||||
|
interval-height="24"
|
||||||
|
interval-count="18"
|
||||||
|
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 }">
|
||||||
|
<div v-for="block in getBoatBlocks(scope)" :key="block.$id">
|
||||||
|
<div
|
||||||
|
class="timeblock"
|
||||||
|
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||||
|
: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
|
||||||
|
v-for="reservation in getBoatReservations(scope)"
|
||||||
|
:key="reservation.$id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="reservation"
|
||||||
|
:style="
|
||||||
|
reservationStyles(
|
||||||
|
reservation,
|
||||||
|
scope.timeStartPos,
|
||||||
|
scope.timeDurationHeight
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ getUserName(reservation.user) || 'loading...' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</QCalendarDay>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
QCalendarDay,
|
||||||
|
Timestamp,
|
||||||
|
diffTimestamp,
|
||||||
|
today,
|
||||||
|
parseTimestamp,
|
||||||
|
parseDate,
|
||||||
|
addToDate,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
import { useScheduleStore } from 'src/stores/schedule';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
|
||||||
|
const scheduleStore = useScheduleStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const selectedBlock = defineModel<Interval | null>();
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
|
||||||
|
const calendar = ref<QCalendarDay | null>(null);
|
||||||
|
|
||||||
|
function handleSwipe({ ...event }) {
|
||||||
|
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||||
|
}
|
||||||
|
function reservationStyles(
|
||||||
|
reservation: Reservation,
|
||||||
|
timeStartPos: (t: string) => string,
|
||||||
|
timeDurationHeight: (d: number) => string
|
||||||
|
) {
|
||||||
|
return genericBlockStyle(
|
||||||
|
parseDate(new Date(reservation.start)) as Timestamp,
|
||||||
|
parseDate(new Date(reservation.end)) as Timestamp,
|
||||||
|
timeStartPos,
|
||||||
|
timeDurationHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserName(userid: string) {
|
||||||
|
return useAuthStore().getUserNameById(userid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockStyles(
|
||||||
|
block: Interval,
|
||||||
|
timeStartPos: (t: string) => string,
|
||||||
|
timeDurationHeight: (d: number) => string
|
||||||
|
) {
|
||||||
|
return genericBlockStyle(
|
||||||
|
parseDate(new Date(block.start)) as Timestamp,
|
||||||
|
parseDate(new Date(block.end)) as Timestamp,
|
||||||
|
timeStartPos,
|
||||||
|
timeDurationHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoatDisplayName(scope: DayBodyScope) {
|
||||||
|
return boats && boats.value[scope.columnIndex]
|
||||||
|
? boats.value[scope.columnIndex].displayName
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericBlockStyle(
|
||||||
|
start: Timestamp,
|
||||||
|
end: Timestamp,
|
||||||
|
timeStartPos: (t: string) => string,
|
||||||
|
timeDurationHeight: (d: number) => string
|
||||||
|
) {
|
||||||
|
const s = {
|
||||||
|
top: '',
|
||||||
|
height: '',
|
||||||
|
opacity: '',
|
||||||
|
};
|
||||||
|
if (timeStartPos && timeDurationHeight) {
|
||||||
|
s.top = timeStartPos(start.time) + 'px';
|
||||||
|
s.height =
|
||||||
|
parseInt(
|
||||||
|
timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)
|
||||||
|
) -
|
||||||
|
1 +
|
||||||
|
'px';
|
||||||
|
}
|
||||||
|
// if (selectedBlock.value?.id === block.id) {
|
||||||
|
// s.opacity = '1.0';
|
||||||
|
// }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayBodyScope {
|
||||||
|
columnIndex: number;
|
||||||
|
timeDurationHeight: string;
|
||||||
|
timeStartPos: (time: string, clamp: boolean) => string;
|
||||||
|
timestamp: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||||
|
// TODO: Disable blocks before today with updateDisabled and/or comparison
|
||||||
|
selectedBlock.value === block
|
||||||
|
? (selectedBlock.value = null)
|
||||||
|
: (selectedBlock.value = block);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoatBlocks {
|
||||||
|
[key: string]: Interval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const boatBlocks = computed((): BoatBlocks => {
|
||||||
|
return scheduleStore
|
||||||
|
.getIntervalsForDate(selectedDate.value)
|
||||||
|
.reduce((result, tb) => {
|
||||||
|
if (!result[tb.boatId]) result[tb.boatId] = [];
|
||||||
|
result[tb.boatId].push(tb);
|
||||||
|
return result;
|
||||||
|
}, <BoatBlocks>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBoatBlocks(scope: DayBodyScope): Interval[] {
|
||||||
|
const boat = boats.value[scope.columnIndex];
|
||||||
|
return boat ? boatBlocks.value[boat.$id] : [];
|
||||||
|
}
|
||||||
|
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||||
|
const boat = boats.value[scope.columnIndex];
|
||||||
|
return boat
|
||||||
|
? reservationStore.getBoatReservations(scope.timestamp, boat.$id)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// function changeEvent({ start }: { start: string }) {
|
||||||
|
// const newBlocks = scheduleStore.getIntervalsForDate(start);
|
||||||
|
// const reservations = scheduleStore.getBoatReservations(
|
||||||
|
// parsed(start) as Timestamp
|
||||||
|
// );
|
||||||
|
// boats.value.map((boat) => {
|
||||||
|
// boat.reservations = reservations.filter(
|
||||||
|
// (reservation) => reservation.resource === boat
|
||||||
|
// );
|
||||||
|
// boat.blocks = newBlocks.filter(
|
||||||
|
// (block) =>
|
||||||
|
// block.boatId === boat.$id &&
|
||||||
|
// boat.reservations?.filter(
|
||||||
|
// (r: Reservation) =>
|
||||||
|
// 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 todayTs = parseTimestamp(today()) as Timestamp;
|
||||||
|
return addToDate(todayTs, { day: -1 }).date;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
.boat-schedule-table-component
|
||||||
|
display: flex
|
||||||
|
max-height: 60vh
|
||||||
|
max-width: 98vw
|
||||||
|
.reservation
|
||||||
|
display: flex
|
||||||
|
position: absolute
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
text-align: center
|
||||||
|
width: 100%
|
||||||
|
opacity: 1
|
||||||
|
margin: 0px
|
||||||
|
text-overflow: ellipsis
|
||||||
|
font-size: 0.8em
|
||||||
|
cursor: pointer
|
||||||
|
background: $accent
|
||||||
|
color: white
|
||||||
|
border: 1px solid black
|
||||||
|
.timeblock
|
||||||
|
display: flex
|
||||||
|
position: absolute
|
||||||
|
justify-content: center
|
||||||
|
text-align: center
|
||||||
|
align-items: center
|
||||||
|
width: 100%
|
||||||
|
opacity: 0.5
|
||||||
|
margin: 0px
|
||||||
|
text-overflow: ellipsis
|
||||||
|
font-size: 0.8em
|
||||||
|
cursor: pointer
|
||||||
|
background: $primary
|
||||||
|
color: white
|
||||||
|
border: 1px solid black
|
||||||
|
.selected
|
||||||
|
opacity: 1 !important
|
||||||
|
.q-calendar-day__interval--text
|
||||||
|
font-size: 0.8em
|
||||||
|
.q-calendar-day__day.q-current-day
|
||||||
|
padding: 1px
|
||||||
|
</style>
|
||||||
259
src/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
259
src/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<div class="title-bar" style="display: flex">
|
||||||
|
<button
|
||||||
|
tabindex="0"
|
||||||
|
class="date-button direction-button direction-button__left"
|
||||||
|
@click="onPrev"
|
||||||
|
>
|
||||||
|
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||||
|
</button>
|
||||||
|
<div class="dates-holder">
|
||||||
|
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||||
|
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||||
|
<button
|
||||||
|
tabindex="0"
|
||||||
|
style="width: 100%"
|
||||||
|
:class="dayClass(day)"
|
||||||
|
@click="selectedDate = day.date"
|
||||||
|
>
|
||||||
|
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||||
|
<div style="width: 100%; font-size: 0.9em">
|
||||||
|
{{ monthFormatter(day, true) }}
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; font-size: 1.2em; font-weight: 700">
|
||||||
|
{{ dayFormatter(day, false) }}
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; font-size: 1em">
|
||||||
|
{{ weekdayFormatter(day, true) }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
tabindex="0"
|
||||||
|
class="date-button direction-button direction-button__right"
|
||||||
|
@click="onNext"
|
||||||
|
>
|
||||||
|
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Timestamp,
|
||||||
|
addToDate,
|
||||||
|
createDayList,
|
||||||
|
createNativeLocaleFormatter,
|
||||||
|
getEndOfWeek,
|
||||||
|
getStartOfWeek,
|
||||||
|
getWeekdaySkips,
|
||||||
|
parseTimestamp,
|
||||||
|
today,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
|
||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
|
||||||
|
const selectedDate = defineModel<string>();
|
||||||
|
|
||||||
|
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]),
|
||||||
|
locale = ref('en-CA'),
|
||||||
|
monthFormatter = monthFormatterFunc(),
|
||||||
|
dayFormatter = dayFormatterFunc(),
|
||||||
|
weekdayFormatter = weekdayFormatterFunc();
|
||||||
|
|
||||||
|
const weekdaySkips = computed(() => {
|
||||||
|
return getWeekdaySkips(weekdays);
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedStart = computed(() =>
|
||||||
|
getStartOfWeek(
|
||||||
|
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||||
|
weekdays,
|
||||||
|
today2.value as Timestamp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedEnd = computed(() =>
|
||||||
|
getEndOfWeek(
|
||||||
|
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||||
|
weekdays,
|
||||||
|
today2.value as Timestamp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const today2 = computed(() => {
|
||||||
|
return parseTimestamp(today());
|
||||||
|
});
|
||||||
|
|
||||||
|
const days = computed(() => {
|
||||||
|
if (parsedStart.value && parsedEnd.value) {
|
||||||
|
return createDayList(
|
||||||
|
parsedStart.value,
|
||||||
|
parsedEnd.value,
|
||||||
|
today2.value as Timestamp,
|
||||||
|
weekdaySkips.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayStyle = computed(() => {
|
||||||
|
const width = 100 / weekdays.length + '%';
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onPrev() {
|
||||||
|
const ts = addToDate(parsedStart.value, { day: -7 });
|
||||||
|
selectedDate.value = ts.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNext() {
|
||||||
|
const ts = addToDate(parsedStart.value, { day: 7 });
|
||||||
|
selectedDate.value = ts.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayClass(day: Timestamp) {
|
||||||
|
return {
|
||||||
|
'date-button': true,
|
||||||
|
'selected-date-button': selectedDate.value === day.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthFormatterFunc() {
|
||||||
|
const longOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
month: 'long',
|
||||||
|
};
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
month: 'short',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||||
|
short ? shortOptions : longOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayFormatterFunc() {
|
||||||
|
const longOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
weekday: 'long',
|
||||||
|
};
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
weekday: 'short',
|
||||||
|
};
|
||||||
|
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||||
|
short ? shortOptions : longOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayFormatterFunc() {
|
||||||
|
const longOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
day: '2-digit',
|
||||||
|
};
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
day: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||||
|
short ? shortOptions : longOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="sass">
|
||||||
|
.title-bar
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
height: 70px
|
||||||
|
background: white
|
||||||
|
display: flex
|
||||||
|
flex-direction: row
|
||||||
|
flex: 1 0 100%
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
overflow: hidden
|
||||||
|
border-radius: 3px
|
||||||
|
user-select: none
|
||||||
|
margin: 2px 0px 2px
|
||||||
|
|
||||||
|
.dates-holder
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
align-items: center
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
color: #fff
|
||||||
|
overflow: hidden
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.internal-dates-holder
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
display: inline-flex
|
||||||
|
flex: 1 1 100%
|
||||||
|
flex-direction: row
|
||||||
|
justify-content: space-between
|
||||||
|
overflow: hidden
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.direction-button
|
||||||
|
background: white
|
||||||
|
color: $primary
|
||||||
|
width: 40px
|
||||||
|
max-width: 50px !important
|
||||||
|
|
||||||
|
.direction-button__left
|
||||||
|
&:before
|
||||||
|
content: '<'
|
||||||
|
display: inline-flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
height: 100%
|
||||||
|
font-weight: 900
|
||||||
|
font-size: 3em
|
||||||
|
|
||||||
|
.direction-button__right
|
||||||
|
&:before
|
||||||
|
content: '>'
|
||||||
|
display: inline-flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
height: 100%
|
||||||
|
font-weight: 900
|
||||||
|
font-size: 3em
|
||||||
|
|
||||||
|
.date-button
|
||||||
|
color: $primary
|
||||||
|
background: white
|
||||||
|
z-index: 2
|
||||||
|
height: 100%
|
||||||
|
outline: 0
|
||||||
|
cursor: pointer
|
||||||
|
border-radius: 3px
|
||||||
|
display: inline-flex
|
||||||
|
flex: 1 0 auto
|
||||||
|
flex-direction: column
|
||||||
|
align-items: stretch
|
||||||
|
position: relative
|
||||||
|
border: 0
|
||||||
|
vertical-align: middle
|
||||||
|
padding: 0
|
||||||
|
font-size: 14px
|
||||||
|
line-height: 1.715em
|
||||||
|
text-decoration: none
|
||||||
|
font-weight: 500
|
||||||
|
text-transform: uppercase
|
||||||
|
text-align: center
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.selected-date-button
|
||||||
|
color: white !important
|
||||||
|
background: $primary !important
|
||||||
|
</style>
|
||||||
24
src/components/task/TaskCardComponent.vue
Normal file
24
src/components/task/TaskCardComponent.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<q-card>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>{{ task.title }}</q-item-label>
|
||||||
|
<q-item-label caption lines="2">{{ task.description }} </q-item-label>
|
||||||
|
<q-item-label caption>Due: {{ task.due_date }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-expansion-item
|
||||||
|
v-if="task.subtasks && task.subtasks.length"
|
||||||
|
expand-separator
|
||||||
|
label="Subtasks"
|
||||||
|
default-opened
|
||||||
|
>
|
||||||
|
<TaskListComponent :tasks="task.subtasks" />
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
import type { Task } from 'src/stores/task';
|
||||||
|
|
||||||
|
defineProps<{ task: Task }>();
|
||||||
|
</script>
|
||||||
266
src/components/task/TaskEditComponent.vue
Normal file
266
src/components/task/TaskEditComponent.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="modifiedTask.title"
|
||||||
|
label="Task Title"
|
||||||
|
hint="A short description of the task"
|
||||||
|
lazy-rules
|
||||||
|
:rules="[
|
||||||
|
(val: string | any[]) =>
|
||||||
|
(val && val.length > 0) || 'Please enter a title for the task',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<q-editor
|
||||||
|
filled
|
||||||
|
v-model="modifiedTask.description"
|
||||||
|
label="Detailed Description"
|
||||||
|
hint="A detailed description of the task"
|
||||||
|
lazy-rules
|
||||||
|
placeholder="Enter a detailed description..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="modifiedTask.due_date"
|
||||||
|
mask="date"
|
||||||
|
:rules="[dateRule]"
|
||||||
|
hint="Enter the due date"
|
||||||
|
lazy-rules
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="event" class="cursor-pointer">
|
||||||
|
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||||||
|
<q-date
|
||||||
|
v-model="modifiedTask.due_date"
|
||||||
|
@input="updateDateISO"
|
||||||
|
today-btn
|
||||||
|
>
|
||||||
|
<div class="row items-center justify-end">
|
||||||
|
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||||
|
</div>
|
||||||
|
</q-date>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<div>
|
||||||
|
<q-select
|
||||||
|
label="Skills Required"
|
||||||
|
hint="Add a list of required skills, to help people find things in their ability"
|
||||||
|
v-model="modifiedTask.required_skills"
|
||||||
|
use-input
|
||||||
|
use-chips
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
input-debounce="250"
|
||||||
|
:options="skillTagOptions"
|
||||||
|
option-label="name"
|
||||||
|
option-value="$id"
|
||||||
|
@filter="filterSkillTags"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<q-select
|
||||||
|
label="Tags"
|
||||||
|
hint="Add Tags to help with searching"
|
||||||
|
v-model="modifiedTask.tags"
|
||||||
|
use-input
|
||||||
|
use-chips
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
input-debounce="250"
|
||||||
|
:options="taskTagOptions"
|
||||||
|
option-label="name"
|
||||||
|
option-value="$id"
|
||||||
|
@filter="filterTaskTags"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
label="Estimated Duration"
|
||||||
|
v-model.number="modifiedTask.duration"
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
suffix="hrs"
|
||||||
|
style="max-width: 200px"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
label="Number of Required Volunteers"
|
||||||
|
v-model.number="modifiedTask.volunteers_required"
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
style="max-width: 200px"
|
||||||
|
/>
|
||||||
|
<q-select
|
||||||
|
label="Status of Task"
|
||||||
|
v-model="modifiedTask.status"
|
||||||
|
:options="TASKSTATUS"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<div>
|
||||||
|
<q-select
|
||||||
|
label="Dependencies"
|
||||||
|
hint="Add a list of tasks that need to be complete before this one"
|
||||||
|
v-model="modifiedTask.depends_on"
|
||||||
|
use-input
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
input-debounce="250"
|
||||||
|
:options="tasks"
|
||||||
|
option-label="title"
|
||||||
|
option-value="$id"
|
||||||
|
@filter="filterTasks"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<q-select
|
||||||
|
label="Boat"
|
||||||
|
hint="Add a boat, if applicable"
|
||||||
|
v-model="modifiedTask.boat"
|
||||||
|
use-input
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
input-debounce="250"
|
||||||
|
:options="boatList"
|
||||||
|
option-label="name"
|
||||||
|
option-value="$id"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
label="Submit"
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
flat
|
||||||
|
class="q-ml-sm"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
label="Cancel"
|
||||||
|
color="secondary"
|
||||||
|
flat
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="$router.go(-1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, Ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
|
||||||
|
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
|
||||||
|
import { date } from 'quasar';
|
||||||
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
|
||||||
|
const props = defineProps<{ taskId?: string }>();
|
||||||
|
const taskStore = useTaskStore();
|
||||||
|
|
||||||
|
const defaultTask = <Task>{
|
||||||
|
description: '',
|
||||||
|
due_date: date.formatDate(Date.now(), 'YYYY-MM-DD'),
|
||||||
|
required_skills: [],
|
||||||
|
title: '',
|
||||||
|
tags: [],
|
||||||
|
duration: 0,
|
||||||
|
volunteers: [],
|
||||||
|
volunteers_required: 0,
|
||||||
|
status: 'ready',
|
||||||
|
depends_on: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
taskStore.fetchTasks();
|
||||||
|
|
||||||
|
const { taskId } = props;
|
||||||
|
const targetTask = taskId && taskStore.tasks.find((t) => t.$id === taskId);
|
||||||
|
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
|
||||||
|
|
||||||
|
let tasks = taskStore.tasks;
|
||||||
|
const boatList = useBoatStore().boats;
|
||||||
|
|
||||||
|
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||||
|
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||||
|
|
||||||
|
const filterSkillTags = computed(
|
||||||
|
() =>
|
||||||
|
(val: string, update: (cb: () => void) => void): void => {
|
||||||
|
return filterTags(skillTagOptions, taskStore.skillTags, val, update);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const filterTaskTags = computed(
|
||||||
|
() =>
|
||||||
|
(val: string, update: (cb: () => void) => void): void => {
|
||||||
|
return filterTags(taskTagOptions, taskStore.taskTags, val, update);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const filterTasks = computed(
|
||||||
|
() =>
|
||||||
|
(val: string, update: (cb: () => void) => void): void => {
|
||||||
|
if (val === '') {
|
||||||
|
update(() => {
|
||||||
|
tasks = taskStore.tasks;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(() => {
|
||||||
|
tasks = taskStore.filterTasksByTitle(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function filterTags(
|
||||||
|
optionVar: Ref<(SkillTag | TaskTag)[] | undefined>,
|
||||||
|
optionSrc: SkillTag[] | TaskTag[],
|
||||||
|
val: string,
|
||||||
|
update: (cb: () => void) => void
|
||||||
|
): void {
|
||||||
|
if (val === '') {
|
||||||
|
update(() => {
|
||||||
|
optionVar.value = optionSrc;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(() => {
|
||||||
|
optionVar.value = optionSrc.filter((v) =>
|
||||||
|
v.name.toLowerCase().includes(val.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to update the model in ISO 8601 format
|
||||||
|
const updateDateISO = (value: string) => {
|
||||||
|
modifiedTask.due_date = date.formatDate(value, 'YYYY-MM-DD');
|
||||||
|
};
|
||||||
|
const dateRule = (val: string) => {
|
||||||
|
// Check if the date is valid using Quasar's date utils if needed
|
||||||
|
// For simplicity, we are directly checking the date string validity
|
||||||
|
return (val && !isNaN(Date.parse(val))) || 'Please enter a valid date';
|
||||||
|
};
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
try {
|
||||||
|
if (modifiedTask.$id) {
|
||||||
|
await taskStore.updateTask(modifiedTask);
|
||||||
|
} else {
|
||||||
|
await taskStore.addTask(modifiedTask);
|
||||||
|
}
|
||||||
|
router.go(-1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create new Task: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
16
src/components/task/TaskListComponent.vue
Normal file
16
src/components/task/TaskListComponent.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="q-pa-md" style="max-width: 350px">
|
||||||
|
<q-list>
|
||||||
|
<div v-for="task in tasks" :key="task.id">
|
||||||
|
<TaskCardComponent :task="task" />
|
||||||
|
</div>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Task } from 'src/stores/task';
|
||||||
|
import TaskCardComponent from './TaskCardComponent.vue';
|
||||||
|
|
||||||
|
defineProps<{ tasks: Task[] }>();
|
||||||
|
</script>
|
||||||
367
src/components/task/TaskTableComponent.vue
Normal file
367
src/components/task/TaskTableComponent.vue
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<template>
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-table
|
||||||
|
:rows="tasks"
|
||||||
|
:columns="columns"
|
||||||
|
:grid="$q.screen.xs"
|
||||||
|
dense
|
||||||
|
row-key="$id"
|
||||||
|
flatten
|
||||||
|
no-data-label="I didn't find anything for you"
|
||||||
|
no-results-label="The filter didn't uncover any results"
|
||||||
|
selection="multiple"
|
||||||
|
v-model:selected="selected"
|
||||||
|
:filter="searchFilter"
|
||||||
|
:filter-method="filterRows"
|
||||||
|
>
|
||||||
|
<template v-slot:top>
|
||||||
|
<q-select
|
||||||
|
style="width: 250px"
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
clearable
|
||||||
|
label="Skills Filter"
|
||||||
|
input-debounce="250"
|
||||||
|
:options="skillTagOptions"
|
||||||
|
v-model="searchFilter.skillTags"
|
||||||
|
option-label="name"
|
||||||
|
option-value="$id"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<q-select
|
||||||
|
style="width: 250px"
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
clearable
|
||||||
|
label="Tag Filter"
|
||||||
|
input-debounce="250"
|
||||||
|
:options="taskTagOptions"
|
||||||
|
v-model="searchFilter.taskTags"
|
||||||
|
option-label="name"
|
||||||
|
option-value="$id"
|
||||||
|
>
|
||||||
|
<template v-slot: prepend>
|
||||||
|
<q-icon name="wrench"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-select>
|
||||||
|
<q-space />
|
||||||
|
<q-input
|
||||||
|
flatten
|
||||||
|
debounce="300"
|
||||||
|
color="primary"
|
||||||
|
clearable
|
||||||
|
v-model="searchFilter.title"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</template>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th key="desc" auto-width>
|
||||||
|
<q-checkbox dense v-model="props.selected"></q-checkbox>
|
||||||
|
</q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-skills="props">
|
||||||
|
<q-td :props="props" class="q-gutter-sm">
|
||||||
|
<q-badge
|
||||||
|
v-for="skill in props.value"
|
||||||
|
:key="skill"
|
||||||
|
:color="skill.tagColour"
|
||||||
|
text-color="white"
|
||||||
|
:label="skill.name"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-tags="props">
|
||||||
|
<q-td :props="props" class="q-gutter-sm">
|
||||||
|
<q-badge
|
||||||
|
v-for="tag in props.value"
|
||||||
|
:key="tag"
|
||||||
|
:color="tag.colour"
|
||||||
|
text-color="white"
|
||||||
|
:label="tag.name"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-actions="props">
|
||||||
|
<q-td :props="props" class="q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
label="Sign Up"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
:to="{ name: 'signup-task', params: { id: props.value } }"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
label="Edit"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
:to="{ name: 'edit-task', params: { id: props.value } }"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item="props">
|
||||||
|
<div
|
||||||
|
class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3 grid-style-transition"
|
||||||
|
:style="props.selected ? 'transform: scale(0.95);' : ''"
|
||||||
|
>
|
||||||
|
<q-card
|
||||||
|
bordered
|
||||||
|
flat
|
||||||
|
:class="
|
||||||
|
props.selected
|
||||||
|
? $q.dark.isActive
|
||||||
|
? 'bg-grey-9'
|
||||||
|
: 'bg-grey-2'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<q-card-section>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
v-model="props.selected"
|
||||||
|
:label="props.row.name"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-list dense>
|
||||||
|
<q-item
|
||||||
|
v-for="col in props.cols.filter((col:Boat) => col.name !== 'desc')"
|
||||||
|
:key="col.name"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ col.label }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-item-label caption v-if="col.name === 'skills'">
|
||||||
|
<q-chip
|
||||||
|
size="sm"
|
||||||
|
v-for="skill in col.value"
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
:key="skill.$id"
|
||||||
|
>{{ skill.name }}</q-chip
|
||||||
|
></q-item-label
|
||||||
|
>
|
||||||
|
<q-item-label caption v-else-if="col.name === 'tags'">
|
||||||
|
<q-chip
|
||||||
|
size="sm"
|
||||||
|
v-for="tag in col.value"
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
:key="tag.$id"
|
||||||
|
>{{ tag.name }}</q-chip
|
||||||
|
></q-item-label
|
||||||
|
>
|
||||||
|
<q-item-label caption v-else-if="col.name === 'actions'">
|
||||||
|
<q-btn
|
||||||
|
label="Sign Up"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
:to="{ name: 'signup-task', params: { id: col.value } }"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
label="Edit"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
:to="{ name: 'edit-task', params: { id: col.value } }"
|
||||||
|
/>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label v-else caption>{{ col.value }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
<q-page-sticky position="bottom-right" :offset="[18, 18]">
|
||||||
|
<q-fab
|
||||||
|
v-model="fabShow"
|
||||||
|
vertical-actions-align="right"
|
||||||
|
color="primary"
|
||||||
|
glossy
|
||||||
|
icon="keyboard_arrow_up"
|
||||||
|
direction="up"
|
||||||
|
>
|
||||||
|
<q-fab-action
|
||||||
|
color="primary"
|
||||||
|
:disable="loading"
|
||||||
|
label="New Task"
|
||||||
|
to="/task/edit"
|
||||||
|
icon="add"
|
||||||
|
/>
|
||||||
|
<q-fab-action
|
||||||
|
v-if="tasks.length !== 0"
|
||||||
|
class="q-ml-sm"
|
||||||
|
color="primary"
|
||||||
|
:disable="loading"
|
||||||
|
label="Delete task(s)"
|
||||||
|
@click="deleteTasks"
|
||||||
|
icon="delete"
|
||||||
|
/> </q-fab
|
||||||
|
></q-page-sticky>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineProps, ref } from 'vue';
|
||||||
|
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
|
||||||
|
import { QTableProps, date, useQuasar } from 'quasar';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
|
||||||
|
const selected = ref([]);
|
||||||
|
const loading = ref(false); // Placeholder
|
||||||
|
const fabShow = ref(false);
|
||||||
|
const columns = <QTableProps['columns']>[
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
required: true,
|
||||||
|
label: 'Title',
|
||||||
|
align: 'left',
|
||||||
|
field: 'title',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'due_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Due Date',
|
||||||
|
field: 'due_date',
|
||||||
|
format: (val) => date.formatDate(val, 'MMM DD, YYYY'),
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Status',
|
||||||
|
field: 'status',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'skills',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Skills',
|
||||||
|
field: (row) =>
|
||||||
|
row.required_skills.map((s: string) => taskStore.getSkillById(s)),
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tags',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Tags',
|
||||||
|
field: (row) => row.tags.map((s: string) => taskStore.getTaskTagById(s)),
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'boat',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Boat',
|
||||||
|
field: (row) =>
|
||||||
|
useBoatStore().boats.find((boat) => boat.$id === row.boat)?.name,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'volunteers',
|
||||||
|
align: 'left',
|
||||||
|
label: "People Req'd",
|
||||||
|
field: 'volunteers_required',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'signedup',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Signed Up',
|
||||||
|
field: (row) => row.volunteers.length,
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'depends',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Dependent Tasks',
|
||||||
|
field: 'depends_on',
|
||||||
|
format: (val) => {
|
||||||
|
return (
|
||||||
|
val
|
||||||
|
.map((t: string) => taskStore.getTaskById(t))
|
||||||
|
.filter((t: Task) => t)
|
||||||
|
.map((t: Task) => t.title)
|
||||||
|
.join(', ') || null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'actions', align: 'center', label: 'Actions', field: '$id' },
|
||||||
|
];
|
||||||
|
|
||||||
|
defineProps<{ tasks: Task[] }>();
|
||||||
|
const taskStore = useTaskStore();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
interface SearchObject {
|
||||||
|
title: string;
|
||||||
|
skillTags: SkillTag[];
|
||||||
|
taskTags: TaskTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilter = ref<SearchObject>({
|
||||||
|
title: '',
|
||||||
|
skillTags: [],
|
||||||
|
taskTags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||||
|
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||||
|
|
||||||
|
// function onRowClick(evt: Event, row: Task) {
|
||||||
|
// router.push({ name: 'edit-task', params: { id: row.$id } });
|
||||||
|
// }
|
||||||
|
// TODO: Implement server side search
|
||||||
|
const filterRows = computed(
|
||||||
|
() => (rows: readonly Task[], terms: SearchObject) => {
|
||||||
|
return rows
|
||||||
|
.filter((row) =>
|
||||||
|
terms.title
|
||||||
|
? row.title.toLowerCase().includes(terms.title.toLowerCase())
|
||||||
|
: true
|
||||||
|
)
|
||||||
|
.filter((row) =>
|
||||||
|
terms.skillTags && terms.skillTags.length > 0
|
||||||
|
? row.required_skills.some((req_skill) =>
|
||||||
|
terms.skillTags.map((t) => t.$id).includes(req_skill)
|
||||||
|
)
|
||||||
|
: true
|
||||||
|
)
|
||||||
|
.filter((row) =>
|
||||||
|
terms.taskTags && terms.taskTags.length > 0
|
||||||
|
? row.tags.some((tag) =>
|
||||||
|
terms.taskTags.map((t) => t.$id).includes(tag)
|
||||||
|
)
|
||||||
|
: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function deleteTasks() {
|
||||||
|
confirmDelete(selected.value);
|
||||||
|
}
|
||||||
|
function confirmDelete(tasks: Task[]) {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Confirm',
|
||||||
|
message:
|
||||||
|
'You are about to delete ' + tasks.length + ' tasks. Are you sure?',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(() => {
|
||||||
|
selected.value.map((task: Task) => {
|
||||||
|
taskStore.deleteTask(task);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -11,5 +11,7 @@ import { ref } 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';
|
||||||
|
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
boatStore.fetchBoats();
|
||||||
const boats = ref(useBoatStore().boats);
|
const boats = ref(useBoatStore().boats);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,32 +8,25 @@
|
|||||||
<q-avatar icon="person" />
|
<q-avatar icon="person" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
Ricky Gervais
|
{{ authStore.currentUser?.name }}
|
||||||
<q-item-label caption>Name</q-item-label>
|
<q-item-label caption>Name</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-avatar icon="numbers" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
12345
|
|
||||||
<q-item-label caption>Member ID</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</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="primary" text-color="white"
|
<div>
|
||||||
|
<q-chip square icon="verified" color="green" text-color="white"
|
||||||
>J/27</q-chip
|
>J/27</q-chip
|
||||||
>
|
>
|
||||||
<q-chip square icon="verified" color="green" text-color="white"
|
<q-chip square icon="verified" color="blue" text-color="white"
|
||||||
>Capri25</q-chip
|
>Capri25</q-chip
|
||||||
>
|
>
|
||||||
<q-chip square icon="verified" color="grey-8" text-color="white"
|
<q-chip square icon="verified" color="grey-9" text-color="white"
|
||||||
>Night</q-chip
|
>Night</q-chip
|
||||||
>
|
>
|
||||||
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
@@ -42,4 +35,7 @@
|
|||||||
|
|
||||||
<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';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<toolbar-component pageTitle="Tasks" />
|
|
||||||
<q-page padding>
|
|
||||||
<q-card bordered separator class="mobile-card">
|
|
||||||
<q-card-section clickable v-ripple>
|
|
||||||
<div class="text-h6">Launch Prep</div>
|
|
||||||
<div class="text-subtitle2">Prepare for Launch</div>
|
|
||||||
<q-chip size="md" color="green" text-color="white" icon="alarm">
|
|
||||||
APR 1,2024
|
|
||||||
</q-chip>
|
|
||||||
<q-chip size="md" icon="build"> 24 tasks </q-chip>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
<q-card bordered separator class="mobile-card">
|
|
||||||
<q-card-section clickable v-ripple>
|
|
||||||
<div class="text-h6">General Maintenance</div>
|
|
||||||
<div class="text-subtitle2">Day to day maintenance and upkeep</div>
|
|
||||||
<q-chip size="md" icon="build"> 4 tasks </q-chip>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
|
||||||
</script>
|
|
||||||
8
src/pages/admin/TaskAdminPage.vue
Normal file
8
src/pages/admin/TaskAdminPage.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<!-- content -->
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
@@ -1,38 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
|
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-sm">
|
||||||
<q-input
|
<q-item>
|
||||||
bottom-slots
|
<q-item-section :avatar="true">
|
||||||
v-model="bookingForm.name"
|
<q-icon name="person"
|
||||||
label="Creating reservation for"
|
/></q-item-section>
|
||||||
readonly
|
<q-item-section>
|
||||||
>
|
<q-item-label> Name: {{ bookingForm.name }} </q-item-label>
|
||||||
<template v-slot:prepend>
|
</q-item-section>
|
||||||
<q-icon name="person" />
|
</q-item>
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
expand-separator
|
expand-separator
|
||||||
v-model="resourceView"
|
v-model="resourceView"
|
||||||
icon="calendar_month"
|
icon="calendar_month"
|
||||||
label="Boat and Time"
|
label="Boat and Time"
|
||||||
default-opened
|
default-opened
|
||||||
|
class="q-mt-none"
|
||||||
:caption="bookingSummary"
|
:caption="bookingSummary"
|
||||||
>
|
>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<resource-schedule-viewer-component
|
<q-banner :class="$q.dark.isActive ? 'bg-grey-9' : 'bg-grey-3'">
|
||||||
@on-click-time="onClickTime"
|
Use the calendar to pick a date. Select an available boat and
|
||||||
@on-update-duration="
|
timeslot below.
|
||||||
(value) => {
|
</q-banner>
|
||||||
bookingForm.duration = value;
|
<BoatScheduleTableComponent v-model="timeblock" />
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<q-banner
|
<q-banner
|
||||||
rounded
|
rounded
|
||||||
class="bg-warning text-grey-10"
|
class="bg-warning text-grey-10"
|
||||||
v-if="bookingForm.boat?.defects"
|
style="max-width: 95vw; margin: auto"
|
||||||
|
v-if="bookingForm.boat && bookingForm.boat.defects.length > 0"
|
||||||
>
|
>
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="warning" color="grey-10" />
|
<q-icon name="warning" color="grey-10" />
|
||||||
@@ -62,7 +60,32 @@
|
|||||||
icon="people"
|
icon="people"
|
||||||
label="Crew and Passengers"
|
label="Crew and Passengers"
|
||||||
default-opened
|
default-opened
|
||||||
|
><q-banner v-if="bookingForm.boat"
|
||||||
|
>Passengers:
|
||||||
|
{{ bookingForm.members.length + bookingForm.guests.length }} /
|
||||||
|
{{ bookingForm.boat.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-separator />
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
@@ -74,50 +97,51 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
import { Dialog, date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue';
|
import { Interval } from 'src/stores/schedule.types';
|
||||||
import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
|
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||||
import { useScheduleStore, Reservation } from 'src/stores/schedule';
|
import { getNewId } from 'src/utils/misc';
|
||||||
|
|
||||||
|
interface BookingForm {
|
||||||
|
bookingId: string;
|
||||||
|
name?: string;
|
||||||
|
boat?: Boat;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
members: { name: string }[];
|
||||||
|
guests: { name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const dateFormat = 'ddd MMM D, YYYY h:mm A';
|
const dateFormat = 'MMM D, YYYY h:mm A';
|
||||||
const resourceView = ref(true);
|
const resourceView = ref(true);
|
||||||
const scheduleStore = useScheduleStore();
|
const timeblock = ref<Interval>();
|
||||||
const bookingForm = reactive({
|
const bookingForm = ref<BookingForm>({
|
||||||
bookingId: scheduleStore.getNewId(),
|
bookingId: getNewId(),
|
||||||
name: auth.currentUser?.name,
|
name: auth.currentUser?.name,
|
||||||
boat: <Boat | undefined>undefined,
|
boat: <Boat | undefined>undefined,
|
||||||
startDate: date.formatDate(new Date(), dateFormat),
|
startDate: date.formatDate(new Date(), dateFormat),
|
||||||
endDate: computed(() =>
|
endDate: date.formatDate(new Date(), dateFormat),
|
||||||
date.formatDate(
|
members: [{ name: 'Karen Henrikso' }, { name: "Rich O'hare" }],
|
||||||
date.addToDate(bookingForm.startDate, {
|
guests: [{ name: 'Bob Barker' }, { name: 'Taylor Swift' }],
|
||||||
hours: bookingForm.duration,
|
|
||||||
}),
|
|
||||||
dateFormat
|
|
||||||
)
|
|
||||||
),
|
|
||||||
duration: 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(bookingForm, (b, a) => {
|
watch(timeblock, (tb_new) => {
|
||||||
const newRes = <Reservation>{
|
bookingForm.value.boat = useBoatStore().boats.find(
|
||||||
id: b.bookingId,
|
(b) => b.$id === tb_new?.boatId
|
||||||
user: b.name,
|
);
|
||||||
resource: b.boat,
|
bookingForm.value.startDate = date.formatDate(tb_new?.start, dateFormat);
|
||||||
start: date.extractDate(b.startDate, dateFormat),
|
bookingForm.value.endDate = date.formatDate(tb_new?.end, dateFormat);
|
||||||
end: date.extractDate(b.endDate, dateFormat),
|
|
||||||
reservationDate: new Date(),
|
|
||||||
status: 'tentative',
|
|
||||||
};
|
|
||||||
//TODO: Turn this into a validator.
|
|
||||||
scheduleStore.isOverlapped(newRes)
|
|
||||||
? Dialog.create({ message: 'This booking overlaps another!' })
|
|
||||||
: scheduleStore.addOrCreateReservation(newRes);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// //TODO: Turn this into a validator.
|
||||||
|
// scheduleStore.isReservationOverlapped(newRes)
|
||||||
|
// ? Dialog.create({ message: 'This booking overlaps another!' })
|
||||||
|
// : scheduleStore.addOrCreateReservation(newRes);
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
// TODO
|
// TODO
|
||||||
};
|
};
|
||||||
@@ -126,38 +150,27 @@ const onSubmit = () => {
|
|||||||
// TODO
|
// TODO
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickTime = (data) => {
|
|
||||||
bookingForm.boat = data.scope.resource;
|
|
||||||
bookingForm.startDate = date.formatDate(
|
|
||||||
date.addToDate(makeDateTime(data.scope.timestamp), { hours: 5 }), // A terrible hack to convert back to EST. TODO: FIX!!!!
|
|
||||||
dateFormat
|
|
||||||
);
|
|
||||||
console.log(bookingForm.startDate);
|
|
||||||
};
|
|
||||||
const bookingDuration = computed(() => {
|
const bookingDuration = computed(() => {
|
||||||
|
if (bookingForm.value.endDate && bookingForm.value.startDate) {
|
||||||
const diff = date.getDateDiff(
|
const diff = date.getDateDiff(
|
||||||
bookingForm.endDate,
|
bookingForm.value.endDate,
|
||||||
bookingForm.startDate,
|
bookingForm.value.startDate,
|
||||||
'minutes'
|
'minutes'
|
||||||
);
|
);
|
||||||
return diff <= 0
|
return diff <= 0
|
||||||
? 'Invalid'
|
? 'Invalid'
|
||||||
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
|
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
|
||||||
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
|
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingSummary = computed(() => {
|
const bookingSummary = computed(() => {
|
||||||
return bookingForm.boat && bookingForm.startDate && bookingForm.endDate
|
return bookingForm.value.boat &&
|
||||||
? `${bookingForm.boat.name} @ ${bookingForm.startDate} for ${bookingDuration.value}`
|
bookingForm.value.startDate &&
|
||||||
|
bookingForm.value.endDate
|
||||||
|
? `${bookingForm.value.boat.name} @ ${bookingForm.value.startDate} for ${bookingDuration.value}`
|
||||||
: '';
|
: '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const limitDate = (startDate: string) => {
|
|
||||||
return date.isBetweenDates(
|
|
||||||
startDate,
|
|
||||||
new Date(),
|
|
||||||
date.addToDate(new Date(), { days: 21 }),
|
|
||||||
{ inclusiveFrom: true, inclusiveTo: true, onlyDate: true }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,132 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<!-- content -->
|
<div class="subcontent">
|
||||||
|
<!-- <navigation-bar @today="onToday" @prev="onPrev" @next="onNext" /> -->
|
||||||
|
|
||||||
|
<div class="row justify-center">
|
||||||
|
<q-calendar-day
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
view="day"
|
||||||
|
:max-days="3"
|
||||||
|
bordered
|
||||||
|
animated
|
||||||
|
transition-next="slide-left"
|
||||||
|
transition-prev="slide-right"
|
||||||
|
@change="onChange"
|
||||||
|
@moved="onMoved"
|
||||||
|
@click-date="onClickDate"
|
||||||
|
@click-time="onClickTime"
|
||||||
|
@click-interval="onClickInterval"
|
||||||
|
@click-head-day="onClickHeadDay"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
#day-body="{
|
||||||
|
scope: { timestamp, timeStartPos, timeDurationHeight },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="event in reservationEvents(timestamp)"
|
||||||
|
:key="event.id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="event.start !== undefined"
|
||||||
|
class="booking-event"
|
||||||
|
:style="slotStyle(event, timeStartPos, timeDurationHeight)"
|
||||||
|
>
|
||||||
|
<span class="title q-calendar__ellipsis">
|
||||||
|
{{ event.user }}
|
||||||
|
<q-tooltip>{{
|
||||||
|
event.start +
|
||||||
|
' - ' +
|
||||||
|
boatStore.getBoatById(event.resource)?.name
|
||||||
|
}}</q-tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</q-calendar-day>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useScheduleStore } from 'src/stores/schedule';
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
import { TimestampOrNull, parsed, 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 { useBoatStore } from 'src/stores/boat';
|
||||||
|
|
||||||
const scheduleStore = useScheduleStore();
|
const selectedDate = ref(today());
|
||||||
scheduleStore.loadSampleData();
|
const boatStore = useBoatStore();
|
||||||
|
|
||||||
|
// Method declarations
|
||||||
|
|
||||||
|
function slotStyle(
|
||||||
|
event: Reservation,
|
||||||
|
timeStartPos: (time: TimestampOrNull) => string,
|
||||||
|
timeDurationHeight: (minutes: number) => string
|
||||||
|
) {
|
||||||
|
const s = {
|
||||||
|
top: '',
|
||||||
|
height: '',
|
||||||
|
'align-items': 'flex-start',
|
||||||
|
};
|
||||||
|
if (timeStartPos && timeDurationHeight) {
|
||||||
|
s.top = timeStartPos(parsed(event.start)) + 'px';
|
||||||
|
s.height =
|
||||||
|
timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
|
||||||
|
'px';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservationEvents(timestamp: Timestamp) {
|
||||||
|
return reservationStore.getBoatReservations(timestamp);
|
||||||
|
}
|
||||||
|
function onMoved(data: Event) {
|
||||||
|
console.log('onMoved', data);
|
||||||
|
}
|
||||||
|
function onChange(data: Event) {
|
||||||
|
console.log('onChange', data);
|
||||||
|
}
|
||||||
|
function onClickDate(data: Event) {
|
||||||
|
console.log('onClickDate', data);
|
||||||
|
}
|
||||||
|
function onClickTime(data: Event) {
|
||||||
|
console.log('onClickTime', data);
|
||||||
|
}
|
||||||
|
function onClickInterval(data: Event) {
|
||||||
|
console.log('onClickInterval', data);
|
||||||
|
}
|
||||||
|
function onClickHeadDay(data: Event) {
|
||||||
|
console.log('onClickHeadDay', data);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
|
||||||
|
.booking-event
|
||||||
|
position: absolute
|
||||||
|
font-size: 12px
|
||||||
|
justify-content: space-evenly
|
||||||
|
margin: 0 1px
|
||||||
|
text-overflow: ellipsis
|
||||||
|
overflow: hidden
|
||||||
|
color: white
|
||||||
|
max-width: 100%
|
||||||
|
background: #027BE3FF
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
.title
|
||||||
|
position: relative
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
height: 100%
|
||||||
|
</style>
|
||||||
|
|||||||
296
src/pages/schedule/ManageCalendar.vue
Normal file
296
src/pages/schedule/ManageCalendar.vue
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<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="50"
|
||||||
|
:cell-width="150"
|
||||||
|
:day-height="0"
|
||||||
|
>
|
||||||
|
<template #day="{ scope }">
|
||||||
|
<div
|
||||||
|
v-if="getIntervals(scope.timestamp, scope.resource)"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="block in getIntervals(
|
||||||
|
scope.timestamp,
|
||||||
|
scope.resource
|
||||||
|
).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))"
|
||||||
|
: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="updateInterval(block)"
|
||||||
|
>
|
||||||
|
<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 = buildISODate(
|
||||||
|
date.formatDate(scope.value.start, 'YYYY-MM-DD'),t as string
|
||||||
|
))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<!-- 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 = buildISODate(
|
||||||
|
date.formatDate(scope.value.end, 'YYYY-MM-DD'),t as string
|
||||||
|
))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</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: 400">
|
||||||
|
<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 timeblockTemplates"
|
||||||
|
: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">
|
||||||
|
This will overwrite existing blocks!
|
||||||
|
{{ overlapped }}
|
||||||
|
</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 {
|
||||||
|
blocksOverlapped,
|
||||||
|
buildInterval,
|
||||||
|
useScheduleStore,
|
||||||
|
} from 'src/stores/schedule';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { buildISODate } from 'src/utils/misc';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
const { fetchBoats } = useBoatStore();
|
||||||
|
const { getIntervals, fetchIntervals, updateInterval, fetchIntervalTemplates } =
|
||||||
|
useScheduleStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const { timeblockTemplates } = storeToRefs(useScheduleStore());
|
||||||
|
const calendar = ref();
|
||||||
|
const overlapped = ref();
|
||||||
|
const blankTemplate: IntervalTemplate = {
|
||||||
|
$id: '',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
};
|
||||||
|
const newTemplate = ref<IntervalTemplate>({ ...blankTemplate });
|
||||||
|
const alert = ref(false);
|
||||||
|
|
||||||
|
/* TODOS:
|
||||||
|
* Need more validation:
|
||||||
|
- Interval start < end
|
||||||
|
- Intervals don't overlap
|
||||||
|
* Need to handle case of overnight blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchBoats();
|
||||||
|
await fetchIntervals();
|
||||||
|
await fetchIntervalTemplates();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetNewTemplate() {
|
||||||
|
newTemplate.value = { ...blankTemplate };
|
||||||
|
}
|
||||||
|
function createTemplate() {
|
||||||
|
newTemplate.value.$id = 'unsaved';
|
||||||
|
}
|
||||||
|
function createIntervals(boat: Boat, templateId: string, date: string) {
|
||||||
|
timeBlocksFromTemplate(boat, templateId, date)?.map((block) =>
|
||||||
|
useScheduleStore().createInterval(block)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeBlocksFromTemplate(
|
||||||
|
boat: Boat,
|
||||||
|
templateId: string,
|
||||||
|
date: string
|
||||||
|
): Interval[] {
|
||||||
|
const timeBlock = timeblockTemplates.value.find((t) => t.$id === templateId);
|
||||||
|
return (
|
||||||
|
timeBlock?.timeTuples.map((tb: TimeTuple) =>
|
||||||
|
buildInterval(boat, tb, date)
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlock(block: Interval) {
|
||||||
|
if (block.$id) {
|
||||||
|
useScheduleStore().deleteInterval(block.$id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ((type === 'day' || type === 'head-day') && e.dataTransfer) {
|
||||||
|
const templateId = e.dataTransfer.getData('ID');
|
||||||
|
const date = scope.timestamp.date;
|
||||||
|
if (type === 'head-day') {
|
||||||
|
overlapped.value = boats.value.map((boat) =>
|
||||||
|
blocksOverlapped(
|
||||||
|
getIntervals(scope.timestamp, boat).concat(
|
||||||
|
timeBlocksFromTemplate(boat, templateId, date)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (overlapped.value.length === 0) {
|
||||||
|
boats.value.map((b) => createIntervals(b, templateId, date));
|
||||||
|
} else {
|
||||||
|
alert.value = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
overlapped.value = blocksOverlapped(
|
||||||
|
getIntervals(scope.timestamp, scope.resource).concat(
|
||||||
|
timeBlocksFromTemplate(scope.resource, templateId, date)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapped.value.length === 0) {
|
||||||
|
createIntervals(scope.resource, 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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<q-item v-for="link in navlinks" :key="link.label">
|
<q-item v-for="link in navlinks" :key="link.label">
|
||||||
<q-btn
|
<q-btn
|
||||||
:icon="link.icon"
|
:icon="link.icon"
|
||||||
color="primary"
|
:color="link.color"
|
||||||
size="1.25em"
|
size="1.25em"
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
:label="link.label"
|
:label="link.label"
|
||||||
@@ -21,7 +21,19 @@ const navlinks = [
|
|||||||
icon: 'more_time',
|
icon: 'more_time',
|
||||||
to: '/schedule/book',
|
to: '/schedule/book',
|
||||||
label: 'Create a Reservation',
|
label: 'Create a Reservation',
|
||||||
|
color: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'calendar_month',
|
||||||
|
to: '/schedule/view',
|
||||||
|
label: 'View Schedule',
|
||||||
|
color: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'edit_calendar',
|
||||||
|
to: '/schedule/manage',
|
||||||
|
label: 'Manage Calendar',
|
||||||
|
color: 'accent',
|
||||||
},
|
},
|
||||||
{ icon: 'calendar_month', to: '/schedule/view', label: 'View Schedule' },
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
16
src/pages/task/TaskEditPage.vue
Normal file
16
src/pages/task/TaskEditPage.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<ToolbarComponent pageTitle="Tasks" />
|
||||||
|
<q-page padding>
|
||||||
|
<div class="q-pa-md" style="max-width: 400px">
|
||||||
|
<TaskEditComponent :taskId="taskId" />
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const taskId = useRoute().params.id as string;
|
||||||
|
console.log(taskId);
|
||||||
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
|
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
</script>
|
||||||
16
src/pages/task/TaskPage.vue
Normal file
16
src/pages/task/TaskPage.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<toolbar-component pageTitle="Tasks" />
|
||||||
|
<q-page padding>
|
||||||
|
<TaskTableComponent :tasks="taskStore.tasks" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTaskStore } from 'stores/task';
|
||||||
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
|
import TaskTableComponent from 'src/components/task/TaskTableComponent.vue';
|
||||||
|
|
||||||
|
const taskStore = useTaskStore();
|
||||||
|
|
||||||
|
taskStore.fetchTasks(); // Fetch on mount
|
||||||
|
</script>
|
||||||
@@ -38,9 +38,6 @@ export default route(function (/* { store, ssrContext } */) {
|
|||||||
Router.beforeEach((to) => {
|
Router.beforeEach((to) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
if (!auth.ready) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (auth.currentUser) {
|
if (auth.currentUser) {
|
||||||
return to.meta.accountRoute ? { name: 'index' } : true;
|
return to.meta.accountRoute ? { name: 'index' } : true;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
import ScheduleIndexPage from 'pages/schedule/ScheduleIndexPage.vue';
|
|
||||||
import ChecklistPageVue from 'pages/ChecklistPage.vue';
|
|
||||||
import LoginPageVue from 'pages/LoginPage.vue';
|
|
||||||
import ReferencePageVue from 'src/pages/reference/ReferencePage.vue';
|
|
||||||
import ReferenceIndexPageVue from 'src/pages/reference/ReferenceIndexPage.vue';
|
|
||||||
import ReferenceItemPageVue from 'src/pages/reference/ReferenceItemPage.vue';
|
|
||||||
import MainLayoutVue from 'src/layouts/MainLayout.vue';
|
|
||||||
import BoatPageVue from 'src/pages/BoatPage.vue';
|
|
||||||
import CertificationPageVue from 'src/pages/CertificationPage.vue';
|
|
||||||
import IndexPageVue from 'src/pages/IndexPage.vue';
|
|
||||||
import ProfilePageVue from 'src/pages/ProfilePage.vue';
|
|
||||||
import TaskPageVue from 'src/pages/TaskPage.vue';
|
|
||||||
import { RouteRecordRaw } from 'vue-router';
|
import { RouteRecordRaw } from 'vue-router';
|
||||||
import SchedulePageView from 'pages/schedule/SchedulePageView.vue';
|
|
||||||
import BoatReservationPageVue from 'src/pages/schedule/BoatReservationPage.vue';
|
|
||||||
import BoatScheduleViewVue from 'src/pages/schedule/BoatScheduleView.vue';
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: MainLayoutVue,
|
component: () => import('src/layouts/MainLayout.vue'),
|
||||||
// If we get so big we need lazy loading, we can use imports again
|
// If we get so big we need lazy loading, we can use imports again
|
||||||
// component: () => import('layouts/MainLayout.vue'),
|
// component: () => import('layouts/MainLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
@@ -26,69 +11,89 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
// If we get so big we need lazy loading, we can use imports again
|
// If we get so big we need lazy loading, we can use imports again
|
||||||
// component: () => import('pages/IndexPage.vue'),
|
// component: () => import('pages/IndexPage.vue'),
|
||||||
component: IndexPageVue,
|
component: () => import('src/pages/IndexPage.vue'),
|
||||||
name: 'index',
|
name: 'index',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/boat',
|
path: '/boat',
|
||||||
component: BoatPageVue,
|
component: () => import('src/pages/BoatPage.vue'),
|
||||||
name: 'boat',
|
name: 'boat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/schedule',
|
path: '/schedule',
|
||||||
component: SchedulePageView,
|
component: () => import('pages/schedule/SchedulePageView.vue'),
|
||||||
name: 'schedule',
|
name: 'schedule',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ScheduleIndexPage,
|
component: () => import('pages/schedule/ScheduleIndexPage.vue'),
|
||||||
name: 'schedule-index',
|
name: 'schedule-index',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'book',
|
path: 'book',
|
||||||
component: BoatReservationPageVue,
|
component: () =>
|
||||||
|
import('src/pages/schedule/BoatReservationPage.vue'),
|
||||||
name: 'reserve-boat',
|
name: 'reserve-boat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'view',
|
path: 'view',
|
||||||
component: BoatScheduleViewVue,
|
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
||||||
name: 'boat-schedule',
|
name: 'boat-schedule',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'manage',
|
||||||
|
component: () => import('src/pages/schedule/ManageCalendar.vue'),
|
||||||
|
name: 'manage-schedule',
|
||||||
|
meta: { requiresScheduleAdmin: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/certification',
|
path: '/certification',
|
||||||
component: CertificationPageVue,
|
component: () => import('src/pages/CertificationPage.vue'),
|
||||||
name: 'certification',
|
name: 'certification',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/task',
|
path: '/task',
|
||||||
component: TaskPageVue,
|
|
||||||
name: 'task',
|
name: 'task',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: () => import('src/pages/task/TaskPage.vue'),
|
||||||
|
name: 'task-index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:id/edit',
|
||||||
|
component: () => import('pages/task/TaskEditPage.vue'),
|
||||||
|
name: 'edit-task',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/checklist',
|
path: '/checklist',
|
||||||
component: ChecklistPageVue,
|
component: () => import('pages/ChecklistPage.vue'),
|
||||||
name: 'checklist',
|
name: 'checklist',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
component: ProfilePageVue,
|
component: () => import('src/pages/ProfilePage.vue'),
|
||||||
name: 'profile',
|
name: 'profile',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/reference',
|
path: '/reference',
|
||||||
component: ReferencePageVue,
|
component: () => import('src/pages/reference/ReferencePage.vue'),
|
||||||
name: 'reference',
|
name: 'reference',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ReferenceIndexPageVue,
|
component: () =>
|
||||||
|
import('src/pages/reference/ReferenceIndexPage.vue'),
|
||||||
name: 'reference-index',
|
name: 'reference-index',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/reference/:id/view',
|
path: '/reference/:id/view',
|
||||||
component: ReferenceItemPageVue,
|
component: () =>
|
||||||
|
import('src/pages/reference/ReferenceItemPage.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -97,6 +102,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
component: () => import('layouts/AdminLayout.vue'),
|
component: () => import('layouts/AdminLayout.vue'),
|
||||||
|
meta: { requiresAdmin: true },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
@@ -112,7 +118,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
component: LoginPageVue,
|
component: () => import('pages/LoginPage.vue'),
|
||||||
name: 'login',
|
name: 'login',
|
||||||
meta: {
|
meta: {
|
||||||
publicRoute: true,
|
publicRoute: true,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ID, account } from 'boot/appwrite';
|
import { ID, account, functions } from 'boot/appwrite';
|
||||||
import type { Models } from 'appwrite';
|
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
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 userNames = ref<Record<string, string>>({});
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
@@ -13,7 +13,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
ready.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(email: string, password: string) {
|
async function register(email: string, password: string) {
|
||||||
@@ -21,21 +20,51 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
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();
|
currentUser.value = await account.get();
|
||||||
}
|
}
|
||||||
async function googleLogin() {
|
async function googleLogin() {
|
||||||
account.createOAuth2Session(
|
account.createOAuth2Session(
|
||||||
'google',
|
OAuthProvider.Google,
|
||||||
'https://bab.toal.ca/',
|
'https://bab.toal.ca/',
|
||||||
'https://bab.toal.ca/#/login'
|
'https://bab.toal.ca/#/login'
|
||||||
);
|
);
|
||||||
currentUser.value = await account.get();
|
currentUser.value = await account.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserNameById(id: string) {
|
||||||
|
try {
|
||||||
|
if (!userNames.value[id]) {
|
||||||
|
userNames.value[id] = '';
|
||||||
|
functions
|
||||||
|
.createExecution(
|
||||||
|
'664038294b5473ef0c8d',
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
'/userinfo/' + id,
|
||||||
|
ExecutionMethod.GET
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(res) => (userNames.value[id] = JSON.parse(res.responseBody).name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('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 };
|
return {
|
||||||
|
currentUser,
|
||||||
|
getUserNameById,
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
googleLogin,
|
||||||
|
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: number;
|
$id: string;
|
||||||
name: string;
|
name: 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[];
|
||||||
maxDuration: number;
|
|
||||||
maxPassengers: number;
|
maxPassengers: number;
|
||||||
};
|
defects: {
|
||||||
defects?: {
|
|
||||||
type: string;
|
type: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -23,58 +24,24 @@ export interface Boat {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSampleData = () => [
|
export const useBoatStore = defineStore('boat', () => {
|
||||||
{
|
const boats = ref<Boat[]>([]);
|
||||||
id: 1,
|
|
||||||
name: 'ProjectX',
|
|
||||||
class: 'J/27',
|
|
||||||
year: 1981,
|
|
||||||
imgsrc: '/tmpimg/j27.png',
|
|
||||||
iconsrc: '/tmpimg/projectx_avatar256.png',
|
|
||||||
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',
|
|
||||||
class: 'J/27',
|
|
||||||
year: 1985,
|
|
||||||
imgsrc: '/tmpimg/j27.png',
|
|
||||||
iconsrc: '/tmpimg/take5_avatar32.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'WeeBeestie',
|
|
||||||
class: 'Capri 25',
|
|
||||||
year: 1989,
|
|
||||||
imgsrc: '/tmpimg/capri25.png',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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): Boat | null => {
|
||||||
|
return boats.value.find((b) => b.$id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
actions: {
|
return { boats, fetchBoats, getBoatById };
|
||||||
// update () {
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
84
src/stores/reservation.ts
Normal file
84
src/stores/reservation.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { Reservation } from './schedule.types';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { Timestamp, parsed } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
|
||||||
|
export const useReservationStore = defineStore('reservation', () => {
|
||||||
|
const reservations = ref<Reservation[]>([]);
|
||||||
|
|
||||||
|
const getConflictingReservations = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): Reservation[] => {
|
||||||
|
const overlapped = reservations.value.filter(
|
||||||
|
(entry: Reservation) =>
|
||||||
|
entry.resource == resource &&
|
||||||
|
new Date(entry.start) < end &&
|
||||||
|
new Date(entry.end) > start
|
||||||
|
);
|
||||||
|
return overlapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isResourceTimeOverlapped = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): boolean => {
|
||||||
|
return getConflictingReservations(resource, start, end).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReservationOverlapped = (res: Reservation): boolean => {
|
||||||
|
return isResourceTimeOverlapped(
|
||||||
|
res.resource,
|
||||||
|
new Date(res.start),
|
||||||
|
new Date(res.end)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addOrCreateReservation = (reservation: Reservation) => {
|
||||||
|
const index = reservations.value.findIndex(
|
||||||
|
(res) => res.id == reservation.id
|
||||||
|
);
|
||||||
|
index != -1
|
||||||
|
? (reservations.value[index] = reservation)
|
||||||
|
: reservations.value.push(reservation);
|
||||||
|
};
|
||||||
|
async function fetchReservations() {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation
|
||||||
|
);
|
||||||
|
reservations.value = response.documents as Reservation[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timeblocks', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBoatReservations = (
|
||||||
|
searchDate: Timestamp,
|
||||||
|
boat?: string
|
||||||
|
): Reservation[] => {
|
||||||
|
const result = reservations.value.filter((x) => {
|
||||||
|
return (
|
||||||
|
((parsed(x.start)?.date == searchDate.date ||
|
||||||
|
parsed(x.end)?.date == searchDate.date) && // Part of reservation falls on day
|
||||||
|
x.resource != undefined && // A boat is defined
|
||||||
|
!boat) ||
|
||||||
|
x.resource == boat // A specific boat has been passed, and matches
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getBoatReservations,
|
||||||
|
fetchReservations,
|
||||||
|
addOrCreateReservation,
|
||||||
|
isReservationOverlapped,
|
||||||
|
isResourceTimeOverlapped,
|
||||||
|
getConflictingReservations,
|
||||||
|
};
|
||||||
|
});
|
||||||
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/j27.png',
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
143
src/stores/sampledata/schedule.ts
Normal file
143
src/stores/sampledata/schedule.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { DateOptions, date } from 'quasar';
|
||||||
|
import { Boat, useBoatStore } from '../boat';
|
||||||
|
import {
|
||||||
|
parseTimestamp,
|
||||||
|
today,
|
||||||
|
Timestamp,
|
||||||
|
addToDate,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
StatusTypes,
|
||||||
|
Reservation,
|
||||||
|
IntervalTemplate,
|
||||||
|
Interval,
|
||||||
|
TimeTuple,
|
||||||
|
} from '../schedule.types';
|
||||||
|
|
||||||
|
export const templateA: IntervalTemplate = {
|
||||||
|
id: '1',
|
||||||
|
name: 'WeekdayBlocks',
|
||||||
|
timeTuples: [
|
||||||
|
['08:00', '12:00'],
|
||||||
|
['12:00', '16:00'],
|
||||||
|
['17:00', '21:00'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templateB: IntervalTemplate = {
|
||||||
|
id: '2',
|
||||||
|
name: 'WeekendBlocks',
|
||||||
|
timeTuples: [
|
||||||
|
['07:00', '10:00'],
|
||||||
|
['10:00', '13:00'],
|
||||||
|
['13:00', '16:00'],
|
||||||
|
['16:00', '19:00'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSampleIntervals(): Interval[] {
|
||||||
|
// Hard-code 30 days worth of blocks, for now. Make them random templates
|
||||||
|
const boats = useBoatStore().boats;
|
||||||
|
const result: Interval[] = [];
|
||||||
|
const tsToday: Timestamp = parseTimestamp(today()) as Timestamp;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 30; i++) {
|
||||||
|
const template = templateB;
|
||||||
|
result.push(
|
||||||
|
...boats
|
||||||
|
.map((b): Interval[] => {
|
||||||
|
return template.blocks.map((t: TimeTuple): Interval => {
|
||||||
|
return {
|
||||||
|
$id: 'id' + Math.random().toString(32).slice(2),
|
||||||
|
boatId: b.$id,
|
||||||
|
start: addToDate(tsToday, { day: i }).date + ' ' + t[0],
|
||||||
|
end: addToDate(tsToday, { day: i }).date + ' ' + t[1],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.flat(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSampleReservations(): Reservation[] {
|
||||||
|
const sampleData = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
user: 'John Smith',
|
||||||
|
start: '7:00',
|
||||||
|
end: '10:00',
|
||||||
|
boat: '66359729003825946ae1',
|
||||||
|
status: 'confirmed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
user: 'Bob Barker',
|
||||||
|
start: '16:00',
|
||||||
|
end: '19:00',
|
||||||
|
boat: '66359729003825946ae1',
|
||||||
|
status: 'confirmed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
user: 'Peter Parker',
|
||||||
|
start: '7:00',
|
||||||
|
end: '13:00',
|
||||||
|
boat: '663597030029b71c7a9b',
|
||||||
|
status: 'tentative',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
user: 'Vince McMahon',
|
||||||
|
start: '10:00',
|
||||||
|
end: '13:00',
|
||||||
|
boat: '663597030029b71c7a9b',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
user: 'Heather Graham',
|
||||||
|
start: '13:00',
|
||||||
|
end: '19:00',
|
||||||
|
boat: '663596b9000235ffea55',
|
||||||
|
status: 'confirmed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
user: 'Lawrence Fishburne',
|
||||||
|
start: '13:00',
|
||||||
|
end: '16:00',
|
||||||
|
boat: '663596b9000235ffea55',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const now = new Date();
|
||||||
|
const splitTime = (x: string): string[] => {
|
||||||
|
return x.split(':');
|
||||||
|
};
|
||||||
|
const makeOpts = (x: string[]): DateOptions => {
|
||||||
|
return {
|
||||||
|
hour: parseInt(x[0]),
|
||||||
|
minute: parseInt(x[1]),
|
||||||
|
seconds: 0,
|
||||||
|
milliseconds: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return sampleData.map((entry): Reservation => {
|
||||||
|
const boat = <Boat>boatStore.boats.find((b) => b.$id == entry.boat);
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
user: entry.user,
|
||||||
|
start: date
|
||||||
|
.adjustDate(now, makeOpts(splitTime(entry.start)))
|
||||||
|
.toISOString(),
|
||||||
|
end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(),
|
||||||
|
resource: boat.$id,
|
||||||
|
reservationDate: now,
|
||||||
|
status: entry.status as StatusTypes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,140 +1,258 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Boat, useBoatStore } from './boat';
|
import { Boat } from './boat';
|
||||||
import { date } from 'quasar';
|
import {
|
||||||
import { DateOptions } from 'quasar';
|
Timestamp,
|
||||||
|
parseDate,
|
||||||
|
parsed,
|
||||||
|
compareDate,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
|
||||||
export interface Reservation {
|
import { IntervalTemplate, TimeTuple, Interval } from './schedule.types';
|
||||||
id: number;
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
user: string;
|
import { ID, Models } from 'appwrite';
|
||||||
start: Date;
|
import { buildISODate } from 'src/utils/misc';
|
||||||
end: Date;
|
|
||||||
resource: Boat;
|
export function arrayToTimeTuples(arr: string[]) {
|
||||||
reservationDate: Date;
|
const timeTuples: TimeTuple[] = [];
|
||||||
status?: string;
|
for (let i = 0; i < arr.length; i += 2) {
|
||||||
|
timeTuples.push([arr[i], arr[i + 1]]);
|
||||||
|
}
|
||||||
|
return timeTuples;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSampleData(): Reservation[] {
|
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||||
const sampleData = [
|
return blocksOverlapped(
|
||||||
{
|
tuples.map((tuples) => {
|
||||||
id: 1,
|
|
||||||
user: 'John Smith',
|
|
||||||
start: '12:00',
|
|
||||||
end: '14:00',
|
|
||||||
boat: 1,
|
|
||||||
status: 'confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
user: 'Bob Barker',
|
|
||||||
start: '18:00',
|
|
||||||
end: '20:00',
|
|
||||||
boat: 1,
|
|
||||||
status: 'confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
user: 'Peter Parker',
|
|
||||||
start: '8:00',
|
|
||||||
end: '10:00',
|
|
||||||
boat: 2,
|
|
||||||
status: 'tentative',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
user: 'Vince McMahon',
|
|
||||||
start: '13:00',
|
|
||||||
end: '17:00',
|
|
||||||
boat: 2,
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
user: 'Heather Graham',
|
|
||||||
start: '06:00',
|
|
||||||
end: '09:00',
|
|
||||||
boat: 3,
|
|
||||||
status: 'confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
user: 'Lawrence Fishburne',
|
|
||||||
start: '18:00',
|
|
||||||
end: '20:00',
|
|
||||||
boat: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const boatStore = useBoatStore();
|
|
||||||
const now = new Date();
|
|
||||||
const splitTime = (x: string): string[] => {
|
|
||||||
return x.split(':');
|
|
||||||
};
|
|
||||||
const makeOpts = (x: string[]): DateOptions => {
|
|
||||||
return { hour: parseInt(x[0]), minute: parseInt(x[1]) };
|
|
||||||
};
|
|
||||||
|
|
||||||
return sampleData.map((entry): Reservation => {
|
|
||||||
const boat = <Boat>boatStore.boats.find((b) => b.id == entry.boat);
|
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
boatId: '',
|
||||||
user: entry.user,
|
start: '01/01/2001 ' + tuples[0],
|
||||||
start: date.adjustDate(now, makeOpts(splitTime(entry.start))),
|
end: '01/01/2001 ' + tuples[1],
|
||||||
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
|
|
||||||
resource: boat,
|
|
||||||
reservationDate: now,
|
|
||||||
status: entry.status,
|
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
).map((t) => {
|
||||||
|
return { ...t, start: t.start.split(' ')[1], end: t.end.split(' ')[1] };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function blocksOverlapped(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 = {
|
||||||
|
boatId: resource.$id,
|
||||||
|
start: buildISODate(blockDate, time[0]),
|
||||||
|
end: buildISODate(blockDate, time[1]),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const useScheduleStore = defineStore('schedule', () => {
|
export const useScheduleStore = defineStore('schedule', () => {
|
||||||
const reservations = ref<Reservation[]>(getSampleData());
|
// TODO: Implement functions to dynamically pull this data.
|
||||||
const getBoatReservations = (
|
const timeblocks = ref<Interval[]>([]);
|
||||||
boat: number | string,
|
const timeblockTemplates = ref<IntervalTemplate[]>([]);
|
||||||
curDate: Date
|
|
||||||
): Reservation[] => {
|
const getIntervals = (date: Timestamp, boat: Boat): Interval[] => {
|
||||||
return reservations.value.filter((x) => {
|
return timeblocks.value.filter((block) => {
|
||||||
return (
|
return (
|
||||||
(x.start.getDate() == curDate.getDate() ||
|
compareDate(parseDate(new Date(block.start)) as Timestamp, date) &&
|
||||||
x.end.getDate() == curDate.getDate()) &&
|
block.boatId === boat.$id
|
||||||
x.resource != undefined &&
|
);
|
||||||
(typeof boat == 'number'
|
});
|
||||||
? x.resource.id == boat
|
};
|
||||||
: x.resource.name == boat)
|
const getIntervalsForDate = (date: string): Interval[] => {
|
||||||
|
// TODO: This needs to actually make sure we have the dates we need, stay in sync, etc.
|
||||||
|
return timeblocks.value.filter((b) => {
|
||||||
|
return compareDate(
|
||||||
|
parseDate(new Date(b.start)) as Timestamp,
|
||||||
|
parsed(date) as Timestamp
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOverlapped = (res: Reservation) => {
|
async function fetchIntervals() {
|
||||||
const lapped = reservations.value.filter(
|
try {
|
||||||
(entry: Reservation) =>
|
const response = await databases.listDocuments(
|
||||||
entry.id != res.id &&
|
AppwriteIds.databaseId,
|
||||||
entry.resource == res.resource &&
|
AppwriteIds.collection.timeBlock
|
||||||
((entry.start <= res.start && entry.end > res.start) ||
|
|
||||||
(entry.end >= res.end && entry.start <= res.end))
|
|
||||||
);
|
);
|
||||||
return lapped.length > 0;
|
timeblocks.value = response.documents as Interval[];
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timeblocks', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getNewId = () => {
|
async function fetchIntervalTemplates() {
|
||||||
// Trivial placeholder
|
try {
|
||||||
return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
const response = await databases.listDocuments(
|
||||||
};
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.timeBlockTemplate
|
||||||
const addOrCreateReservation = (reservation: Reservation) => {
|
|
||||||
const index = reservations.value.findIndex(
|
|
||||||
(res) => res.id == reservation.id
|
|
||||||
);
|
);
|
||||||
index != -1
|
timeblockTemplates.value = response.documents.map(
|
||||||
? (reservations.value[index] = reservation)
|
(d: Models.Document): IntervalTemplate => {
|
||||||
: reservations.value.push(reservation);
|
return {
|
||||||
|
...d,
|
||||||
|
timeTuples: arrayToTimeTuples(d.timeTuple),
|
||||||
|
} as IntervalTemplate;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timeblock templates', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// const getConflicts = (timeblock: Interval, 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 createInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.timeBlock,
|
||||||
|
ID.unique(),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
timeblocks.value.push(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.timeBlock,
|
||||||
|
interval.$id,
|
||||||
|
{ ...interval, $id: undefined }
|
||||||
|
);
|
||||||
|
timeblocks.value.push(response as Interval);
|
||||||
|
} 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.timeBlock,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
timeblocks.value = timeblocks.value.filter((block) => block.$id !== id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createIntervalTemplate = async (template: IntervalTemplate) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.timeBlockTemplate,
|
||||||
|
ID.unique(),
|
||||||
|
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||||
|
);
|
||||||
|
timeblockTemplates.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.timeBlockTemplate,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
timeblockTemplates.value = timeblockTemplates.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.timeBlockTemplate,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: template.name,
|
||||||
|
timeTuple: template.timeTuples.flat(2),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
timeblockTemplates.value = timeblockTemplates.value.map((b) =>
|
||||||
|
b.$id !== id
|
||||||
|
? b
|
||||||
|
: ({
|
||||||
|
...response,
|
||||||
|
timeTuples: arrayToTimeTuples(response.timeTuple),
|
||||||
|
} as IntervalTemplate)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reservations,
|
timeblocks,
|
||||||
getBoatReservations,
|
timeblockTemplates,
|
||||||
getNewId,
|
getIntervalsForDate,
|
||||||
addOrCreateReservation,
|
getIntervals,
|
||||||
isOverlapped,
|
fetchIntervals,
|
||||||
|
fetchIntervalTemplates,
|
||||||
|
createInterval,
|
||||||
|
updateInterval,
|
||||||
|
deleteInterval,
|
||||||
|
createIntervalTemplate,
|
||||||
|
deleteIntervalTemplate,
|
||||||
|
updateIntervalTemplate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
29
src/stores/schedule.types.ts
Normal file
29
src/stores/schedule.types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Models } from 'appwrite';
|
||||||
|
|
||||||
|
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||||
|
export type Reservation = Partial<Models.Document> & {
|
||||||
|
user: string;
|
||||||
|
start: string; // ISODate
|
||||||
|
end: string; //ISODate
|
||||||
|
resource: string; // Boat ID
|
||||||
|
status?: StatusTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||||
|
// Within 24 hrs, any available slot
|
||||||
|
/* 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
|
||||||
|
objects in here? */
|
||||||
|
|
||||||
|
export type TimeTuple = [start: string, end: string];
|
||||||
|
export type Interval = Partial<Models.Document> & {
|
||||||
|
boatId: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
selected?: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntervalTemplate = Partial<Models.Document> & {
|
||||||
|
name: string;
|
||||||
|
timeTuples: TimeTuple[];
|
||||||
|
};
|
||||||
158
src/stores/task.ts
Normal file
158
src/stores/task.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases, ID } from 'src/boot/appwrite';
|
||||||
|
import type { Models } from 'appwrite';
|
||||||
|
|
||||||
|
export const TASKSTATUS = ['ready', 'complete', 'waiting', 'archived'];
|
||||||
|
|
||||||
|
export interface Task extends Partial<Models.Document> {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
|
required_skills: string[];
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
|
tags: string[];
|
||||||
|
due_date: string;
|
||||||
|
duration: number;
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
|
volunteers: string[];
|
||||||
|
volunteers_required: number;
|
||||||
|
status: string;
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
|
depends_on: string[];
|
||||||
|
/* Appwrite ID of a Boat resource */
|
||||||
|
boat?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskTag extends Models.Document {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
colour: string;
|
||||||
|
}
|
||||||
|
export interface SkillTag extends Models.Document {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tagColour: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTaskStore = defineStore('tasks', {
|
||||||
|
state: () => ({
|
||||||
|
tasks: [] as Task[],
|
||||||
|
taskTags: [] as TaskTag[],
|
||||||
|
skillTags: [] as SkillTag[],
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchTasks() {
|
||||||
|
try {
|
||||||
|
await this.fetchTaskTags();
|
||||||
|
await this.fetchSkillTags();
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.task
|
||||||
|
);
|
||||||
|
this.tasks = response.documents as Task[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tasks', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchTaskTags() {
|
||||||
|
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.taskTags
|
||||||
|
);
|
||||||
|
this.taskTags = response.documents as TaskTag[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch task tags', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchSkillTags() {
|
||||||
|
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.skillTags
|
||||||
|
);
|
||||||
|
this.skillTags = response.documents as SkillTag[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch skill tags', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteTask(task: Task | string) {
|
||||||
|
const docId = typeof task === 'string' ? task : task.$id;
|
||||||
|
if (docId === undefined) {
|
||||||
|
console.error('No document ID provided to deleteTask!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.task,
|
||||||
|
docId
|
||||||
|
);
|
||||||
|
this.tasks = this.tasks.filter((task) => docId !== task.$id);
|
||||||
|
} catch (error) {
|
||||||
|
// Need some better error handling, here.
|
||||||
|
console.error('Failed to delete task:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async addTask(task: Task) {
|
||||||
|
const newTask = <Models.Document>{ ...task };
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.task,
|
||||||
|
ID.unique(),
|
||||||
|
newTask
|
||||||
|
);
|
||||||
|
this.tasks.push(response as Task);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add task:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async updateTask(task: Task) {
|
||||||
|
const newTask = <Partial<Models.Document>>{
|
||||||
|
...task,
|
||||||
|
id: undefined,
|
||||||
|
$databaseId: undefined,
|
||||||
|
$collectionId: undefined,
|
||||||
|
};
|
||||||
|
if (!task.$id) {
|
||||||
|
console.error('No Task ID!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.task,
|
||||||
|
task.$id,
|
||||||
|
newTask
|
||||||
|
);
|
||||||
|
this.tasks.push(response as Task);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update task:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// TODO: Enhance this store to include offline caching, and subscription notification when items change on the server.
|
||||||
|
},
|
||||||
|
// Add more actions as needed (e.g., updateTask, deleteTask)
|
||||||
|
getters: {
|
||||||
|
getTaskById: (state) => (id: string) => {
|
||||||
|
return state.tasks.find((task) => task.$id === id) || null;
|
||||||
|
},
|
||||||
|
getTaskTagById: (state) => (id: string) => {
|
||||||
|
return state.taskTags.find((tag) => tag.$id === id) || null;
|
||||||
|
},
|
||||||
|
getSkillById: (state) => (id: string) => {
|
||||||
|
return state.skillTags.find((tag) => tag.$id === id) || null;
|
||||||
|
},
|
||||||
|
filterTasksByTitle: (state) => (searchQuery: string) => {
|
||||||
|
const result = state.tasks.filter((task) =>
|
||||||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
console.log(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
src/utils/misc.ts
Normal file
11
src/utils/misc.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function buildISODate(date: string, time: string | null): string {
|
||||||
|
return new Date(date + 'T' + time || '00:00').toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
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