Compare commits
69 Commits
b3ce8e59cb
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
7fc640d679
|
|||
|
91b54cf791
|
|||
|
27b15a37f7
|
|||
|
947b463fe2
|
|||
|
c3098b073f
|
|||
|
b2420b270c
|
|||
|
9104ccab0f
|
|||
|
387af2e6ce
|
|||
|
6654132120
|
|||
|
59d2729719
|
|||
|
9f398e5509
|
|||
|
2fb236cf97
|
|||
|
7bc0573455
|
|||
|
68a2b8ffff
|
|||
|
ce696a5a04
|
|||
|
b0d6ec877b
|
|||
|
c03ad48615
|
|||
|
55bc1acbb3
|
|||
|
cd692a6f3b
|
|||
|
737de91bbc
|
|||
|
a6e357f973
|
|||
|
76b0498a18
|
|||
|
d6339815aa
|
|||
|
97481a5d2e
|
|||
|
369bbc4960
|
|||
|
c3ee739366
|
|||
|
adc34a116b
|
|||
|
b506ab7ca9
|
|||
|
dd631b71bb
|
|||
|
b0921ccf32
|
|||
|
78211a33ae
|
|||
|
4a273ccb2f
|
|||
|
3a67f2fbb1
|
|||
|
77619b0741
|
|||
|
ea785887a1
|
|||
|
b860e1d977
|
|||
|
274d0193f7
|
|||
|
033993b1b8
|
|||
|
2872fb867e
|
|||
|
8e73650462
|
|||
|
634cff507c
|
|||
|
fa4d83e42d
|
|||
|
c92f737612
|
|||
|
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
|
@@ -1,9 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
APPWRITE_API_ENDPOINT='https://appwrite.oys.undock.ca/v1'
|
||||||
|
APPWRITE_API_PROJECT='bab'
|
||||||
@@ -21,6 +21,8 @@ jobs:
|
|||||||
run: yarn install
|
run: yarn install
|
||||||
- name: Install Quasar CLI
|
- name: Install Quasar CLI
|
||||||
run: yarn global add @quasar/cli
|
run: yarn global add @quasar/cli
|
||||||
|
- name: Temporary - Invoke custom qcalendar build
|
||||||
|
run: quasar ext invoke @quasar/qcalendar
|
||||||
- name: Create env file
|
- name: Create env file
|
||||||
run: |
|
run: |
|
||||||
echo "${{ vars.ENV_FILE }}" > .env.local
|
echo "${{ vars.ENV_FILE }}" > .env.local
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"semi": true
|
|
||||||
}
|
|
||||||
4
appwrite.json
Normal file
4
appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "65ede55a213134f2b688",
|
||||||
|
"projectName": ""
|
||||||
|
}
|
||||||
20
docs/time.md
Normal file
20
docs/time.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Dealing with Time
|
||||||
|
|
||||||
|
Dealing with time sucks, okay? We have three different formats we need to deal with:
|
||||||
|
|
||||||
|
1. ES Date - The native ECMAScript Date object. This is saddled with all the legacy of the decades. Hopefully, we will be able to retire this one day... Ref: https://tc39.es/proposal-temporal/docs/index.html
|
||||||
|
2. ISO 8601 Date - Used by Appwrite backend. This is just a string, but can represent any date, with or without a timezone.
|
||||||
|
3. Timestamp - Used internally by QCalendar.
|
||||||
|
|
||||||
|
We can't just use one format. We need ISO8601 format for Appwrite, and we get passed Timestamp objects by QCalendar. In the middle of that, we need ES Date objects to do some underlying math.
|
||||||
|
|
||||||
|
Componentization:
|
||||||
|
In order to make things clean and modular, we will rely on Timestamp as the main format in a component.
|
||||||
|
|
||||||
|
In data that comes from, or goes to the backend, we will store absolute dates in ISO format.
|
||||||
|
|
||||||
|
For any user-facing dates / times, the data will be rendered in the users local time.
|
||||||
|
|
||||||
|
For time-only data (as used in Intervals, eg: '09:00'), the template will be stored as a string of 'hh:mm', and represent the users local time. We may want to change this in the future, as this could prove a problem when a user is travelling, but wants to apply a template to their home location.
|
||||||
|
|
||||||
|
For now, we'll use the Timestamp object provided by QCalendar. We might need to refactor this in the future.
|
||||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oys_bab",
|
"name": "oys_bab",
|
||||||
"version": "0.0.2",
|
"version": "0.6.1",
|
||||||
"description": "Manage a Borrow a Boat program for a Yacht Club",
|
"description": "Manage a Borrow a Boat program for a Yacht Club",
|
||||||
"productName": "OYS Borrow a Boat",
|
"productName": "OYS Borrow a Boat",
|
||||||
"author": "Patrick Toal <ptoal@takeflight.ca>",
|
"author": "Patrick Toal <ptoal@takeflight.ca>",
|
||||||
@@ -14,14 +14,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.11",
|
"@quasar/extras": "^1.16.11",
|
||||||
"appwrite": "^13.0.0",
|
"@quasar/quasar-app-extension-qcalendar": "https://github.com/ptoal/quasar-ui-qcalendar/releases/download/v4.0.0-beta.19/app-extension.tgz",
|
||||||
|
"@quasar/quasar-ui-qcalendar": "https://github.com/ptoal/quasar-ui-qcalendar/releases/download/v4.0.0-beta.19/qcalendar-ui.tgz",
|
||||||
|
"appwrite": "^14.0.1",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"file": "^0.2.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "3",
|
"vue": "3",
|
||||||
"vue-router": "4"
|
"vue-router": "4",
|
||||||
|
"vue3-google-login": "^2.0.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quasar/app-vite": "^1.7.4",
|
"@quasar/app-vite": "^1.9.1",
|
||||||
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.15",
|
|
||||||
"@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",
|
||||||
@@ -31,8 +35,10 @@
|
|||||||
"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",
|
||||||
"quasar": "^2.15.2",
|
"quasar": "^2.16.0",
|
||||||
"typescript": "^4.5.4",
|
"typescript": "~5.3.0",
|
||||||
|
"vite-plugin-checker": "^0.6.4",
|
||||||
|
"vue-tsc": "^1.8.22",
|
||||||
"workbox-build": "^7.0.0",
|
"workbox-build": "^7.0.0",
|
||||||
"workbox-cacheable-response": "^7.0.0",
|
"workbox-cacheable-response": "^7.0.0",
|
||||||
"workbox-core": "^7.0.0",
|
"workbox-core": "^7.0.0",
|
||||||
|
|||||||
BIN
public/tmpimg/JMI.jpg
Normal file
BIN
public/tmpimg/JMI.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -9,7 +9,6 @@
|
|||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||||
|
|
||||||
const { configure } = require('quasar/wrappers');
|
const { configure } = require('quasar/wrappers');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
module.exports = configure(function (/* ctx */) {
|
module.exports = configure(function (/* ctx */) {
|
||||||
return {
|
return {
|
||||||
@@ -49,13 +48,12 @@ 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,
|
env: require('dotenv').config().parsed,
|
||||||
target: {
|
target: {
|
||||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||||
node: 'node16',
|
node: 'node16',
|
||||||
},
|
},
|
||||||
|
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
|
||||||
// vueRouterBase,
|
// vueRouterBase,
|
||||||
// vueDevtools,
|
// vueDevtools,
|
||||||
// vueOptionsAPI: false,
|
// vueOptionsAPI: false,
|
||||||
@@ -74,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
|
||||||
@@ -93,6 +102,12 @@ module.exports = configure(function (/* ctx */) {
|
|||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
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: {
|
||||||
@@ -104,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
|
||||||
|
|||||||
15
src/App.vue
15
src/App.vue
@@ -2,10 +2,19 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent, onMounted } from 'vue';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import { useBoatStore } from './stores/boat';
|
||||||
|
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 useBoatStore().fetchBoats();
|
||||||
|
await useReservationStore().fetchUserReservations();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,75 @@
|
|||||||
import { boot } from 'quasar/wrappers';
|
import { boot } from 'quasar/wrappers';
|
||||||
import { Client, Account, Databases, ID } from 'appwrite';
|
import {
|
||||||
|
Client,
|
||||||
|
Account,
|
||||||
|
Databases,
|
||||||
|
Functions,
|
||||||
|
ID,
|
||||||
|
AppwriteException,
|
||||||
|
Teams,
|
||||||
|
} from 'appwrite';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
import { Dialog, Notify } from 'quasar';
|
import { Dialog, Notify } from 'quasar';
|
||||||
import type { Router } from 'vue-router';
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
|
||||||
// appwrite.io SaaS
|
let APPWRITE_API_ENDPOINT, APPWRITE_API_PROJECT;
|
||||||
// client
|
|
||||||
// .setEndpoint('https://api.bab.toal.ca/v1')
|
|
||||||
// .setProject('653ef6f76baf06d68034');
|
|
||||||
// const appDatabaseId = '654ac5044d1c446feb71';
|
|
||||||
|
|
||||||
// Private self-hosted appwrite
|
// Private self-hosted appwrite
|
||||||
client
|
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT) {
|
||||||
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
|
APPWRITE_API_ENDPOINT = process.env.APPWRITE_API_ENDPOINT;
|
||||||
.setProject(process.env.APPWRITE_API_PROJECT);
|
APPWRITE_API_PROJECT = process.env.APPWRITE_API_PROJECT;
|
||||||
//TODO move this to config file
|
} else if (process.env.DEV) {
|
||||||
|
APPWRITE_API_ENDPOINT = 'http://localhost:4000/api/v1';
|
||||||
|
APPWRITE_API_PROJECT = '65ede55a213134f2b688';
|
||||||
|
} else {
|
||||||
|
APPWRITE_API_ENDPOINT = 'https://appwrite.oys.undock.ca/v1';
|
||||||
|
APPWRITE_API_PROJECT = 'bab';
|
||||||
|
}
|
||||||
|
client.setEndpoint(APPWRITE_API_ENDPOINT).setProject(APPWRITE_API_PROJECT);
|
||||||
|
|
||||||
const AppwriteIds = {
|
const pwresetUrl = process.env.DEV
|
||||||
databaseId: '65ee1cbf9c2493faf15f',
|
? 'http://localhost:4000/pwreset'
|
||||||
collectionIdTask: '65ee1cd5b550023fae4f',
|
: 'https://oys.undock.ca/pwreset';
|
||||||
collectionIdTaskTags: '65ee21d72d5c8007c34c',
|
|
||||||
collectionIdSkillTags: '66072582a74d94a4bd01',
|
const AppwriteIds = process.env.DEV
|
||||||
};
|
? {
|
||||||
|
databaseId: '65ee1cbf9c2493faf15f',
|
||||||
|
collection: {
|
||||||
|
boat: 'boat',
|
||||||
|
reservation: 'reservation',
|
||||||
|
skillTags: 'skillTags',
|
||||||
|
task: 'task',
|
||||||
|
taskTags: 'taskTags',
|
||||||
|
interval: 'interval',
|
||||||
|
intervalTemplate: 'intervalTemplate',
|
||||||
|
},
|
||||||
|
function: {
|
||||||
|
userinfo: 'userinfo',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
databaseId: 'bab_prod',
|
||||||
|
collection: {
|
||||||
|
boat: 'boat',
|
||||||
|
reservation: 'reservation',
|
||||||
|
skillTags: 'skillTags',
|
||||||
|
task: 'task',
|
||||||
|
taskTags: 'taskTags',
|
||||||
|
interval: 'interval',
|
||||||
|
intervalTemplate: 'intervalTemplate',
|
||||||
|
},
|
||||||
|
function: {
|
||||||
|
userinfo: '664038294b5473ef0c8d',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const account = new Account(client);
|
const account = new Account(client);
|
||||||
const databases = new Databases(client);
|
const databases = new Databases(client);
|
||||||
|
const functions = new Functions(client);
|
||||||
|
const teams = new Teams(client);
|
||||||
|
|
||||||
let appRouter: Router;
|
let appRouter: Router;
|
||||||
|
|
||||||
export default boot(async ({ router }) => {
|
export default boot(async ({ router }) => {
|
||||||
@@ -56,7 +99,7 @@ async function logout() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
const notification = Notify.create({
|
const notification = Notify.create({
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -66,30 +109,56 @@ function login(email: string, password: string) {
|
|||||||
group: false,
|
group: false,
|
||||||
});
|
});
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
authStore
|
try {
|
||||||
.login(email, password)
|
await authStore.login(email, password);
|
||||||
.then(() => {
|
notification({
|
||||||
notification({
|
type: 'positive',
|
||||||
type: 'positive',
|
message: 'Logged in!',
|
||||||
message: 'Logged in!',
|
timeout: 2000,
|
||||||
timeout: 2000,
|
spinner: false,
|
||||||
spinner: false,
|
icon: 'check_circle',
|
||||||
icon: 'check_circle',
|
});
|
||||||
});
|
appRouter.replace({ name: 'index' });
|
||||||
console.log('Redirecting to index page');
|
} catch (error: unknown) {
|
||||||
appRouter.replace({ name: 'index' });
|
if (error instanceof AppwriteException) {
|
||||||
})
|
if (error.type === 'user_session_already_exists') {
|
||||||
.catch(function (reason: Error) {
|
appRouter.replace({ name: 'index' });
|
||||||
notification({
|
notification({
|
||||||
type: 'negative',
|
type: 'positive',
|
||||||
message: 'Login failed.',
|
message: 'Already Logged in!',
|
||||||
timeout: 1,
|
timeout: 2000,
|
||||||
});
|
spinner: false,
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
Dialog.create({
|
Dialog.create({
|
||||||
title: 'Login Error!',
|
title: 'Login Error!',
|
||||||
message: reason.message,
|
message: error.message,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
notification({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Login failed.',
|
||||||
|
timeout: 2000,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export { client, account, databases, ID, AppwriteIds, login, logout };
|
|
||||||
|
async function resetPassword(email: string) {
|
||||||
|
await account.createRecovery(email, pwresetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
client,
|
||||||
|
account,
|
||||||
|
teams,
|
||||||
|
databases,
|
||||||
|
functions,
|
||||||
|
ID,
|
||||||
|
AppwriteIds,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
resetPassword,
|
||||||
|
};
|
||||||
|
|||||||
284
src/components/BoatReservationComponent.vue
Normal file
284
src/components/BoatReservationComponent.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="q-pa-xs row q-gutter-xs">
|
||||||
|
<q-card
|
||||||
|
flat
|
||||||
|
class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h5 q-mt-none q-mb-xs">
|
||||||
|
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-list class="q-px-xs">
|
||||||
|
<q-item
|
||||||
|
class="q-pa-none"
|
||||||
|
clickable
|
||||||
|
@click="boatSelect = true">
|
||||||
|
<q-card
|
||||||
|
v-if="boat"
|
||||||
|
class="col-12">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img
|
||||||
|
:src="boat.imgSrc"
|
||||||
|
:fit="'scale-down'">
|
||||||
|
<div class="row absolute-top">
|
||||||
|
<div class="col text-h7 text-left">
|
||||||
|
{{ boat.name }}
|
||||||
|
</div>
|
||||||
|
<div class="col text-right text-caption">
|
||||||
|
{{ boat.class }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-card-section class="col-9">
|
||||||
|
<q-list
|
||||||
|
dense
|
||||||
|
class="row">
|
||||||
|
<q-item class="q-ma-none col-12">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge
|
||||||
|
color="primary"
|
||||||
|
label="Start" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-body2">
|
||||||
|
{{ formatDate(bookingForm.interval?.start) }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-ma-none col-12">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge
|
||||||
|
color="primary"
|
||||||
|
label="End" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
class="text-body2"
|
||||||
|
style="min-width: 150px">
|
||||||
|
{{ formatDate(bookingForm.interval?.end) }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator vertical />
|
||||||
|
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||||
|
{{ bookingDuration.hours }} hours
|
||||||
|
<div v-if="bookingDuration.minutes">
|
||||||
|
<q-separator />
|
||||||
|
{{ bookingDuration.minutes }} mins
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="col-12">
|
||||||
|
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||||
|
</div>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-px-none">
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
v-model="bookingForm.reason"
|
||||||
|
:options="reason_options"
|
||||||
|
label="Reason for sail" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-px-none">
|
||||||
|
<q-item-section>
|
||||||
|
<q-input
|
||||||
|
v-model="bookingForm.comment"
|
||||||
|
clearable
|
||||||
|
autogrow
|
||||||
|
filled
|
||||||
|
label="Additional Comments (optional)" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
label="Delete"
|
||||||
|
color="negative"
|
||||||
|
size="lg"
|
||||||
|
v-if="reservation?.$id"
|
||||||
|
@click="onDelete" />
|
||||||
|
<q-btn
|
||||||
|
label="Reset"
|
||||||
|
@click="onReset"
|
||||||
|
size="lg"
|
||||||
|
color="secondary" />
|
||||||
|
<q-btn
|
||||||
|
label="Submit"
|
||||||
|
@click="onSubmit"
|
||||||
|
size="lg"
|
||||||
|
color="primary" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<q-dialog
|
||||||
|
v-model="boatSelect"
|
||||||
|
full-width>
|
||||||
|
<BoatScheduleTableComponent
|
||||||
|
:model-value="bookingForm.interval"
|
||||||
|
@update:model-value="updateInterval" />
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
|
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||||
|
import { formatDate } from 'src/utils/schedule';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
interface BookingForm {
|
||||||
|
$id?: string;
|
||||||
|
user?: string;
|
||||||
|
interval?: Interval | null;
|
||||||
|
reason?: string;
|
||||||
|
members?: string[];
|
||||||
|
guests?: string[];
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
|
||||||
|
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const newForm = {
|
||||||
|
user: auth.currentUser?.$id,
|
||||||
|
interval: {} as Interval,
|
||||||
|
reason: 'Open Sail',
|
||||||
|
members: [],
|
||||||
|
guests: [],
|
||||||
|
comment: '',
|
||||||
|
};
|
||||||
|
const reservation = defineModel<Reservation>();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const boatSelect = ref(false);
|
||||||
|
const bookingForm = ref<BookingForm>({ ...newForm });
|
||||||
|
const $q = useQuasar();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
watch(reservation, (newReservation) => {
|
||||||
|
if (!newReservation) {
|
||||||
|
bookingForm.value = newForm;
|
||||||
|
} else {
|
||||||
|
const updatedReservation = {
|
||||||
|
...newReservation,
|
||||||
|
user: auth.currentUser?.$id,
|
||||||
|
interval: {
|
||||||
|
start: newReservation.start,
|
||||||
|
end: newReservation.end,
|
||||||
|
resource: newReservation.resource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
bookingForm.value = updatedReservation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInterval = (interval: Interval) => {
|
||||||
|
bookingForm.value.interval = interval;
|
||||||
|
boatSelect.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||||
|
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
|
||||||
|
const start = new Date(bookingForm.value.interval.start).getTime();
|
||||||
|
const end = new Date(bookingForm.value.interval.end).getTime();
|
||||||
|
const delta = Math.abs(end - start) / 1000;
|
||||||
|
const hours = Math.floor(delta / 3600) % 24;
|
||||||
|
const minutes = Math.floor(delta - hours * 3600) % 60;
|
||||||
|
return { hours: hours, minutes: minutes };
|
||||||
|
}
|
||||||
|
return { hours: 0, minutes: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingName = computed(() =>
|
||||||
|
auth.getUserNameById(bookingForm.value?.user)
|
||||||
|
);
|
||||||
|
|
||||||
|
const boat = computed((): Boat | null => {
|
||||||
|
const boatId = bookingForm.value.interval?.resource;
|
||||||
|
return boatStore.getBoatById(boatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
reservationStore.deleteReservation(reservation.value?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
bookingForm.value.interval = null;
|
||||||
|
bookingForm.value = reservation.value
|
||||||
|
? {
|
||||||
|
...reservation.value,
|
||||||
|
interval: {
|
||||||
|
start: reservation.value.start,
|
||||||
|
end: reservation.value.end,
|
||||||
|
resource: reservation.value.resource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { ...newForm };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const booking = bookingForm.value;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
booking.interval &&
|
||||||
|
booking.interval.resource &&
|
||||||
|
booking.interval.start &&
|
||||||
|
booking.interval.end &&
|
||||||
|
auth.currentUser
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// TODO: Make a proper validator
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newReservation = <Reservation>{
|
||||||
|
resource: booking.interval.resource,
|
||||||
|
start: booking.interval.start,
|
||||||
|
end: booking.interval.end,
|
||||||
|
user: auth.currentUser.$id,
|
||||||
|
status: 'confirmed',
|
||||||
|
reason: booking.reason,
|
||||||
|
comment: booking.comment,
|
||||||
|
$id: reservation.value?.$id,
|
||||||
|
};
|
||||||
|
const status = $q.notify({
|
||||||
|
color: 'secondary',
|
||||||
|
textColor: 'white',
|
||||||
|
message: 'Submitting Reservation',
|
||||||
|
spinner: true,
|
||||||
|
closeBtn: 'Dismiss',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 0,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const r = await reservationStore.createOrUpdateReservation(newReservation);
|
||||||
|
status({
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'cloud_done',
|
||||||
|
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
|
||||||
|
boatStore.getBoatById(r.resource)?.name
|
||||||
|
} at ${formatDate(r.start)}`,
|
||||||
|
spinner: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
status({
|
||||||
|
color: 'negative',
|
||||||
|
icon: 'error',
|
||||||
|
spinner: false,
|
||||||
|
message: 'Failed to book!' + e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.go(-1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
icon="calendar_month"
|
icon="calendar_month"
|
||||||
to="/schedule"
|
to="/schedule"
|
||||||
></q-route-tab>
|
></q-route-tab>
|
||||||
<q-route-tab
|
<!-- <q-route-tab
|
||||||
name="Checklists"
|
name="Checklists"
|
||||||
icon="checklist"
|
icon="checklist"
|
||||||
to="/checklist"
|
to="/checklist"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
></q-route-tab>
|
></q-route-tab>
|
||||||
<q-route-tab name="Tasks" icon="build" to="/task">
|
<q-route-tab name="Tasks" icon="build" to="/task">
|
||||||
<q-badge color="red" floating> NEW </q-badge>
|
<q-badge color="red" floating> NEW </q-badge>
|
||||||
</q-route-tab>
|
</q-route-tab> -->
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -4,22 +4,57 @@
|
|||||||
show-if-above
|
show-if-above
|
||||||
:width="200"
|
:width="200"
|
||||||
:breakpoint="1024"
|
:breakpoint="1024"
|
||||||
@update:model-value="$emit('drawer-toggle')"
|
@update:model-value="$emit('drawer-toggle')">
|
||||||
>
|
|
||||||
<q-scroll-area class="fit">
|
<q-scroll-area class="fit">
|
||||||
<q-list padding class="menu-list">
|
<q-list
|
||||||
<template v-for="link in links" :key="link.name">
|
padding
|
||||||
<q-item clickable v-ripple :to="link.to">
|
class="menu-list">
|
||||||
|
<template
|
||||||
|
v-for="link in enabledLinks"
|
||||||
|
:key="link.name">
|
||||||
|
<!-- TODO: Template this to be DRY -->
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
:to="link.to">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon :name="link.icon" />
|
<q-icon :name="link.icon" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section> {{ link.name }} </q-item-section>
|
<q-item-section>
|
||||||
|
<span :class="link.color ? `text-${link.color}` : ''">
|
||||||
|
{{ link.name }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-list v-if="link.sublinks">
|
||||||
|
<div
|
||||||
|
v-for="sublink in link.sublinks"
|
||||||
|
:key="sublink.name">
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
:to="sublink.to"
|
||||||
|
class="q-ml-md">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="sublink.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section>
|
||||||
|
<span :class="sublink.color ? `text-${sublink.color}` : ''">
|
||||||
|
{{ sublink.name }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</q-list>
|
||||||
</template>
|
</template>
|
||||||
<q-item clickable v-ripple @click="logout()">
|
<q-item
|
||||||
<q-item-section avatar><q-icon name="logout" /></q-item-section
|
clickable
|
||||||
><q-item-section>Logout</q-item-section>
|
v-ripple
|
||||||
|
@click="logout()">
|
||||||
|
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||||
|
<q-item-section>Logout</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-scroll-area>
|
</q-scroll-area>
|
||||||
@@ -28,7 +63,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { links } from 'src/router/navlinks.js';
|
import { enabledLinks } from 'src/router/navlinks.js';
|
||||||
import { logout } from 'boot/appwrite';
|
import { logout } from 'boot/appwrite';
|
||||||
|
|
||||||
defineProps(['drawer']);
|
defineProps(['drawer']);
|
||||||
|
|||||||
@@ -12,21 +12,20 @@
|
|||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
class="q-button"
|
class="q-button"
|
||||||
style="cursor: pointer; user-select: none"
|
style="cursor: pointer; user-select: none"
|
||||||
@click="onPrev"
|
@click="onPrev">
|
||||||
><</span
|
<
|
||||||
>
|
</span>
|
||||||
{{ formattedMonth }}
|
{{ formattedMonth }}
|
||||||
<span
|
<span
|
||||||
class="q-button"
|
class="q-button"
|
||||||
style="cursor: pointer; user-select: none"
|
style="cursor: pointer; user-select: none"
|
||||||
@click="onNext"
|
@click="onNext">
|
||||||
>></span
|
>
|
||||||
>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<div style="display: flex; width: 100%">
|
<div style="display: flex; width: 100%">
|
||||||
<q-calendar-month
|
<q-calendar-month
|
||||||
ref="calendar"
|
ref="calendar"
|
||||||
@@ -48,10 +46,10 @@
|
|||||||
date-type="rounded"
|
date-type="rounded"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@moved="onMoved"
|
@moved="onMoved"
|
||||||
@click-date="onClickDate"
|
@click-date="onClickDate" />
|
||||||
/>
|
</div>
|
||||||
</div></div
|
</div>
|
||||||
></q-card-section>
|
</q-card-section>
|
||||||
<q-calendar-resource
|
<q-calendar-resource
|
||||||
v-model="selectedDate"
|
v-model="selectedDate"
|
||||||
:model-resources="boatStore.boats"
|
:model-resources="boatStore.boats"
|
||||||
@@ -73,18 +71,25 @@
|
|||||||
@click-time="onClickTime"
|
@click-time="onClickTime"
|
||||||
@click-resource="onClickResource"
|
@click-resource="onClickResource"
|
||||||
@click-head-resources="onClickHeadResources"
|
@click-head-resources="onClickHeadResources"
|
||||||
@click-interval="onClickInterval"
|
@click-interval="onClickInterval">
|
||||||
>
|
|
||||||
<template #resource-intervals="{ scope }">
|
<template #resource-intervals="{ scope }">
|
||||||
<template v-for="(event, index) in getEvents(scope)" :key="index">
|
<template
|
||||||
<q-badge outline :label="event.title" :style="getStyle(event)" />
|
v-for="(event, index) in getEvents(scope)"
|
||||||
|
:key="index">
|
||||||
|
<q-badge
|
||||||
|
outline
|
||||||
|
:label="event.title"
|
||||||
|
:style="getStyle(event)" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #resource-label="{ scope: { resource } }">
|
<template #resource-label="{ scope: { resource } }">
|
||||||
<div class="col-12 .col-md-auto">
|
<div class="col-12 .col-md-auto">
|
||||||
{{ resource.displayName }}
|
{{ resource.displayName }}
|
||||||
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
<q-icon
|
||||||
|
v-if="resource.defects"
|
||||||
|
name="warning"
|
||||||
|
color="warning" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</q-calendar-resource>
|
</q-calendar-resource>
|
||||||
@@ -97,9 +102,10 @@
|
|||||||
dense
|
dense
|
||||||
@update:model-value="onUpdateDuration"
|
@update:model-value="onUpdateDuration"
|
||||||
label="Duration (hours)"
|
label="Duration (hours)"
|
||||||
stack-label
|
stack-label>
|
||||||
><template v-slot:append><q-icon name="timelapse" /></template></q-select
|
<template v-slot:append><q-icon name="timelapse" /></template>
|
||||||
></q-card-section>
|
</q-select>
|
||||||
|
</q-card-section>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
@@ -107,18 +113,20 @@ import {
|
|||||||
QCalendarResource,
|
QCalendarResource,
|
||||||
TimestampOrNull,
|
TimestampOrNull,
|
||||||
today,
|
today,
|
||||||
parseDate,
|
|
||||||
parseTimestamp,
|
parseTimestamp,
|
||||||
addToDate,
|
addToDate,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
|
parsed,
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
import { useScheduleStore } from 'src/stores/schedule';
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
import { date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { StatusTypes } from 'src/stores/schedule';
|
import type { StatusTypes } from 'src/stores/schedule.types';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
type EventData = {
|
interface EventData {
|
||||||
event: object;
|
event: object;
|
||||||
scope: {
|
scope: {
|
||||||
timestamp: object;
|
timestamp: object;
|
||||||
@@ -126,16 +134,16 @@ type EventData = {
|
|||||||
activeDate: boolean;
|
activeDate: boolean;
|
||||||
droppable: 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'],
|
||||||
@@ -145,8 +153,8 @@ const statusLookup = {
|
|||||||
|
|
||||||
const calendar = ref();
|
const calendar = ref();
|
||||||
const boatStore = useBoatStore();
|
const boatStore = useBoatStore();
|
||||||
const scheduleStore = useScheduleStore();
|
const reservationStore = useReservationStore();
|
||||||
const selectedDate = ref(today());
|
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||||
const duration = ref(1);
|
const duration = ref(1);
|
||||||
|
|
||||||
const formattedMonth = computed(() => {
|
const formattedMonth = computed(() => {
|
||||||
@@ -171,14 +179,14 @@ function monthFormatter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEvents(scope: ResourceIntervalScope) {
|
function getEvents(scope: ResourceIntervalScope) {
|
||||||
const resourceEvents = scheduleStore.getBoatReservations(
|
const resourceEvents = reservationStore.getReservationsByDate(
|
||||||
scope.resource.id,
|
selectedDate.value,
|
||||||
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')
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,16 +7,16 @@
|
|||||||
round
|
round
|
||||||
icon="menu"
|
icon="menu"
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
@click="toggleLeftDrawer"
|
@click="toggleLeftDrawer" />
|
||||||
/>
|
|
||||||
|
|
||||||
<q-toolbar-title> {{ pageTitle }} </q-toolbar-title>
|
<q-toolbar-title>{{ pageTitle }}</q-toolbar-title>
|
||||||
<q-tabs shrink>
|
<q-space />
|
||||||
<q-tab> </q-tab>
|
<div>v2024.6.4.2</div>
|
||||||
</q-tabs>
|
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
<LeftDrawer :drawer="leftDrawerOpen" @drawer-toggle="toggleLeftDrawer" />
|
<LeftDrawer
|
||||||
|
:drawer="leftDrawerOpen"
|
||||||
|
@drawer-toggle="toggleLeftDrawer" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-img v-if="boat?.iconsrc" :src="boat?.iconsrc" />
|
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
|
||||||
<q-icon v-else name="sailing" />
|
<q-icon v-else name="sailing" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
<div v-if="boats">
|
||||||
<q-card-section>
|
<q-card
|
||||||
<q-img :src="boat.imgsrc" :fit="'scale-down'">
|
v-for="boat in boats"
|
||||||
<div class="row absolute-top">
|
:key="boat.id"
|
||||||
<div class="col text-h5 text-left">{{ boat.name }}</div>
|
class="mobile-card q-ma-sm">
|
||||||
<div class="col text-right">{{ boat.class }}</div>
|
<q-card-section>
|
||||||
</div>
|
<q-img
|
||||||
</q-img>
|
:src="boat.imgSrc"
|
||||||
</q-card-section>
|
:fit="'scale-down'">
|
||||||
|
<div class="row absolute-top">
|
||||||
|
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||||
|
<div class="col text-right">{{ boat.class }}</div>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-card-actions align="evenly">
|
<!-- <q-card-actions align="evenly">
|
||||||
<q-btn flat>Info</q-btn>
|
<q-btn flat>Info</q-btn>
|
||||||
<q-btn flat>Book</q-btn>
|
<q-btn flat>Book</q-btn>
|
||||||
<q-btn flat>Check-Out</q-btn>
|
<q-btn flat>Check-Out</q-btn>
|
||||||
<q-btn flat>Check-In</q-btn>
|
<q-btn flat>Check-In</q-btn>
|
||||||
</q-card-actions>
|
</q-card-actions> -->
|
||||||
</q-card>
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
<template>
|
|
||||||
<q-card-section style="max-width: 320px">
|
|
||||||
<div class="text-caption">
|
|
||||||
Use the calendar to pick a date. Select an available boat and timeslot
|
|
||||||
below.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
width: 50%;
|
|
||||||
max-width: 350px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="q-button"
|
|
||||||
style="cursor: pointer; user-select: none"
|
|
||||||
@click="onPrev"
|
|
||||||
><</span
|
|
||||||
>
|
|
||||||
{{ formattedMonth }}
|
|
||||||
<span
|
|
||||||
class="q-button"
|
|
||||||
style="cursor: pointer; user-select: none"
|
|
||||||
@click="onNext"
|
|
||||||
>></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div style="display: flex; width: 100%">
|
|
||||||
<q-calendar-month
|
|
||||||
ref="calendar"
|
|
||||||
v-model="selectedDate"
|
|
||||||
:disabled-before="disabledBefore"
|
|
||||||
:disabled-days="disabledDays()"
|
|
||||||
animated
|
|
||||||
bordered
|
|
||||||
mini-mode
|
|
||||||
date-type="rounded"
|
|
||||||
@click-date="onClickDate"
|
|
||||||
@change="onChange"
|
|
||||||
/>
|
|
||||||
</div></div
|
|
||||||
></q-card-section>
|
|
||||||
<q-card-section style="max-width: 320px">
|
|
||||||
<div v-for="boat in boatStore.boats" :key="boat.name">
|
|
||||||
<q-item-label header>{{ boat.name }}</q-item-label>
|
|
||||||
<q-item>
|
|
||||||
<q-item-section>
|
|
||||||
<q-option-group
|
|
||||||
:options="boatoptions(boat)"
|
|
||||||
type="radio"
|
|
||||||
v-model="selectedBoatTime"
|
|
||||||
>
|
|
||||||
<template v-slot:label="opt">
|
|
||||||
<div class="row items-center">
|
|
||||||
{{ opt.label }}
|
|
||||||
<span class="text-caption" v-if="opt.disable"
|
|
||||||
>Reserved by {{ opt.user }}</span
|
|
||||||
>
|
|
||||||
</div></template
|
|
||||||
>
|
|
||||||
</q-option-group>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import {
|
|
||||||
today,
|
|
||||||
parseTimestamp,
|
|
||||||
addToDate,
|
|
||||||
Timestamp,
|
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
|
||||||
import { useScheduleStore, Timeblock } from 'src/stores/schedule';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { date } from 'quasar';
|
|
||||||
|
|
||||||
type EventData = {
|
|
||||||
event: object;
|
|
||||||
scope: {
|
|
||||||
timestamp: object;
|
|
||||||
columnindex: number;
|
|
||||||
activeDate: boolean;
|
|
||||||
droppable: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const calendar = ref();
|
|
||||||
const boatStore = useBoatStore();
|
|
||||||
const scheduleStore = useScheduleStore();
|
|
||||||
const selectedDate = ref(today());
|
|
||||||
const selectedBoatTime = ref();
|
|
||||||
|
|
||||||
const formattedMonth = computed(() => {
|
|
||||||
const date = new Date(selectedDate.value);
|
|
||||||
|
|
||||||
return monthFormatter()?.format(date);
|
|
||||||
});
|
|
||||||
|
|
||||||
const disabledBefore = computed(() => {
|
|
||||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
|
||||||
return addToDate(todayTs, { day: -1 }).date;
|
|
||||||
});
|
|
||||||
|
|
||||||
function monthFormatter() {
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('en-CA' || undefined, {
|
|
||||||
month: 'long',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disabledDays = () => {
|
|
||||||
// Placeholder. This should actually compute days when boats aren't available.
|
|
||||||
const days = [];
|
|
||||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
|
||||||
days.push(addToDate(todayTs, { day: 2 }).date);
|
|
||||||
return days;
|
|
||||||
};
|
|
||||||
|
|
||||||
const boatoptions = (boat: Boat) => {
|
|
||||||
const options = useScheduleStore()
|
|
||||||
.getTimeblocksForDate(date.extractDate(selectedDate.value, 'YYYY-MM-DD'))
|
|
||||||
.map((x: Timeblock) => {
|
|
||||||
const conflicts = getConflicts(x, boat);
|
|
||||||
return {
|
|
||||||
label: x.start.time + ' to ' + x.end.time,
|
|
||||||
value: boat.id + ':' + x.start.time,
|
|
||||||
disable: conflicts.length > 0,
|
|
||||||
user: conflicts[0]?.user,
|
|
||||||
boat: boat,
|
|
||||||
timeblock: x,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return options;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
|
||||||
|
|
||||||
function onPrev() {
|
|
||||||
calendar.value.prev();
|
|
||||||
}
|
|
||||||
function onNext() {
|
|
||||||
calendar.value.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClickDate(data: EventData) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function onChange(data: EventData) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getConflicts = (timeblock: Timeblock, boat: Boat) => {
|
|
||||||
const start = date.buildDate({
|
|
||||||
hour: timeblock.start.hour,
|
|
||||||
minute: timeblock.start.minute,
|
|
||||||
second: 0,
|
|
||||||
millisecond: 0,
|
|
||||||
});
|
|
||||||
const end = date.buildDate({
|
|
||||||
hour: timeblock.end.hour,
|
|
||||||
minute: timeblock.end.minute,
|
|
||||||
second: 0,
|
|
||||||
millisecond: 0,
|
|
||||||
});
|
|
||||||
return scheduleStore.getConflictingReservations(boat, start, end);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|||||||
173
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
173
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<q-expansion-item
|
||||||
|
expand-icon-toggle
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, template)"
|
||||||
|
v-model="expanded">
|
||||||
|
<template v-slot:header>
|
||||||
|
<q-item-section>
|
||||||
|
<q-input
|
||||||
|
label="Template name"
|
||||||
|
:borderless="!edit"
|
||||||
|
dense
|
||||||
|
v-model="template.name"
|
||||||
|
v-if="edit" />
|
||||||
|
<q-item-label
|
||||||
|
v-if="!edit"
|
||||||
|
class="cursor-pointer">
|
||||||
|
{{ template.name }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</template>
|
||||||
|
<q-card flat>
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-card-section class="q-pt-xs">
|
||||||
|
<q-list dense>
|
||||||
|
<q-item
|
||||||
|
v-for="(item, index) in template.timeTuples"
|
||||||
|
:key="item[0]">
|
||||||
|
<q-input
|
||||||
|
class="q-mx-sm"
|
||||||
|
dense
|
||||||
|
v-model="item[0]"
|
||||||
|
type="time"
|
||||||
|
label="Start"
|
||||||
|
:borderless="!edit"
|
||||||
|
:readonly="!edit" />
|
||||||
|
<q-input
|
||||||
|
class="q-mx-sm"
|
||||||
|
dense
|
||||||
|
v-model="item[1]"
|
||||||
|
type="time"
|
||||||
|
label="End"
|
||||||
|
:borderless="!edit"
|
||||||
|
:readonly="!edit">
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="delete"
|
||||||
|
@click="template.timeTuples.splice(index, 1)" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
label="Add interval"
|
||||||
|
@click="template.timeTuples.push(['00:00', '00:00'])" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions vertical>
|
||||||
|
<q-btn
|
||||||
|
v-if="!edit"
|
||||||
|
color="primary"
|
||||||
|
icon="edit"
|
||||||
|
label="Edit"
|
||||||
|
@click="toggleEdit" />
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
color="primary"
|
||||||
|
icon="save"
|
||||||
|
label="Save"
|
||||||
|
@click="saveTemplate($event, template)" />
|
||||||
|
<q-btn
|
||||||
|
v-if="edit"
|
||||||
|
color="secondary"
|
||||||
|
icon="cancel"
|
||||||
|
label="Cancel"
|
||||||
|
@click="revert" />
|
||||||
|
<q-btn
|
||||||
|
color="negative"
|
||||||
|
icon="delete"
|
||||||
|
label="Delete"
|
||||||
|
v-if="template.$id !== ''"
|
||||||
|
@click="deleteTemplate($event, template)" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-dialog v-model="alert">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Overlapped blocks!</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
<q-chip
|
||||||
|
square
|
||||||
|
icon="schedule"
|
||||||
|
v-for="item in overlapped"
|
||||||
|
:key="item.start">
|
||||||
|
{{ item.start }}-{{ item.end }}
|
||||||
|
</q-chip>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="OK"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||||
|
import { IntervalTemplate } from 'src/stores/schedule.types';
|
||||||
|
import { copyIntervalTemplate, timeTuplesOverlapped } from 'src/utils/schedule';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
const alert = ref(false);
|
||||||
|
const overlapped = ref();
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||||
|
const edit = ref(props.edit);
|
||||||
|
const expanded = ref(props.edit);
|
||||||
|
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||||
|
|
||||||
|
const revert = () => {
|
||||||
|
template.value = copyIntervalTemplate(props.modelValue);
|
||||||
|
edit.value = false;
|
||||||
|
emit('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEdit = () => {
|
||||||
|
edit.value = !edit.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTemplate = (
|
||||||
|
event: Event,
|
||||||
|
template: IntervalTemplate | undefined
|
||||||
|
) => {
|
||||||
|
if (template?.$id) intervalTemplateStore.deleteIntervalTemplate(template.$id);
|
||||||
|
};
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, template: IntervalTemplate) {
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('ID', template.$id || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saveTemplate = (evt: Event, template: IntervalTemplate | undefined) => {
|
||||||
|
if (!template) return false;
|
||||||
|
overlapped.value = timeTuplesOverlapped(template.timeTuples);
|
||||||
|
if (overlapped.value.length > 0) {
|
||||||
|
alert.value = true;
|
||||||
|
} else {
|
||||||
|
edit.value = false;
|
||||||
|
if (template.$id && template.$id !== 'unsaved') {
|
||||||
|
intervalTemplateStore.updateIntervalTemplate(template, template.$id);
|
||||||
|
} else {
|
||||||
|
intervalTemplateStore.createIntervalTemplate(template);
|
||||||
|
emit('saved');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
19
src/components/scheduling/NavigationBar.vue
Normal file
19
src/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div class="q-pa-md q-gutter-sm row">
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">
|
||||||
|
Today
|
||||||
|
</q-btn>
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">
|
||||||
|
< Prev
|
||||||
|
</q-btn>
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">
|
||||||
|
Next >
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits(['today', 'prev', 'next']);
|
||||||
|
</script>
|
||||||
116
src/components/scheduling/ReservationCardComponent.vue
Normal file
116
src/components/scheduling/ReservationCardComponent.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<q-card
|
||||||
|
bordered
|
||||||
|
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
|
||||||
|
class="q-ma-md">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-h6">
|
||||||
|
{{ boatStore.getBoatById(reservation.resource)?.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-subtitle2">
|
||||||
|
<p>
|
||||||
|
Start: {{ formatDate(reservation.start) }}
|
||||||
|
<br />
|
||||||
|
End: {{ formatDate(reservation.end) }}
|
||||||
|
<br />
|
||||||
|
Type: {{ reservation.reason }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
color="grey-7"
|
||||||
|
round
|
||||||
|
flat
|
||||||
|
icon="more_vert">
|
||||||
|
<q-menu
|
||||||
|
cover
|
||||||
|
auto-close>
|
||||||
|
<q-list>
|
||||||
|
<q-item clickable>
|
||||||
|
<q-item-section>remove card</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable>
|
||||||
|
<q-item-section>send feedback</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable>
|
||||||
|
<q-item-section>share</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- <q-card-section>Some more information here...</q-card-section> -->
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-card-actions v-if="!isPast(reservation.end)">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
:to="{ name: 'edit-reservation', params: { id: reservation.$id } }">
|
||||||
|
Modify
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
@click="cancelReservation()">
|
||||||
|
Delete
|
||||||
|
</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<q-dialog v-model="cancelDialog">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="row items-center">
|
||||||
|
<q-avatar
|
||||||
|
icon="warning"
|
||||||
|
color="negative"
|
||||||
|
text-color="white" />
|
||||||
|
<span class="q-ml-md">Warning!</span>
|
||||||
|
<p class="q-pt-md">
|
||||||
|
This will delete your reservation for
|
||||||
|
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
|
||||||
|
{{ formatDate(reservation?.start) }}
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
label="Cancel"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
label="Delete"
|
||||||
|
color="negative"
|
||||||
|
@click="reservationStore.deleteReservation(reservation)"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import type { Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { formatDate, isPast } from 'src/utils/schedule';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const cancelDialog = ref(false);
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
|
||||||
|
const reservation = defineModel<Reservation>({ required: true });
|
||||||
|
|
||||||
|
const cancelReservation = () => {
|
||||||
|
cancelDialog.value = true;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
271
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
271
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<q-card>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||||
|
<q-btn
|
||||||
|
icon="close"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
v-close-popup />
|
||||||
|
</q-toolbar>
|
||||||
|
<q-separator />
|
||||||
|
<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 getAvailableIntervals(
|
||||||
|
scope.timestamp,
|
||||||
|
boats[scope.columnIndex]
|
||||||
|
)"
|
||||||
|
:key="block.$id">
|
||||||
|
<div
|
||||||
|
class="timeblock"
|
||||||
|
:disabled="beforeNow(new Date(block.end))"
|
||||||
|
: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 column"
|
||||||
|
:style="
|
||||||
|
reservationStyles(
|
||||||
|
reservation,
|
||||||
|
scope.timeStartPos,
|
||||||
|
scope.timeDurationHeight
|
||||||
|
)
|
||||||
|
">
|
||||||
|
{{ getUserName(reservation.user) || 'loading...' }}
|
||||||
|
<br />
|
||||||
|
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</QCalendarDay>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</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, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const selectedBlock = defineModel<Interval | null>();
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
const { getAvailableIntervals } = useIntervalStore();
|
||||||
|
const calendar = ref<QCalendarDay | null>(null);
|
||||||
|
const now = ref(new Date());
|
||||||
|
let intervalId: string | number | NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await useBoatStore().fetchBoats();
|
||||||
|
await intervalTemplateStore.fetchIntervalTemplates();
|
||||||
|
intervalId = setInterval(function () {
|
||||||
|
now.value = new Date();
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => clearInterval(intervalId));
|
||||||
|
|
||||||
|
function handleSwipe({ ...event }) {
|
||||||
|
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 beforeNow(time: Date) {
|
||||||
|
return time < now.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayBodyScope {
|
||||||
|
columnIndex: number;
|
||||||
|
timeDurationHeight: string;
|
||||||
|
timeStartPos: (time: string, clamp: boolean) => string;
|
||||||
|
timestamp: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||||
|
if (scope.timestamp.disabled || new Date(block.end) < new Date())
|
||||||
|
return false;
|
||||||
|
selectedBlock.value = block;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boatReservations = computed((): Record<string, Reservation[]> => {
|
||||||
|
return reservationStore
|
||||||
|
.getReservationsByDate(selectedDate.value)
|
||||||
|
.reduce((result, reservation) => {
|
||||||
|
if (!result[reservation.resource]) result[reservation.resource] = [];
|
||||||
|
result[reservation.resource].push(reservation);
|
||||||
|
return result;
|
||||||
|
}, <Record<string, Reservation[]>>{});
|
||||||
|
});
|
||||||
|
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||||
|
const boat = boats.value[scope.columnIndex];
|
||||||
|
return boat ? boatReservations.value[boat.$id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.q-calendar-day__head--days__column
|
||||||
|
background: $primary
|
||||||
|
color: white
|
||||||
|
</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>
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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>
|
|
||||||
<!-- TODO: Add date formatting Mixin? https://jerickson.net/how-to-format-dates-in-vue-3/ -->
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import type { Task } from 'src/stores/task';
|
|
||||||
|
|
||||||
const props = defineProps<{ task: Task }>();
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
|
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="modifiedTask.title"
|
v-model="modifiedTask.title"
|
||||||
@@ -48,17 +48,18 @@
|
|||||||
<q-select
|
<q-select
|
||||||
label="Skills Required"
|
label="Skills Required"
|
||||||
hint="Add a list of required skills, to help people find things in their ability"
|
hint="Add a list of required skills, to help people find things in their ability"
|
||||||
v-model="skillTagList"
|
v-model="modifiedTask.required_skills"
|
||||||
use-input
|
use-input
|
||||||
use-chips
|
use-chips
|
||||||
multiple
|
multiple
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
input-debounce="250"
|
input-debounce="250"
|
||||||
@new-value="addTag"
|
|
||||||
:options="skillTagOptions"
|
:options="skillTagOptions"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
option-value="$id"
|
option-value="$id"
|
||||||
@filter="filterSkillTags"
|
@filter="filterSkillTags"
|
||||||
new-value-mode="add-unique"
|
|
||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,17 +67,18 @@
|
|||||||
<q-select
|
<q-select
|
||||||
label="Tags"
|
label="Tags"
|
||||||
hint="Add Tags to help with searching"
|
hint="Add Tags to help with searching"
|
||||||
v-model="taskTagList"
|
v-model="modifiedTask.tags"
|
||||||
use-input
|
use-input
|
||||||
use-chips
|
use-chips
|
||||||
multiple
|
multiple
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
input-debounce="250"
|
input-debounce="250"
|
||||||
@new-value="addTag"
|
|
||||||
:options="taskTagOptions"
|
:options="taskTagOptions"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
option-value="$id"
|
option-value="$id"
|
||||||
@filter="filterTaskTags"
|
@filter="filterTaskTags"
|
||||||
new-value-mode="add-unique"
|
|
||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +107,12 @@
|
|||||||
<q-select
|
<q-select
|
||||||
label="Dependencies"
|
label="Dependencies"
|
||||||
hint="Add a list of tasks that need to be complete before this one"
|
hint="Add a list of tasks that need to be complete before this one"
|
||||||
v-model="dependsList"
|
v-model="modifiedTask.depends_on"
|
||||||
use-input
|
use-input
|
||||||
multiple
|
multiple
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
input-debounce="250"
|
input-debounce="250"
|
||||||
:options="tasks"
|
:options="tasks"
|
||||||
option-label="title"
|
option-label="title"
|
||||||
@@ -122,7 +127,9 @@
|
|||||||
hint="Add a boat, if applicable"
|
hint="Add a boat, if applicable"
|
||||||
v-model="modifiedTask.boat"
|
v-model="modifiedTask.boat"
|
||||||
use-input
|
use-input
|
||||||
|
clearable
|
||||||
emit-value
|
emit-value
|
||||||
|
map-options
|
||||||
input-debounce="250"
|
input-debounce="250"
|
||||||
:options="boatList"
|
:options="boatList"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
@@ -138,7 +145,6 @@
|
|||||||
flat
|
flat
|
||||||
class="q-ml-sm"
|
class="q-ml-sm"
|
||||||
/>
|
/>
|
||||||
<q-btn label="Reset" type="reset" color="primary" flat class="q-ml-sm" />
|
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -151,14 +157,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, Ref } from 'vue';
|
import { computed, reactive, ref, Ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
|
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
|
||||||
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
|
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
|
||||||
import { date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
|
|
||||||
const props = defineProps<{ taskId: string }>();
|
const props = defineProps<{ taskId?: string }>();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
|
|
||||||
const defaultTask = <Task>{
|
const defaultTask = <Task>{
|
||||||
@@ -172,50 +178,50 @@ const defaultTask = <Task>{
|
|||||||
volunteers_required: 0,
|
volunteers_required: 0,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
depends_on: [],
|
depends_on: [],
|
||||||
boat: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
taskStore.fetchTasks();
|
||||||
|
|
||||||
const { taskId } = props;
|
const { taskId } = props;
|
||||||
const targetTask = taskId && taskStore.tasks.find((t) => t.$id === taskId);
|
const targetTask = taskId && taskStore.tasks.find((t) => t.$id === taskId);
|
||||||
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
|
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
|
||||||
|
|
||||||
taskStore.fetchSkillTags();
|
let tasks = taskStore.tasks;
|
||||||
taskStore.fetchTaskTags();
|
const boatList = useBoatStore().boats;
|
||||||
taskStore.fetchTasks();
|
|
||||||
|
|
||||||
const tasks = ref<Task[]>(taskStore.tasks);
|
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||||
|
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||||
|
|
||||||
const boatList = ref<Boat[]>(useBoatStore().boats);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const skillTagOptions = ref<SkillTag[]>();
|
update(() => {
|
||||||
const skillTagList = ref<SkillTag[]>([]);
|
tasks = taskStore.filterTasksByTitle(val);
|
||||||
|
});
|
||||||
const taskTagOptions = ref<TaskTag[]>();
|
}
|
||||||
const taskTagList = ref<TaskTag[]>([]);
|
);
|
||||||
|
|
||||||
const dependsList = ref<Task[]>([]);
|
|
||||||
|
|
||||||
function filterSkillTags(val: string, update: (cb: () => void) => void): void {
|
|
||||||
return filterTags(skillTagOptions, taskStore.skillTags, val, update);
|
|
||||||
}
|
|
||||||
function filterTaskTags(val: string, update: (cb: () => void) => void): void {
|
|
||||||
return filterTags(taskTagOptions, taskStore.taskTags, val, update);
|
|
||||||
}
|
|
||||||
function filterTasks(val: string, update: (cb: () => void) => void): void {
|
|
||||||
if (val === '') {
|
|
||||||
update(() => {
|
|
||||||
tasks.value = taskStore.tasks;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
update(() => {
|
|
||||||
tasks.value = taskStore.filterTasks(val);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterTags(
|
function filterTags(
|
||||||
optionVar: Ref<SkillTag[] | TaskTag[] | undefined>,
|
optionVar: Ref<(SkillTag | TaskTag)[] | undefined>,
|
||||||
optionSrc: SkillTag[] | TaskTag[],
|
optionSrc: SkillTag[] | TaskTag[],
|
||||||
val: string,
|
val: string,
|
||||||
update: (cb: () => void) => void
|
update: (cb: () => void) => void
|
||||||
@@ -234,9 +240,6 @@ function filterTags(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag(tag: string) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Method to update the model in ISO 8601 format
|
// Method to update the model in ISO 8601 format
|
||||||
const updateDateISO = (value: string) => {
|
const updateDateISO = (value: string) => {
|
||||||
modifiedTask.due_date = date.formatDate(value, 'YYYY-MM-DD');
|
modifiedTask.due_date = date.formatDate(value, 'YYYY-MM-DD');
|
||||||
@@ -249,24 +252,15 @@ const dateRule = (val: string) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
console.log(modifiedTask);
|
|
||||||
try {
|
try {
|
||||||
// It would probably be more performant to store the tags as objects in the
|
if (modifiedTask.$id) {
|
||||||
// form, and then extract the ID's before submitting.
|
await taskStore.updateTask(modifiedTask);
|
||||||
modifiedTask.required_skills = skillTagList.value.map((s) => s['$id']);
|
} else {
|
||||||
modifiedTask.tags = taskTagList.value.map((s) => s['$id']);
|
await taskStore.addTask(modifiedTask);
|
||||||
modifiedTask.depends_on = dependsList.value.map(
|
}
|
||||||
(d) => d['$id']
|
|
||||||
) as string[];
|
|
||||||
await taskStore.addTask(modifiedTask);
|
|
||||||
console.log('Created Task');
|
|
||||||
router.go(-1);
|
router.go(-1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create new Task: ', error);
|
console.error('Failed to create new Task: ', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
<div class="q-pa-md" style="max-width: 350px">
|
<div class="q-pa-md" style="max-width: 350px">
|
||||||
<q-list>
|
<q-list>
|
||||||
<div v-for="task in tasks" :key="task.id">
|
<div v-for="task in tasks" :key="task.id">
|
||||||
<TaskComponent :task="task" />
|
<TaskCardComponent :task="task" />
|
||||||
</div>
|
</div>
|
||||||
</q-list>
|
</q-list>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import type { Task } from 'src/stores/task';
|
import type { Task } from 'src/stores/task';
|
||||||
import TaskComponent from './TaskComponent.vue';
|
import TaskCardComponent from './TaskCardComponent.vue';
|
||||||
|
|
||||||
const props = defineProps<{ tasks: Task[] }>();
|
defineProps<{ tasks: Task[] }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,50 +1,225 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-sm">
|
||||||
<q-table
|
<q-table
|
||||||
:rows="tasks"
|
:rows="tasks"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
:grid="$q.screen.xs"
|
||||||
|
dense
|
||||||
row-key="$id"
|
row-key="$id"
|
||||||
|
flatten
|
||||||
no-data-label="I didn't find anything for you"
|
no-data-label="I didn't find anything for you"
|
||||||
no-results-label="The filter didn't uncover any results"
|
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>
|
<template v-slot:top>
|
||||||
<q-btn
|
<q-select
|
||||||
color="primary"
|
style="width: 250px"
|
||||||
:disable="loading"
|
multiple
|
||||||
label="New Task"
|
use-chips
|
||||||
to="/task/new"
|
clearable
|
||||||
/>
|
label="Skills Filter"
|
||||||
<q-btn
|
input-debounce="250"
|
||||||
v-if="tasks.length !== 0"
|
:options="skillTagOptions"
|
||||||
class="q-ml-sm"
|
v-model="searchFilter.skillTags"
|
||||||
color="primary"
|
option-label="name"
|
||||||
:disable="loading"
|
option-value="$id"
|
||||||
label="Delete task(s)"
|
>
|
||||||
@click="deleteTask"
|
</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-space />
|
||||||
<q-input
|
<q-input
|
||||||
borderless
|
flatten
|
||||||
dense
|
|
||||||
debounce="300"
|
debounce="300"
|
||||||
color="primary"
|
color="primary"
|
||||||
v-model="filter"
|
clearable
|
||||||
|
v-model="searchFilter.title"
|
||||||
>
|
>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<q-icon name="search" />
|
<q-icon name="search" />
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
</template>
|
</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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, ref } from 'vue';
|
import { computed, defineProps, ref } from 'vue';
|
||||||
import { useTaskStore, Task } from 'src/stores/task';
|
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
|
||||||
import type { QTableProps } from 'quasar';
|
import { QTableProps, date, useQuasar } from 'quasar';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
|
||||||
|
const selected = ref([]);
|
||||||
const loading = ref(false); // Placeholder
|
const loading = ref(false); // Placeholder
|
||||||
|
const fabShow = ref(false);
|
||||||
const columns = <QTableProps['columns']>[
|
const columns = <QTableProps['columns']>[
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
@@ -55,11 +230,12 @@ const columns = <QTableProps['columns']>[
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'description',
|
name: 'due_date',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'Description',
|
label: 'Due Date',
|
||||||
field: 'description',
|
field: 'due_date',
|
||||||
sortable: false,
|
format: (val) => date.formatDate(val, 'MMM DD, YYYY'),
|
||||||
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'status',
|
name: 'status',
|
||||||
@@ -68,18 +244,124 @@ const columns = <QTableProps['columns']>[
|
|||||||
field: 'status',
|
field: 'status',
|
||||||
sortable: true,
|
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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const props = defineProps<{ tasks: Task[] }>();
|
defineProps<{ tasks: Task[] }>();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
taskStore.fetchTaskTags();
|
const $q = useQuasar();
|
||||||
taskStore.fetchSkillTags();
|
|
||||||
|
|
||||||
function newTask() {
|
interface SearchObject {
|
||||||
return;
|
title: string;
|
||||||
|
skillTags: SkillTag[];
|
||||||
|
taskTags: TaskTag[];
|
||||||
}
|
}
|
||||||
function deleteTask() {
|
|
||||||
return;
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const filter = ref('');
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
|
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
|
||||||
import { ref } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { useBoatStore } from 'src/stores/boat';
|
import { useBoatStore } from 'src/stores/boat';
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
const boats = ref(useBoatStore().boats);
|
const boatStore = useBoatStore();
|
||||||
|
const { boats } = storeToRefs(boatStore);
|
||||||
|
|
||||||
|
onMounted(() => boatStore.fetchBoats());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
|
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
|
||||||
<q-list class="full-width mobile-only">
|
<q-list class="full-width mobile-only">
|
||||||
<q-item
|
<q-item
|
||||||
v-for="link in links.filter((x) => x.front_links)"
|
v-for="link in enabledLinks.filter((x) => x.front_links)"
|
||||||
:key="link.name"
|
:key="link.name"
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -23,6 +23,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { links } from 'src/router/navlinks.js';
|
import { enabledLinks } from 'src/router/navlinks.js';
|
||||||
import ToolbarComponent from 'components/ToolbarComponent.vue';
|
import ToolbarComponent from 'components/ToolbarComponent.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,48 +3,55 @@
|
|||||||
<q-page-container>
|
<q-page-container>
|
||||||
<q-page class="flex bg-image flex-center">
|
<q-page class="flex bg-image flex-center">
|
||||||
<q-card
|
<q-card
|
||||||
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }"
|
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
>
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-img fit="scale-down" src="~assets/oysqn_logo.png" />
|
<q-img
|
||||||
|
fit="scale-down"
|
||||||
|
src="~assets/oysqn_logo.png" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-center q-pt-sm">
|
<div class="text-center q-pt-sm">
|
||||||
<div class="col text-h6">Log in</div>
|
<div class="col text-h6">Log in</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-form>
|
||||||
<q-form class="q-gutter-md">
|
<q-card-section class="q-gutter-md">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="email"
|
v-model="email"
|
||||||
label="E-Mail"
|
label="E-Mail"
|
||||||
type="email"
|
type="email"
|
||||||
color="darkblue"
|
color="darkblue"
|
||||||
filled
|
filled></q-input>
|
||||||
></q-input>
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
color="darkblue"
|
color="darkblue"
|
||||||
filled
|
filled></q-input>
|
||||||
></q-input>
|
<q-card-actions>
|
||||||
<q-btn
|
<q-btn
|
||||||
type="submit"
|
type="button"
|
||||||
@click="login(email, password)"
|
@click="doLogin"
|
||||||
label="Login"
|
label="Login"
|
||||||
color="primary"
|
color="primary"></q-btn>
|
||||||
></q-btn>
|
<q-space />
|
||||||
<!-- <q-btn
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="secondary"
|
||||||
|
to="/pwreset">
|
||||||
|
Reset password
|
||||||
|
</q-btn>
|
||||||
|
<!-- <q-btn
|
||||||
type="button"
|
type="button"
|
||||||
@click="register"
|
@click="register"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
label="Register"
|
label="Register"
|
||||||
flat
|
flat
|
||||||
></q-btn> -->
|
></q-btn> -->
|
||||||
</q-form>
|
</q-card-actions>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section><GoogleOauthComponent /></q-card-section>
|
</q-form>
|
||||||
|
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
@@ -69,8 +76,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { login } from 'boot/appwrite';
|
import { login } from 'boot/appwrite';
|
||||||
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
// import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
||||||
|
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
|
login(email.value, password.value);
|
||||||
|
};
|
||||||
</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>
|
|
||||||
123456
|
|
||||||
<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="green" text-color="white"
|
<div>
|
||||||
>J/27</q-chip
|
<q-chip square icon="verified" color="green" text-color="white"
|
||||||
>
|
>J/27</q-chip
|
||||||
<q-chip square icon="verified" color="blue" text-color="white"
|
>
|
||||||
>Capri25</q-chip
|
<q-chip square icon="verified" color="blue" text-color="white"
|
||||||
>
|
>Capri25</q-chip
|
||||||
<q-chip square icon="verified" color="red" text-color="white"
|
>
|
||||||
>Night</q-chip
|
<q-chip square icon="verified" color="grey-9" text-color="white"
|
||||||
>
|
>Night</q-chip
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
@@ -42,4 +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>
|
||||||
|
|||||||
190
src/pages/ResetPassword.vue
Normal file
190
src/pages/ResetPassword.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex bg-image flex-center">
|
||||||
|
<q-card
|
||||||
|
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img
|
||||||
|
fit="scale-down"
|
||||||
|
src="~assets/oysqn_logo.png" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-pt-sm">
|
||||||
|
<div class="col text-h6">Reset Password</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-form v-if="!isPasswordResetLink()">
|
||||||
|
<q-card-section class="q-ma-sm">
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
color="darkblue"
|
||||||
|
@keydown.enter.prevent="resetPw"
|
||||||
|
filled></q-input>
|
||||||
|
<div class="text-caption q-py-md">
|
||||||
|
Enter your e-mail address. If we have an account with that
|
||||||
|
address on file, you will be e-mailed a link to reset your
|
||||||
|
password.
|
||||||
|
</div>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
type="button"
|
||||||
|
@click="resetPw"
|
||||||
|
label="Send Reset Link"
|
||||||
|
color="primary"></q-btn>
|
||||||
|
<!-- <q-btn
|
||||||
|
type="button"
|
||||||
|
@click="register"
|
||||||
|
color="secondary"
|
||||||
|
label="Register"
|
||||||
|
flat
|
||||||
|
></q-btn> -->
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card-section>
|
||||||
|
</q-form>
|
||||||
|
<q-form
|
||||||
|
@submit="submitNewPw"
|
||||||
|
v-else-if="validResetLink()">
|
||||||
|
<q-card-section class="q-ma-sm">
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
color="darkblue"
|
||||||
|
:rules="[validatePasswordStrength]"
|
||||||
|
lazy-rules
|
||||||
|
filled></q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
label="Confirm New Password"
|
||||||
|
type="password"
|
||||||
|
color="darkblue"
|
||||||
|
:rules="[validatePasswordStrength]"
|
||||||
|
lazy-rules
|
||||||
|
filled></q-input>
|
||||||
|
<div class="text-caption q-py-md">Enter a new password.</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
label="Reset Password"
|
||||||
|
color="primary"></q-btn>
|
||||||
|
<!-- <q-btn
|
||||||
|
type="button"
|
||||||
|
@click="register"
|
||||||
|
color="secondary"
|
||||||
|
label="Register"
|
||||||
|
flat
|
||||||
|
></q-btn> -->
|
||||||
|
</q-card-actions>
|
||||||
|
</q-form>
|
||||||
|
<q-card
|
||||||
|
v-else
|
||||||
|
class="text-center">
|
||||||
|
<span class="text-h5">Invalid reset link.</span>
|
||||||
|
</q-card>
|
||||||
|
<!-- <q-card-section><GoogleOauthComponent /></q-card-section> -->
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-image {
|
||||||
|
background-image: url('/src/assets/oys_lighthouse.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position-x: center;
|
||||||
|
background-size: cover;
|
||||||
|
/* background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#ed232a 0%,
|
||||||
|
#ffffff 75%,
|
||||||
|
#14539a 100%
|
||||||
|
); */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { account, resetPassword } from 'boot/appwrite';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Dialog } from 'quasar';
|
||||||
|
// import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const router = useRouter();
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
|
const validatePasswordStrength = (val: string) => {
|
||||||
|
const hasUpperCase = /[A-Z]/.test(val);
|
||||||
|
const hasLowerCase = /[a-z]/.test(val);
|
||||||
|
const hasNumbers = /[0-9]/.test(val);
|
||||||
|
const hasNonAlphas = /[\W_]/.test(val);
|
||||||
|
const isValidLength = val.length >= 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hasUpperCase &&
|
||||||
|
hasLowerCase &&
|
||||||
|
hasNumbers &&
|
||||||
|
hasNonAlphas &&
|
||||||
|
isValidLength) ||
|
||||||
|
'Password must be at least 8 characters long and include uppercase, lowercase, number, and special character.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePasswordsMatch = (val: string) => {
|
||||||
|
return val === password.value || 'Passwords do not match.';
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPasswordResetLink() {
|
||||||
|
const query = router.currentRoute.value.query;
|
||||||
|
return query && query.secret && query.userId && query.expire;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validResetLink(): boolean {
|
||||||
|
const query = router.currentRoute.value.query;
|
||||||
|
const expire = query.expire ? new Date(query.expire + 'Z') : null;
|
||||||
|
return Boolean(
|
||||||
|
query && expire && query.secret && query.userId && new Date() < expire
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitNewPw() {
|
||||||
|
const query = router.currentRoute.value.query;
|
||||||
|
if (
|
||||||
|
validatePasswordStrength(password.value) === true &&
|
||||||
|
validatePasswordsMatch(confirmPassword.value) === true
|
||||||
|
) {
|
||||||
|
account
|
||||||
|
.updateRecovery(
|
||||||
|
query.userId as string,
|
||||||
|
query.secret as string,
|
||||||
|
password.value
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
Dialog.create({ message: 'Password Changed!' });
|
||||||
|
router.replace('/login');
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
Dialog.create({
|
||||||
|
message: 'Password change failed! Error: ' + e.message,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPw() {
|
||||||
|
resetPassword(email.value)
|
||||||
|
.then(() => router.replace('/login'))
|
||||||
|
.finally(() =>
|
||||||
|
Dialog.create({
|
||||||
|
message:
|
||||||
|
'If your address is in our system, you should receive an e-mail shortly.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,157 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<BoatReservationComponent v-model="newReservation" />
|
||||||
<q-list>
|
|
||||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
bottom-slots
|
|
||||||
v-model="bookingForm.name"
|
|
||||||
label="Creating reservation for"
|
|
||||||
readonly
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="person" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
v-model="resourceView"
|
|
||||||
icon="calendar_month"
|
|
||||||
label="Boat and Time"
|
|
||||||
default-opened
|
|
||||||
:caption="bookingSummary"
|
|
||||||
>
|
|
||||||
<q-separator />
|
|
||||||
<boat-selection />
|
|
||||||
|
|
||||||
<q-banner
|
|
||||||
rounded
|
|
||||||
class="bg-warning text-grey-10"
|
|
||||||
v-if="bookingForm.boat?.defects"
|
|
||||||
>
|
|
||||||
<template v-slot:avatar>
|
|
||||||
<q-icon name="warning" color="grey-10" />
|
|
||||||
</template>
|
|
||||||
{{ bookingForm.boat.name }} currently has the following notices:
|
|
||||||
<ol>
|
|
||||||
<li
|
|
||||||
v-for="defect in bookingForm.boat.defects"
|
|
||||||
:key="defect.description"
|
|
||||||
>
|
|
||||||
{{ defect.description }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</q-banner>
|
|
||||||
<q-card-section>
|
|
||||||
<q-btn
|
|
||||||
color="primary"
|
|
||||||
class="full-width"
|
|
||||||
icon="keyboard_arrow_down"
|
|
||||||
icon-right="keyboard_arrow_down"
|
|
||||||
label="Next: Crew & Passengers"
|
|
||||||
@click="resourceView = false"
|
|
||||||
/></q-card-section>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="people"
|
|
||||||
label="Crew and Passengers"
|
|
||||||
default-opened
|
|
||||||
>
|
|
||||||
<q-separator />
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
<q-item-section>
|
|
||||||
<q-btn label="Submit" type="submit" color="primary" />
|
|
||||||
</q-item-section> </q-form
|
|
||||||
></q-list>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, watch } from 'vue';
|
import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
import { Dialog, date } from 'quasar';
|
import { ref } from 'vue';
|
||||||
import BoatSelection from 'src/components/scheduling/BoatSelection.vue';
|
import { useRoute } from 'vue-router';
|
||||||
import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
|
|
||||||
import { useScheduleStore, Reservation } from 'src/stores/schedule';
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const $route = useRoute();
|
||||||
const dateFormat = 'ddd MMM D, YYYY h:mm A';
|
const newReservation = ref<Reservation>();
|
||||||
const resourceView = ref(true);
|
|
||||||
const scheduleStore = useScheduleStore();
|
|
||||||
const bookingForm = reactive({
|
|
||||||
bookingId: scheduleStore.getNewId(),
|
|
||||||
name: auth.currentUser?.name,
|
|
||||||
boat: <Boat | undefined>undefined,
|
|
||||||
startDate: date.formatDate(new Date(), dateFormat),
|
|
||||||
endDate: computed(() =>
|
|
||||||
date.formatDate(
|
|
||||||
date.addToDate(bookingForm.startDate, {
|
|
||||||
hours: bookingForm.duration,
|
|
||||||
}),
|
|
||||||
dateFormat
|
|
||||||
)
|
|
||||||
),
|
|
||||||
duration: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(bookingForm, (b, a) => {
|
if (typeof $route.query.interval === 'string') {
|
||||||
const newRes = <Reservation>{
|
useIntervalStore()
|
||||||
id: b.bookingId,
|
.fetchInterval($route.query.interval)
|
||||||
user: b.name,
|
.then(
|
||||||
resource: b.boat,
|
(interval: Interval) =>
|
||||||
start: date.extractDate(b.startDate, dateFormat),
|
(newReservation.value = <Reservation>{
|
||||||
end: date.extractDate(b.endDate, dateFormat),
|
resource: interval.resource,
|
||||||
reservationDate: new Date(),
|
start: interval.start,
|
||||||
status: 'tentative',
|
end: interval.end,
|
||||||
};
|
})
|
||||||
//TODO: Turn this into a validator.
|
);
|
||||||
scheduleStore.isReservationOverlapped(newRes)
|
}
|
||||||
? Dialog.create({ message: 'This booking overlaps another!' })
|
|
||||||
: scheduleStore.addOrCreateReservation(newRes);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onReset = () => {
|
|
||||||
// TODO
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
|
||||||
// TODO
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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 diff = date.getDateDiff(
|
|
||||||
bookingForm.endDate,
|
|
||||||
bookingForm.startDate,
|
|
||||||
'minutes'
|
|
||||||
);
|
|
||||||
return diff <= 0
|
|
||||||
? 'Invalid'
|
|
||||||
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
|
|
||||||
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookingSummary = computed(() => {
|
|
||||||
return bookingForm.boat && bookingForm.startDate && bookingForm.endDate
|
|
||||||
? `${bookingForm.boat.name} @ ${bookingForm.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,163 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page>
|
||||||
<!-- content -->
|
<div class="col">
|
||||||
|
<navigation-bar
|
||||||
|
@today="onToday"
|
||||||
|
@prev="onPrev"
|
||||||
|
@next="onNext" />
|
||||||
|
</div>
|
||||||
|
<div class="col q-ma-sm">
|
||||||
|
<q-calendar-scheduler
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
v-model:model-resources="boatStore.boats"
|
||||||
|
resource-key="$id"
|
||||||
|
resource-label="displayName"
|
||||||
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
|
:view="$q.screen.gt.md ? 'week' : 'day'"
|
||||||
|
v-touch-swipe.mouse.left.right="handleSwipe"
|
||||||
|
:max-days="$q.screen.lt.sm ? 3 : 7"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
style="--calendar-resources-width: 40px">
|
||||||
|
<template #day="{ scope }">
|
||||||
|
<div
|
||||||
|
v-for="interval in getSortedIntervals(
|
||||||
|
scope.timestamp,
|
||||||
|
scope.resource
|
||||||
|
)"
|
||||||
|
:key="interval.$id"
|
||||||
|
class="q-pb-xs row"
|
||||||
|
@click="createReservationFromInterval(interval)">
|
||||||
|
<q-badge
|
||||||
|
multi-line
|
||||||
|
:class="!interval.user ? 'cursor-pointer' : null"
|
||||||
|
class="col-12 q-pa-sm"
|
||||||
|
:transparent="interval.user != undefined"
|
||||||
|
:color="interval.user ? 'secondary' : 'primary'"
|
||||||
|
:outline="!interval.user"
|
||||||
|
:id="interval.id">
|
||||||
|
{{
|
||||||
|
interval.user
|
||||||
|
? useAuthStore().getUserNameById(interval.user)
|
||||||
|
: 'Available'
|
||||||
|
}}
|
||||||
|
<br />
|
||||||
|
{{ formatTime(interval.start) }} to
|
||||||
|
<br />
|
||||||
|
{{ formatTime(interval.end) }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-scheduler>
|
||||||
|
</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 { ref } from 'vue';
|
||||||
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
|
||||||
const scheduleStore = useScheduleStore();
|
const reservationStore = useReservationStore();
|
||||||
scheduleStore.loadSampleData();
|
import { getDate } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { formatTime } from 'src/utils/schedule';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const calendar = ref();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const $router = useRouter();
|
||||||
|
const { getAvailableIntervals } = useIntervalStore();
|
||||||
|
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||||
|
const currentUser = useAuthStore().currentUser;
|
||||||
|
|
||||||
|
// interface DayScope {
|
||||||
|
// timestamp: Timestamp;
|
||||||
|
// columnIndex: number;
|
||||||
|
// resource: object;
|
||||||
|
// resourceIndex: number;
|
||||||
|
// indentLevel: number;
|
||||||
|
// activeDate: boolean;
|
||||||
|
// droppable: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
|
||||||
|
return getAvailableIntervals(timestamp, boat)
|
||||||
|
.concat(boatReservationEvents(timestamp, boat))
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||||
|
};
|
||||||
|
// 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;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const createReservationFromInterval = (interval: Interval | Reservation) => {
|
||||||
|
if (interval.user) {
|
||||||
|
if (interval.user === currentUser?.$id) {
|
||||||
|
$router.push({ name: 'edit-reservation', params: { id: interval.$id } });
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$router.push({
|
||||||
|
name: 'reserve-boat',
|
||||||
|
query: { interval: interval.$id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSwipe({ ...event }) {
|
||||||
|
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||||
|
}
|
||||||
|
function boatReservationEvents(
|
||||||
|
timestamp: Timestamp,
|
||||||
|
resource: Boat | undefined
|
||||||
|
) {
|
||||||
|
if (!resource) return [];
|
||||||
|
return reservationStore.getReservationsByDate(
|
||||||
|
getDate(timestamp),
|
||||||
|
(resource as Boat).$id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function onToday() {
|
||||||
|
calendar.value.moveToToday();
|
||||||
|
}
|
||||||
|
function onPrev() {
|
||||||
|
calendar.value.prev();
|
||||||
|
}
|
||||||
|
function onNext() {
|
||||||
|
calendar.value.next();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
.q-calendar-scheduler__resource
|
||||||
|
background-color: $primary
|
||||||
|
color: white
|
||||||
|
font-weight: bold
|
||||||
|
</style>
|
||||||
|
|||||||
86
src/pages/schedule/ListReservationsPage.vue
Normal file
86
src/pages/schedule/ListReservationsPage.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<q-tabs
|
||||||
|
v-model="tab"
|
||||||
|
inline-label
|
||||||
|
class="text-primary">
|
||||||
|
<q-tab
|
||||||
|
name="upcoming"
|
||||||
|
icon="schedule"
|
||||||
|
label="Upcoming" />
|
||||||
|
<q-tab
|
||||||
|
name="past"
|
||||||
|
icon="history"
|
||||||
|
label="Past" />
|
||||||
|
</q-tabs>
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-tab-panels
|
||||||
|
v-model="tab"
|
||||||
|
animated>
|
||||||
|
<q-tab-panel
|
||||||
|
name="upcoming"
|
||||||
|
class="q-pa-none">
|
||||||
|
<q-card
|
||||||
|
clas="q-ma-md"
|
||||||
|
v-if="!futureUserReservations.length">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">You don't have any upcoming bookings!</div>
|
||||||
|
<div class="text-h8">Why don't you go make one?</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="event"
|
||||||
|
:size="`1.25em`"
|
||||||
|
label="Book Now"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
:align="'left'"
|
||||||
|
to="/schedule/book" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="reservation in futureUserReservations"
|
||||||
|
:key="reservation.$id">
|
||||||
|
<ReservationCardComponent :modelValue="reservation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel
|
||||||
|
name="past"
|
||||||
|
class="q-pa-none">
|
||||||
|
<div
|
||||||
|
v-for="reservation in pastUserReservations"
|
||||||
|
:key="reservation.$id">
|
||||||
|
<ReservationCardComponent :modelValue="reservation" />
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import ReservationCardComponent from 'src/components/scheduling/ReservationCardComponent.vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const { futureUserReservations, pastUserReservations } = useReservationStore();
|
||||||
|
|
||||||
|
onMounted(() => useReservationStore().fetchUserReservations());
|
||||||
|
|
||||||
|
const tab = ref('upcoming');
|
||||||
|
|
||||||
|
// const showMarker = (
|
||||||
|
// index: number,
|
||||||
|
// items: Reservation[] | undefined
|
||||||
|
// ): boolean => {
|
||||||
|
// if (!items) return false;
|
||||||
|
|
||||||
|
// const currentItemDate = new Date(items[index].start);
|
||||||
|
// const nextItemDate = index > 0 ? new Date(items[index - 1].start) : null;
|
||||||
|
|
||||||
|
// // Show marker if current item is past and the next item is future or vice versa
|
||||||
|
// return (
|
||||||
|
// isPast(currentItemDate) && (nextItemDate === null || !isPast(nextItemDate))
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
</script>
|
||||||
322
src/pages/schedule/ManageCalendar.vue
Normal file
322
src/pages/schedule/ManageCalendar.vue
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fit row wrap justify-start items-start content-start">
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div
|
||||||
|
class="scheduler"
|
||||||
|
style="max-width: 1200px">
|
||||||
|
<NavigationBar
|
||||||
|
@next="onNext"
|
||||||
|
@today="onToday"
|
||||||
|
@prev="onPrev" />
|
||||||
|
<q-calendar-scheduler
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
v-model:model-resources="boats"
|
||||||
|
resource-key="$id"
|
||||||
|
resource-label="name"
|
||||||
|
view="week"
|
||||||
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
:drag-enter-func="onDragEnter"
|
||||||
|
:drag-over-func="onDragOver"
|
||||||
|
:drag-leave-func="onDragLeave"
|
||||||
|
:drop-func="onDrop"
|
||||||
|
day-min-height="50px"
|
||||||
|
cell-width="150px">
|
||||||
|
<template #day="{ scope }">
|
||||||
|
<div
|
||||||
|
v-if="filteredIntervals(scope.timestamp, scope.resource).length"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
">
|
||||||
|
<template
|
||||||
|
v-for="block in sortedIntervals(
|
||||||
|
scope.timestamp,
|
||||||
|
scope.resource
|
||||||
|
)"
|
||||||
|
:key="block.id">
|
||||||
|
<q-chip class="cursor-pointer">
|
||||||
|
{{ date.formatDate(block.start, 'HH:mm') }} -
|
||||||
|
{{ date.formatDate(block.end, 'HH:mm') }}
|
||||||
|
<!-- <q-popup-edit
|
||||||
|
:model-value="block"
|
||||||
|
v-slot="scope"
|
||||||
|
buttons
|
||||||
|
@save="saveInterval"
|
||||||
|
>
|
||||||
|
TODO: Why isn't this saving?
|
||||||
|
<q-input
|
||||||
|
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
|
||||||
|
dense
|
||||||
|
autofocus
|
||||||
|
type="time"
|
||||||
|
label="start"
|
||||||
|
@keyup.enter="scope.set"
|
||||||
|
@update:model-value="
|
||||||
|
(t) => {
|
||||||
|
block.start = new Date(
|
||||||
|
scope.value.start.split('T')[0] + 'T' + t
|
||||||
|
).toISOString();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
TODO: Clean this up
|
||||||
|
<q-input
|
||||||
|
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
|
||||||
|
dense
|
||||||
|
type="time"
|
||||||
|
label="end"
|
||||||
|
@keyup.enter="scope.set"
|
||||||
|
@update:model-value="
|
||||||
|
(t) =>
|
||||||
|
(block.end = new Date(
|
||||||
|
scope.value.end.split('T')[0] + 'T' + t
|
||||||
|
).toISOString())
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</q-popup-edit>-->
|
||||||
|
</q-chip>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
round
|
||||||
|
@click="deleteBlock(block)" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-scheduler>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="q-pa-md"
|
||||||
|
style="width: 400px">
|
||||||
|
<q-list
|
||||||
|
padding
|
||||||
|
bordered
|
||||||
|
class="rounded-borders">
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>Availability Templates</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
Drag and drop a template to a boat / date to create booking
|
||||||
|
availability
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
label="Add Template"
|
||||||
|
color="primary"
|
||||||
|
@click="createTemplate" />
|
||||||
|
</q-card-actions>
|
||||||
|
<q-item v-if="newTemplate.$id === 'unsaved'">
|
||||||
|
<IntervalTemplateComponent
|
||||||
|
:model-value="newTemplate"
|
||||||
|
:edit="true"
|
||||||
|
@cancel="resetNewTemplate"
|
||||||
|
@saved="resetNewTemplate" />
|
||||||
|
</q-item>
|
||||||
|
<q-separator spaced />
|
||||||
|
<IntervalTemplateComponent
|
||||||
|
v-for="template in intervalTemplates"
|
||||||
|
:key="template.$id"
|
||||||
|
:model-value="template" />
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-dialog v-model="alert">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Warning!</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
Conflicting times! Please delete overlapped items!
|
||||||
|
<q-chip
|
||||||
|
v-for="item in overlapped"
|
||||||
|
:key="item.index">
|
||||||
|
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
|
||||||
|
{{ date.formatDate(item.start, 'hh:mm') }} -
|
||||||
|
{{ date.formatDate(item.end, 'hh:mm') }}
|
||||||
|
</q-chip>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="OK"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
QCalendarScheduler,
|
||||||
|
Timestamp,
|
||||||
|
today,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||||
|
import { useIntervalStore } from 'src/stores/interval';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
Interval,
|
||||||
|
IntervalTemplate,
|
||||||
|
TimeTuple,
|
||||||
|
} from 'src/stores/schedule.types';
|
||||||
|
import { date } from 'quasar';
|
||||||
|
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
|
||||||
|
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
|
||||||
|
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||||
|
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
const { fetchBoats } = useBoatStore();
|
||||||
|
const intervalStore = useIntervalStore();
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
|
||||||
|
const calendar = ref();
|
||||||
|
const overlapped = ref();
|
||||||
|
const alert = ref(false);
|
||||||
|
const newTemplate = ref<IntervalTemplate>({
|
||||||
|
$id: '',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
});
|
||||||
|
|
||||||
|
/* TODOS:
|
||||||
|
* Need more validation:
|
||||||
|
- Interval start < end
|
||||||
|
- Intervals don't overlap
|
||||||
|
* Need to handle case of overnight blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchBoats();
|
||||||
|
await intervalTemplateStore.fetchIntervalTemplates();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredIntervals = (date: Timestamp, boat: Boat) => {
|
||||||
|
return intervalStore.getIntervals(date, boat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedIntervals = (date: Timestamp, boat: Boat) => {
|
||||||
|
return filteredIntervals(date, boat).sort(
|
||||||
|
(a, b) => Date.parse(a.start) - Date.parse(b.start)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetNewTemplate() {
|
||||||
|
newTemplate.value = {
|
||||||
|
$id: 'unsaved',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function createTemplate() {
|
||||||
|
newTemplate.value.$id = 'unsaved';
|
||||||
|
}
|
||||||
|
function createIntervals(boat: Boat, templateId: string, date: string) {
|
||||||
|
const intervals = intervalsFromTemplate(boat, templateId, date);
|
||||||
|
intervals.forEach((interval) => intervalStore.createInterval(interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntervals(date: Timestamp, boat: Boat) {
|
||||||
|
return intervalStore.getIntervals(date, boat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalsFromTemplate(
|
||||||
|
boat: Boat,
|
||||||
|
templateId: string,
|
||||||
|
date: string
|
||||||
|
): Interval[] {
|
||||||
|
const template = intervalTemplateStore
|
||||||
|
.getIntervalTemplates()
|
||||||
|
.value.find((t) => t.$id === templateId);
|
||||||
|
return template
|
||||||
|
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||||
|
buildInterval(boat, timeTuple, date)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlock(block: Interval) {
|
||||||
|
if (block.$id) {
|
||||||
|
intervalStore.deleteInterval(block.$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.add('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(
|
||||||
|
//TODO: Move all overlap checking to the store. This is too messy right now.
|
||||||
|
e: DragEvent,
|
||||||
|
type: string,
|
||||||
|
scope: { resource: Boat; timestamp: Timestamp }
|
||||||
|
) {
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
|
||||||
|
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
|
||||||
|
const templateId = e.dataTransfer.getData('ID');
|
||||||
|
const date = scope.timestamp.date;
|
||||||
|
const resource = scope.resource;
|
||||||
|
const existingIntervals = getIntervals(scope.timestamp, resource);
|
||||||
|
const boatsToApply = type === 'head-day' ? boats.value : [resource];
|
||||||
|
overlapped.value = boatsToApply
|
||||||
|
.map((boat) =>
|
||||||
|
intervalsOverlapped(
|
||||||
|
existingIntervals.concat(
|
||||||
|
intervalsFromTemplate(boat, templateId, date)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat(1);
|
||||||
|
if (overlapped.value.length === 0) {
|
||||||
|
boatsToApply.map((b) => createIntervals(b, templateId, date));
|
||||||
|
} else {
|
||||||
|
alert.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToday() {
|
||||||
|
calendar.value.moveToToday();
|
||||||
|
}
|
||||||
|
function onPrev() {
|
||||||
|
calendar.value.prev();
|
||||||
|
}
|
||||||
|
function onNext() {
|
||||||
|
calendar.value.next();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
18
src/pages/schedule/ModifyBoatReservation.vue
Normal file
18
src/pages/schedule/ModifyBoatReservation.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<BoatReservationComponent v-model="reservation" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
|
||||||
|
import { useReservationStore } from 'src/stores/reservation';
|
||||||
|
import { Reservation } from 'src/stores/schedule.types';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const reservation = ref<Reservation>();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = useRoute().params.id as string;
|
||||||
|
reservation.value = await useReservationStore().getReservationById(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<q-item v-for="link in navlinks" :key="link.label">
|
<q-item
|
||||||
|
v-for="link in navlinks"
|
||||||
|
:key="link.name">
|
||||||
<q-btn
|
<q-btn
|
||||||
:icon="link.icon"
|
:icon="link.icon"
|
||||||
color="primary"
|
:color="link.color ? link.color : 'primary'"
|
||||||
size="1.25em"
|
size="1.25em"
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
:label="link.label"
|
:label="link.name"
|
||||||
rounded
|
rounded
|
||||||
class="full-width"
|
class="full-width"
|
||||||
align="left"
|
align="left" />
|
||||||
/>
|
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const navlinks = [
|
import { enabledLinks } from 'src/router/navlinks';
|
||||||
{
|
|
||||||
icon: 'more_time',
|
const navlinks = enabledLinks.find(
|
||||||
to: '/schedule/book',
|
(link) => link.name === 'Schedule'
|
||||||
label: 'Create a Reservation',
|
)?.sublinks;
|
||||||
},
|
|
||||||
{ icon: 'calendar_month', to: '/schedule/view', label: 'View Schedule' },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolbarComponent pageTitle="Tasks" />
|
<ToolbarComponent pageTitle="Tasks" />
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div class="q-pa-md" style="max-width: 400px">
|
<div
|
||||||
<TaskEditComponent taskId="660eb3627974223bb47a" />
|
class="q-pa-md"
|
||||||
|
style="max-width: 400px">
|
||||||
|
<TaskEditComponent :taskId="taskId" />
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const taskId = useRoute().params.id as string;
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
</script>
|
</script>
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<toolbar-component pageTitle="Tasks" />
|
<toolbar-component pageTitle="Tasks" />
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<TaskListComponent v-if="$q.screen.lt.sm" :tasks="taskStore.tasks" />
|
<TaskTableComponent :tasks="taskStore.tasks" />
|
||||||
<TaskTableComponent v-else :tasks="taskStore.tasks" />
|
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTaskStore } from 'stores/task';
|
import { useTaskStore } from 'stores/task';
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||||
import TaskListComponent from 'src/components/task/TaskListComponent.vue';
|
|
||||||
import TaskTableComponent from 'src/components/task/TaskTableComponent.vue';
|
import TaskTableComponent from 'src/components/task/TaskTableComponent.vue';
|
||||||
|
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
import { useAuthStore } from 'src/stores/auth';
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
|
||||||
|
const publicRoutes = routes
|
||||||
|
.filter((route) => route.meta?.publicRoute)
|
||||||
|
.map((r) => r.path);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If not building with SSR mode, you can
|
* If not building with SSR mode, you can
|
||||||
* directly export the Router instantiation;
|
* directly export the Router instantiation;
|
||||||
@@ -35,17 +39,33 @@ export default route(function (/* { store, ssrContext } */) {
|
|||||||
history: createHistory(process.env.VUE_ROUTER_BASE),
|
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||||
});
|
});
|
||||||
|
|
||||||
Router.beforeEach((to) => {
|
Router.beforeEach(async (to, from, next) => {
|
||||||
const auth = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const currentUser = authStore.currentUser;
|
||||||
|
const authRequired = !publicRoutes.includes(to.path);
|
||||||
|
const requiredRoles = to.meta?.requiredRoles as string[];
|
||||||
|
|
||||||
if (!auth.ready) {
|
if (authRequired && !currentUser) {
|
||||||
return false;
|
return next('/login');
|
||||||
}
|
}
|
||||||
if (auth.currentUser) {
|
|
||||||
return to.meta.accountRoute ? { name: 'index' } : true;
|
if (requiredRoles) {
|
||||||
} else {
|
if (!currentUser) {
|
||||||
return to.name == 'login' ? true : { name: 'login' };
|
return next('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasRole = authStore.hasRequiredRole(requiredRoles);
|
||||||
|
if (!hasRole) {
|
||||||
|
return next(from);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user teams:', error);
|
||||||
|
return next('/error'); // Redirect to an error page or handle it as needed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
return Router;
|
return Router;
|
||||||
|
|||||||
@@ -1,50 +1,121 @@
|
|||||||
export const links = [
|
import { useAuthStore } from 'src/stores/auth';
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
name: string;
|
||||||
|
to: string;
|
||||||
|
icon: string;
|
||||||
|
front_links?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
sublinks?: Link[];
|
||||||
|
requiredRoles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const links = <Link[]>[
|
||||||
{
|
{
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
to: '/',
|
to: '/',
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
front_links: false,
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
to: '/profile',
|
to: '/profile',
|
||||||
icon: 'account_circle',
|
icon: 'account_circle',
|
||||||
front_links: false,
|
front_links: false,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Boats',
|
name: 'Boats',
|
||||||
to: '/boat',
|
to: '/boat',
|
||||||
icon: 'sailing',
|
icon: 'sailing',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Schedule',
|
name: 'Schedule',
|
||||||
to: '/schedule',
|
to: '/schedule',
|
||||||
icon: 'calendar_month',
|
icon: 'calendar_month',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: true,
|
||||||
|
sublinks: [
|
||||||
|
{
|
||||||
|
name: 'My View',
|
||||||
|
to: '/schedule/list',
|
||||||
|
icon: 'list',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Book',
|
||||||
|
to: '/schedule/book',
|
||||||
|
icon: 'more_time',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Calendar',
|
||||||
|
to: '/schedule/view',
|
||||||
|
icon: 'calendar_month',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Manage',
|
||||||
|
to: '/schedule/manage',
|
||||||
|
icon: 'edit_calendar',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
color: 'accent',
|
||||||
|
requiredRoles: ['Schedule Admins'],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Certifications',
|
name: 'Certifications',
|
||||||
to: '/certification',
|
to: '/certification',
|
||||||
icon: 'verified',
|
icon: 'verified',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Checklists',
|
name: 'Checklists',
|
||||||
to: '/checklist',
|
to: '/checklist',
|
||||||
icon: 'checklist',
|
icon: 'checklist',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Reference',
|
name: 'Reference',
|
||||||
to: '/reference',
|
to: '/reference',
|
||||||
icon: 'info_outline',
|
icon: 'info_outline',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tasks',
|
name: 'Tasks',
|
||||||
to: '/task',
|
to: '/task',
|
||||||
icon: 'build',
|
icon: 'build',
|
||||||
front_links: true,
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
function hasRole(roles: string[] | undefined) {
|
||||||
|
if (roles === undefined) return true;
|
||||||
|
const hasRole = authStore.hasRequiredRole(roles);
|
||||||
|
return hasRole;
|
||||||
|
}
|
||||||
|
export const enabledLinks = links
|
||||||
|
.filter((link) => link.enabled)
|
||||||
|
.map((link) => {
|
||||||
|
if (link.sublinks) {
|
||||||
|
link.sublinks = link.sublinks.filter(
|
||||||
|
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
||||||
name: 'boat-schedule',
|
name: 'boat-schedule',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'list',
|
||||||
|
component: () =>
|
||||||
|
import('src/pages/schedule/ListReservationsPage.vue'),
|
||||||
|
name: 'list-reservations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit/:id',
|
||||||
|
component: () =>
|
||||||
|
import('src/pages/schedule/ModifyBoatReservation.vue'),
|
||||||
|
name: 'edit-reservation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manage',
|
||||||
|
component: () => import('src/pages/schedule/ManageCalendar.vue'),
|
||||||
|
name: 'manage-schedule',
|
||||||
|
meta: { requiredRoles: ['Schedule Admins'] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -57,9 +75,9 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'task-index',
|
name: 'task-index',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: '/:id/edit',
|
||||||
component: () => import('pages/task/NewTaskPage.vue'),
|
component: () => import('pages/task/TaskEditPage.vue'),
|
||||||
name: 'new-task',
|
name: 'edit-task',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -96,6 +114,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
component: () => import('layouts/AdminLayout.vue'),
|
component: () => import('layouts/AdminLayout.vue'),
|
||||||
|
meta: { requiredRoles: ['admin'] },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
@@ -117,6 +136,22 @@ const routes: RouteRecordRaw[] = [
|
|||||||
publicRoute: true,
|
publicRoute: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/pwreset',
|
||||||
|
component: () => import('pages/ResetPassword.vue'),
|
||||||
|
name: 'pwreset',
|
||||||
|
meta: {
|
||||||
|
publicRoute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
component: () => import('pages/LoginPage.vue'),
|
||||||
|
name: 'login',
|
||||||
|
meta: {
|
||||||
|
publicRoute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/terms-of-service',
|
path: '/terms-of-service',
|
||||||
component: () => import('pages/TermsOfServicePage.vue'),
|
component: () => import('pages/TermsOfServicePage.vue'),
|
||||||
|
|||||||
@@ -1,41 +1,94 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ID, account } from 'boot/appwrite';
|
import { ID, account, functions, teams } from 'boot/appwrite';
|
||||||
import type { Models } from 'appwrite';
|
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
||||||
const ready = ref(false);
|
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const userNames = ref<Record<string, string>>({});
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
currentUser.value = await account.get();
|
currentUser.value = await account.get();
|
||||||
|
currentUserTeams.value = await teams.list();
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
|
currentUserTeams.value = null;
|
||||||
}
|
}
|
||||||
ready.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUserTeamNames = computed(() =>
|
||||||
|
currentUserTeams.value
|
||||||
|
? currentUserTeams.value.teams.map((team) => team.name)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRequiredRole = (requiredRoles: string[]): boolean => {
|
||||||
|
return requiredRoles.some((role) =>
|
||||||
|
currentUserTeamNames.value.includes(role)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
async function register(email: string, password: string) {
|
async function register(email: string, password: string) {
|
||||||
await account.create(ID.unique(), email, password);
|
await account.create(ID.unique(), email, password);
|
||||||
return await login(email, password);
|
return await login(email, password);
|
||||||
}
|
}
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
await account.createEmailSession(email, password);
|
await account.createEmailPasswordSession(email, password);
|
||||||
currentUser.value = await account.get();
|
await init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function 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 | undefined | null): string {
|
||||||
|
if (!id) return 'No User';
|
||||||
|
try {
|
||||||
|
if (!userNames.value[id]) {
|
||||||
|
userNames.value[id] = 'Loading...';
|
||||||
|
functions
|
||||||
|
.createExecution(
|
||||||
|
'userinfo',
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
'/userinfo/' + id,
|
||||||
|
ExecutionMethod.GET
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.responseBody) {
|
||||||
|
userNames.value[id] = JSON.parse(res.responseBody).name;
|
||||||
|
} else {
|
||||||
|
console.error(res, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get username. Error: ' + e);
|
||||||
|
}
|
||||||
|
return userNames.value[id];
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
return account.deleteSession('current').then((currentUser.value = null));
|
return account.deleteSession('current').then((currentUser.value = null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { currentUser, register, login, googleLogin, logout, init, ready };
|
return {
|
||||||
|
currentUser,
|
||||||
|
getUserNameById,
|
||||||
|
hasRequiredRole,
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
googleLogin,
|
||||||
|
logout,
|
||||||
|
init,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
|
import { Models } from 'appwrite';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
// const boatSource = null;
|
// const boatSource = null;
|
||||||
|
|
||||||
export interface Boat {
|
export interface Boat extends Models.Document {
|
||||||
$id: string;
|
$id: string;
|
||||||
name: string;
|
name: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
imgsrc?: string;
|
imgSrc?: string;
|
||||||
iconsrc?: string;
|
iconSrc?: string;
|
||||||
booking?: {
|
bookingAvailable: boolean;
|
||||||
available: boolean;
|
requiredCerts: string[];
|
||||||
requiredCerts: string[];
|
maxPassengers: number;
|
||||||
maxDuration: number;
|
defects: {
|
||||||
maxPassengers: number;
|
|
||||||
};
|
|
||||||
defects?: {
|
|
||||||
type: string;
|
type: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -24,61 +24,25 @@ export interface Boat {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSampleData = () => [
|
export const useBoatStore = defineStore('boat', () => {
|
||||||
{
|
const boats = ref<Boat[]>([]);
|
||||||
$id: '1',
|
|
||||||
name: 'ProjectX',
|
|
||||||
displayName: 'PX',
|
|
||||||
class: 'J/27',
|
|
||||||
year: 1981,
|
|
||||||
imgsrc: '/tmpimg/j27.png',
|
|
||||||
iconsrc: '/tmpimg/projectx_avatar256.png',
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$id: '3',
|
|
||||||
name: 'WeeBeestie',
|
|
||||||
displayName: 'WB',
|
|
||||||
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 | null | undefined): Boat | null => {
|
||||||
|
if (!id) return null;
|
||||||
|
return boats.value?.find((b) => b.$id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
actions: {
|
return { boats, fetchBoats, getBoatById };
|
||||||
// update () {
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
144
src/stores/interval.ts
Normal file
144
src/stores/interval.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { Boat } from './boat';
|
||||||
|
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
|
||||||
|
import { Interval, IntervalRecord } from './schedule.types';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { useReservationStore } from './reservation';
|
||||||
|
|
||||||
|
export const useIntervalStore = defineStore('interval', () => {
|
||||||
|
// TODO: Implement functions to dynamically pull this data.
|
||||||
|
const intervals = ref<Map<string, Interval>>(new Map());
|
||||||
|
const intervalDates = ref<IntervalRecord>({});
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const selectedDate = ref<string>(today());
|
||||||
|
|
||||||
|
const getIntervals = (date: Timestamp | string, boat?: Boat): Interval[] => {
|
||||||
|
const searchDate = typeof date === 'string' ? date : date.date;
|
||||||
|
const dayStart = new Date(searchDate + 'T00:00');
|
||||||
|
const dayEnd = new Date(searchDate + 'T23:59');
|
||||||
|
if (!intervalDates.value[searchDate]) {
|
||||||
|
intervalDates.value[searchDate] = 'pending';
|
||||||
|
fetchIntervals(searchDate);
|
||||||
|
}
|
||||||
|
return computed(() => {
|
||||||
|
return Array.from(intervals.value.values()).filter((interval) => {
|
||||||
|
const intervalStart = new Date(interval.start);
|
||||||
|
const intervalEnd = new Date(interval.end);
|
||||||
|
|
||||||
|
const isWithinDay = intervalStart < dayEnd && intervalEnd > dayStart;
|
||||||
|
const matchesBoat = boat ? boat.$id === interval.resource : true;
|
||||||
|
return isWithinDay && matchesBoat;
|
||||||
|
});
|
||||||
|
}).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableIntervals = (
|
||||||
|
date: Timestamp | string,
|
||||||
|
boat?: Boat
|
||||||
|
): Interval[] => {
|
||||||
|
return computed(() => {
|
||||||
|
return getIntervals(date, boat).filter((interval) => {
|
||||||
|
return !reservationStore.isResourceTimeOverlapped(
|
||||||
|
interval.resource,
|
||||||
|
new Date(interval.start),
|
||||||
|
new Date(interval.end)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchInterval(id: string): Promise<Interval> {
|
||||||
|
return (await databases.getDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
id
|
||||||
|
)) as Interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIntervals(dateString: string) {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
[
|
||||||
|
Query.greaterThanEqual(
|
||||||
|
'end',
|
||||||
|
new Date(dateString + 'T00:00').toISOString()
|
||||||
|
),
|
||||||
|
Query.lessThanEqual(
|
||||||
|
'start',
|
||||||
|
new Date(dateString + 'T23:59').toISOString()
|
||||||
|
),
|
||||||
|
Query.limit(50), // We are asuming that we won't have more than 50 intervals per day.
|
||||||
|
]
|
||||||
|
);
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
intervals.value.set(d.$id, d as Interval)
|
||||||
|
);
|
||||||
|
intervalDates.value[dateString] = 'loaded';
|
||||||
|
console.info(`Loaded ${response.documents.length} intervals from server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch intervals', error);
|
||||||
|
intervalDates.value[dateString] = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
ID.unique(),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
intervals.value.set(response.$id, response as Interval);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
if (interval.$id) {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
interval.$id,
|
||||||
|
{ ...interval, $id: undefined }
|
||||||
|
);
|
||||||
|
intervals.value.set(response.$id, response as Interval);
|
||||||
|
console.info(`Saved Interval: ${interval.$id}`);
|
||||||
|
} else {
|
||||||
|
console.error('Update interval called without an ID');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deleteInterval = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
intervals.value.delete(id);
|
||||||
|
console.info(`Deleted interval: ${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIntervals,
|
||||||
|
getAvailableIntervals,
|
||||||
|
fetchIntervals,
|
||||||
|
fetchInterval,
|
||||||
|
createInterval,
|
||||||
|
updateInterval,
|
||||||
|
deleteInterval,
|
||||||
|
selectedDate,
|
||||||
|
};
|
||||||
|
});
|
||||||
97
src/stores/intervalTemplate.ts
Normal file
97
src/stores/intervalTemplate.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Ref, ref } from 'vue';
|
||||||
|
import { IntervalTemplate } from './schedule.types';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ID, Models } from 'appwrite';
|
||||||
|
import { arrayToTimeTuples } from 'src/utils/schedule';
|
||||||
|
|
||||||
|
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||||
|
const intervalTemplates = ref<IntervalTemplate[]>([]);
|
||||||
|
|
||||||
|
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
|
||||||
|
// Should subscribe to get new intervaltemplates when they are created
|
||||||
|
if (!intervalTemplates.value) fetchIntervalTemplates();
|
||||||
|
return intervalTemplates;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchIntervalTemplates() {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate
|
||||||
|
);
|
||||||
|
intervalTemplates.value = response.documents.map(
|
||||||
|
(d: Models.Document): IntervalTemplate => {
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
timeTuples: arrayToTimeTuples(d.timeTuple),
|
||||||
|
} as IntervalTemplate;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timeblock templates', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIntervalTemplate = async (template: IntervalTemplate) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
ID.unique(),
|
||||||
|
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||||
|
);
|
||||||
|
intervalTemplates.value.push(response as IntervalTemplate);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deleteIntervalTemplate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
intervalTemplates.value = intervalTemplates.value.filter(
|
||||||
|
(template) => template.$id !== id
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateIntervalTemplate = async (
|
||||||
|
template: IntervalTemplate,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: template.name,
|
||||||
|
timeTuple: template.timeTuples.flat(2),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
intervalTemplates.value = intervalTemplates.value.map((b) =>
|
||||||
|
b.$id !== id
|
||||||
|
? b
|
||||||
|
: ({
|
||||||
|
...response,
|
||||||
|
timeTuples: arrayToTimeTuples(response.timeTuple),
|
||||||
|
} as IntervalTemplate)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIntervalTemplates,
|
||||||
|
fetchIntervalTemplates,
|
||||||
|
createIntervalTemplate,
|
||||||
|
deleteIntervalTemplate,
|
||||||
|
updateIntervalTemplate,
|
||||||
|
};
|
||||||
|
});
|
||||||
291
src/stores/reservation.ts
Normal file
291
src/stores/reservation.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { Reservation } from './schedule.types';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { date, useQuasar } from 'quasar';
|
||||||
|
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { LoadingTypes } from 'src/utils/misc';
|
||||||
|
import { useAuthStore } from './auth';
|
||||||
|
import { isPast } from 'src/utils/schedule';
|
||||||
|
|
||||||
|
export const useReservationStore = defineStore('reservation', () => {
|
||||||
|
const reservations = ref<Map<string, Reservation>>(new Map());
|
||||||
|
const datesLoaded = ref<Record<string, LoadingTypes>>({});
|
||||||
|
const userReservations = ref<Map<string, Reservation>>(new Map());
|
||||||
|
// TODO: Come up with a better way of storing reservations by date & reservations for user
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// Fetch reservations for a specific date range
|
||||||
|
const fetchReservationsForDateRange = async (
|
||||||
|
start: string = today(),
|
||||||
|
end: string = start
|
||||||
|
) => {
|
||||||
|
const startDate = new Date(start < end ? start : end + 'T00:00');
|
||||||
|
const endDate = new Date(start < end ? end : start + 'T23:59');
|
||||||
|
|
||||||
|
if (getUnloadedDates(startDate, endDate).length === 0) return;
|
||||||
|
|
||||||
|
setDateLoaded(startDate, endDate, 'pending');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
[
|
||||||
|
Query.greaterThanEqual('end', startDate.toISOString()),
|
||||||
|
Query.lessThanEqual('start', endDate.toISOString()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
reservations.value.set(d.$id, d as Reservation)
|
||||||
|
);
|
||||||
|
setDateLoaded(startDate, endDate, 'loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservations', error);
|
||||||
|
setDateLoaded(startDate, endDate, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getReservationById = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.getDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return response as Reservation;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservation: ', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOrUpdateReservation = async (
|
||||||
|
reservation: Reservation
|
||||||
|
): Promise<Reservation> => {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
if (reservation.$id) {
|
||||||
|
response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
reservation.$id,
|
||||||
|
reservation
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
ID.unique(),
|
||||||
|
reservation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reservations.value.set(response.$id, response as Reservation);
|
||||||
|
userReservations.value.set(response.$id, response as Reservation);
|
||||||
|
console.info('Reservation booked: ', response);
|
||||||
|
return response as Reservation;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating Reservation: ' + e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteReservation = async (
|
||||||
|
reservation: string | Reservation | null | undefined
|
||||||
|
) => {
|
||||||
|
if (!reservation) return false;
|
||||||
|
let id;
|
||||||
|
if (typeof reservation === 'string') {
|
||||||
|
id = reservation;
|
||||||
|
} else if ('$id' in reservation && typeof reservation.$id === 'string') {
|
||||||
|
id = reservation.$id;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = $q.notify({
|
||||||
|
color: 'secondary',
|
||||||
|
textColor: 'white',
|
||||||
|
message: 'Deleting Reservation',
|
||||||
|
spinner: true,
|
||||||
|
closeBtn: 'Dismiss',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 0,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
reservations.value.delete(id);
|
||||||
|
userReservations.value.delete(id);
|
||||||
|
console.info(`Deleted reservation: ${id}`);
|
||||||
|
status({
|
||||||
|
color: 'warning',
|
||||||
|
message: 'Reservation Deleted',
|
||||||
|
spinner: false,
|
||||||
|
icon: 'delete',
|
||||||
|
timeout: 4000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting reservation: ' + e);
|
||||||
|
status({
|
||||||
|
color: 'negative',
|
||||||
|
message: 'Failed to Delete Reservation',
|
||||||
|
spinner: false,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the loading state for dates
|
||||||
|
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
|
||||||
|
if (start > end) return [];
|
||||||
|
let curDate = start;
|
||||||
|
while (curDate < end) {
|
||||||
|
datesLoaded.value[(parseDate(curDate) as Timestamp).date] = state;
|
||||||
|
curDate = date.addToDate(curDate, { days: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnloadedDates = (start: Date, end: Date): string[] => {
|
||||||
|
if (start > end) return [];
|
||||||
|
let curDate = start;
|
||||||
|
const unloaded = [];
|
||||||
|
while (curDate < end) {
|
||||||
|
const parsedDate = (parseDate(curDate) as Timestamp).date;
|
||||||
|
if (datesLoaded.value[parsedDate] === undefined)
|
||||||
|
unloaded.push(parsedDate);
|
||||||
|
curDate = date.addToDate(curDate, { days: 1 });
|
||||||
|
}
|
||||||
|
return unloaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get reservations by date and optionally filter by boat
|
||||||
|
const getReservationsByDate = (
|
||||||
|
searchDate: string,
|
||||||
|
boat?: string
|
||||||
|
): Reservation[] => {
|
||||||
|
if (!datesLoaded.value[searchDate]) {
|
||||||
|
fetchReservationsForDateRange(searchDate);
|
||||||
|
}
|
||||||
|
const dayStart = new Date(searchDate + 'T00:00');
|
||||||
|
const dayEnd = new Date(searchDate + 'T23:59');
|
||||||
|
|
||||||
|
return computed(() => {
|
||||||
|
return Array.from(reservations.value.values()).filter((reservation) => {
|
||||||
|
const reservationStart = new Date(reservation.start);
|
||||||
|
const reservationEnd = new Date(reservation.end);
|
||||||
|
|
||||||
|
const isWithinDay =
|
||||||
|
reservationStart < dayEnd && reservationEnd > dayStart;
|
||||||
|
const matchesBoat = boat ? boat === reservation.resource : true;
|
||||||
|
return isWithinDay && matchesBoat;
|
||||||
|
});
|
||||||
|
}).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get conflicting reservations for a resource within a time range
|
||||||
|
const getConflictingReservations = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): Reservation[] => {
|
||||||
|
return Array.from(reservations.value.values()).filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.resource === resource &&
|
||||||
|
new Date(entry.start) < end &&
|
||||||
|
new Date(entry.end) > start
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a resource has time overlap
|
||||||
|
const isResourceTimeOverlapped = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): boolean => {
|
||||||
|
return getConflictingReservations(resource, start, end).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a reservation overlaps with existing reservations
|
||||||
|
const isReservationOverlapped = (res: Reservation): boolean => {
|
||||||
|
return isResourceTimeOverlapped(
|
||||||
|
res.resource,
|
||||||
|
new Date(res.start),
|
||||||
|
new Date(res.end)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserReservations = async () => {
|
||||||
|
if (!authStore.currentUser) return;
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
[Query.equal('user', authStore.currentUser.$id)]
|
||||||
|
);
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
userReservations.value.set(d.$id, d as Reservation)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservations for user: ', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedUserReservations = computed((): Reservation[] =>
|
||||||
|
[...userReservations.value?.values()].sort(
|
||||||
|
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const futureUserReservations = computed((): Reservation[] => {
|
||||||
|
if (!sortedUserReservations.value) return [];
|
||||||
|
return sortedUserReservations.value.filter((b) => !isPast(b.end));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pastUserReservations = computed((): Reservation[] => {
|
||||||
|
if (!sortedUserReservations.value) return [];
|
||||||
|
return sortedUserReservations.value?.filter((b) => isPast(b.end));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure reactivity for computed properties when Map is modified
|
||||||
|
watch(
|
||||||
|
reservations,
|
||||||
|
() => {
|
||||||
|
sortedUserReservations.value;
|
||||||
|
futureUserReservations.value;
|
||||||
|
pastUserReservations.value;
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
userReservations,
|
||||||
|
() => {
|
||||||
|
sortedUserReservations.value;
|
||||||
|
futureUserReservations.value;
|
||||||
|
pastUserReservations.value;
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getReservationsByDate,
|
||||||
|
getReservationById,
|
||||||
|
createOrUpdateReservation,
|
||||||
|
deleteReservation,
|
||||||
|
fetchReservationsForDateRange,
|
||||||
|
isReservationOverlapped,
|
||||||
|
isResourceTimeOverlapped,
|
||||||
|
getConflictingReservations,
|
||||||
|
fetchUserReservations,
|
||||||
|
sortedUserReservations,
|
||||||
|
futureUserReservations,
|
||||||
|
pastUserReservations,
|
||||||
|
userReservations,
|
||||||
|
};
|
||||||
|
});
|
||||||
65
src/stores/sampledata/boat.ts
Normal file
65
src/stores/sampledata/boat.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export const getSampleData = () => [
|
||||||
|
{
|
||||||
|
$id: '1',
|
||||||
|
name: 'ProjectX',
|
||||||
|
displayName: 'PX',
|
||||||
|
class: 'J/27',
|
||||||
|
year: 1981,
|
||||||
|
imgSrc: '/tmpimg/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: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
151
src/stores/sampledata/schedule.ts
Normal file
151
src/stores/sampledata/schedule.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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),
|
||||||
|
resource: 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',
|
||||||
|
reason: 'Open Sail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
user: 'Bob Barker',
|
||||||
|
start: '16:00',
|
||||||
|
end: '19:00',
|
||||||
|
boat: '66359729003825946ae1',
|
||||||
|
status: 'confirmed',
|
||||||
|
reason: 'Open Sail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
user: 'Peter Parker',
|
||||||
|
start: '7:00',
|
||||||
|
end: '13:00',
|
||||||
|
boat: '663597030029b71c7a9b',
|
||||||
|
status: 'tentative',
|
||||||
|
reason: 'Open Sail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
user: 'Vince McMahon',
|
||||||
|
start: '10:00',
|
||||||
|
end: '13:00',
|
||||||
|
boat: '663597030029b71c7a9b',
|
||||||
|
status: 'pending',
|
||||||
|
reason: 'Open Sail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
user: 'Heather Graham',
|
||||||
|
start: '13:00',
|
||||||
|
end: '19:00',
|
||||||
|
boat: '663596b9000235ffea55',
|
||||||
|
status: 'confirmed',
|
||||||
|
reason: 'Private Sail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
user: 'Lawrence Fishburne',
|
||||||
|
start: '13:00',
|
||||||
|
end: '16:00',
|
||||||
|
boat: '663596b9000235ffea55',
|
||||||
|
reason: 'Open Sail',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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,
|
||||||
|
reason: entry.reason,
|
||||||
|
status: entry.status as StatusTypes,
|
||||||
|
comment: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { Boat, useBoatStore } from './boat';
|
|
||||||
import { date } from 'quasar';
|
|
||||||
import { DateOptions } from 'quasar';
|
|
||||||
import {
|
|
||||||
Timestamp,
|
|
||||||
parseTimestamp,
|
|
||||||
TimestampArray,
|
|
||||||
} from '@quasar/quasar-ui-qcalendar';
|
|
||||||
import { timeStamp } from 'console';
|
|
||||||
|
|
||||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
|
||||||
export type Reservation = {
|
|
||||||
id: number;
|
|
||||||
user: string;
|
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
resource: Boat;
|
|
||||||
reservationDate: Date;
|
|
||||||
status?: StatusTypes;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Timeblock = {
|
|
||||||
start: Timestamp;
|
|
||||||
end: Timestamp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sampleBlocks = [
|
|
||||||
{
|
|
||||||
start: { time: '09:00', hour: 9, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
end: { time: '12:00', hour: 12, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
start: { time: '12:00', hour: 12, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
end: { time: '15:00', hour: 15, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
start: { time: '15:00', hour: 15, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
end: { time: '18:00', hour: 18, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
start: { time: '18:00', hour: 18, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
end: { time: '21:00', hour: 21, minute: 0, hasDay: false, hasTime: true },
|
|
||||||
},
|
|
||||||
] as Timeblock[];
|
|
||||||
|
|
||||||
function getSampleReservations(): Reservation[] {
|
|
||||||
const sampleData = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
user: 'John Smith',
|
|
||||||
start: '12:00',
|
|
||||||
end: '15:00',
|
|
||||||
boat: 1,
|
|
||||||
status: 'confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
user: 'Bob Barker',
|
|
||||||
start: '18:00',
|
|
||||||
end: '21:00',
|
|
||||||
boat: 1,
|
|
||||||
status: 'confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
user: 'Peter Parker',
|
|
||||||
start: '9:00',
|
|
||||||
end: '12:00',
|
|
||||||
boat: 2,
|
|
||||||
status: 'tentative',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
user: 'Vince McMahon',
|
|
||||||
start: '15:00',
|
|
||||||
end: '18:00',
|
|
||||||
boat: 2,
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
user: 'Heather Graham',
|
|
||||||
start: '09:00',
|
|
||||||
end: '12:00',
|
|
||||||
boat: 3,
|
|
||||||
status: 'confirmed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
user: 'Lawrence Fishburne',
|
|
||||||
start: '18:00',
|
|
||||||
end: '21: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]),
|
|
||||||
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))),
|
|
||||||
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
|
|
||||||
resource: boat,
|
|
||||||
reservationDate: now,
|
|
||||||
status: entry.status as StatusTypes,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useScheduleStore = defineStore('schedule', () => {
|
|
||||||
// TODO: Implement functions to dynamically pull this data.
|
|
||||||
const reservations = ref<Reservation[]>(getSampleReservations());
|
|
||||||
const timeblocks = sampleBlocks;
|
|
||||||
|
|
||||||
const getTimeblocksForDate = (date: Date): Timeblock[] => timeblocks;
|
|
||||||
|
|
||||||
const getBoatReservations = (
|
|
||||||
boat: number | string,
|
|
||||||
curDate: Date
|
|
||||||
): Reservation[] => {
|
|
||||||
return reservations.value.filter((x) => {
|
|
||||||
return (
|
|
||||||
(x.start.getDate() == curDate.getDate() ||
|
|
||||||
x.end.getDate() == curDate.getDate()) &&
|
|
||||||
x.resource != undefined &&
|
|
||||||
(typeof boat == 'number'
|
|
||||||
? x.resource.id == boat
|
|
||||||
: x.resource.name == boat)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConflictingReservations = (
|
|
||||||
resource: Boat,
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
): Reservation[] => {
|
|
||||||
const overlapped = reservations.value.filter(
|
|
||||||
(entry: Reservation) =>
|
|
||||||
entry.resource.id == resource.id &&
|
|
||||||
entry.start < end &&
|
|
||||||
entry.end > start
|
|
||||||
);
|
|
||||||
return overlapped;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isResourceTimeOverlapped = (
|
|
||||||
resource: Boat,
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
): boolean => {
|
|
||||||
return getConflictingReservations(resource, start, end).length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReservationOverlapped = (res: Reservation): boolean => {
|
|
||||||
return isResourceTimeOverlapped(res.resource, res.start, res.end);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNewId = () => {
|
|
||||||
// Trivial placeholder
|
|
||||||
return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addOrCreateReservation = (reservation: Reservation) => {
|
|
||||||
const index = reservations.value.findIndex(
|
|
||||||
(res) => res.id == reservation.id
|
|
||||||
);
|
|
||||||
index != -1
|
|
||||||
? (reservations.value[index] = reservation)
|
|
||||||
: reservations.value.push(reservation);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
reservations,
|
|
||||||
getBoatReservations,
|
|
||||||
getConflictingReservations,
|
|
||||||
getTimeblocksForDate,
|
|
||||||
getNewId,
|
|
||||||
addOrCreateReservation,
|
|
||||||
isReservationOverlapped,
|
|
||||||
isResourceTimeOverlapped,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
35
src/stores/schedule.types.ts
Normal file
35
src/stores/schedule.types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Models } from 'appwrite';
|
||||||
|
import { LoadingTypes } from 'src/utils/misc';
|
||||||
|
|
||||||
|
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||||
|
export type Reservation = Interval & {
|
||||||
|
user: string;
|
||||||
|
status?: StatusTypes;
|
||||||
|
reason: string;
|
||||||
|
comment: string;
|
||||||
|
members?: string[];
|
||||||
|
guests?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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> & {
|
||||||
|
resource: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntervalTemplate = Partial<Models.Document> & {
|
||||||
|
name: string;
|
||||||
|
timeTuples: TimeTuple[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IntervalRecord {
|
||||||
|
[key: string]: LoadingTypes;
|
||||||
|
}
|
||||||
@@ -7,24 +7,31 @@ export const TASKSTATUS = ['ready', 'complete', 'waiting', 'archived'];
|
|||||||
export interface Task extends Partial<Models.Document> {
|
export interface Task extends Partial<Models.Document> {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
required_skills: string[];
|
required_skills: string[];
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
tags: string[];
|
tags: string[];
|
||||||
due_date: string;
|
due_date: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
volunteers: string[];
|
volunteers: string[];
|
||||||
volunteers_required: number;
|
volunteers_required: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
/* Array of Appwrite Document IDs */
|
||||||
depends_on: string[];
|
depends_on: string[];
|
||||||
boat: string;
|
/* Appwrite ID of a Boat resource */
|
||||||
} // TODO: convert some of these strings into objects.
|
boat?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskTag extends Models.Document {
|
export interface TaskTag extends Models.Document {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
colour: string;
|
||||||
}
|
}
|
||||||
export interface SkillTag extends Models.Document {
|
export interface SkillTag extends Models.Document {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
tagColour: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTaskStore = defineStore('tasks', {
|
export const useTaskStore = defineStore('tasks', {
|
||||||
@@ -37,9 +44,11 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
actions: {
|
actions: {
|
||||||
async fetchTasks() {
|
async fetchTasks() {
|
||||||
try {
|
try {
|
||||||
|
await this.fetchTaskTags();
|
||||||
|
await this.fetchSkillTags();
|
||||||
const response = await databases.listDocuments(
|
const response = await databases.listDocuments(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTask
|
AppwriteIds.collection.task
|
||||||
);
|
);
|
||||||
this.tasks = response.documents as Task[];
|
this.tasks = response.documents as Task[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -51,7 +60,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
const response = await databases.listDocuments(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTaskTags
|
AppwriteIds.collection.taskTags
|
||||||
);
|
);
|
||||||
this.taskTags = response.documents as TaskTag[];
|
this.taskTags = response.documents as TaskTag[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -63,52 +72,86 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
const response = await databases.listDocuments(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdSkillTags
|
AppwriteIds.collection.skillTags
|
||||||
);
|
);
|
||||||
this.skillTags = response.documents as SkillTag[];
|
this.skillTags = response.documents as SkillTag[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch skill tags', 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) {
|
async addTask(task: Task) {
|
||||||
|
const newTask = <Models.Document>{ ...task };
|
||||||
try {
|
try {
|
||||||
const response = await databases.createDocument(
|
const response = await databases.createDocument(
|
||||||
AppwriteIds.databaseId,
|
AppwriteIds.databaseId,
|
||||||
AppwriteIds.collectionIdTask,
|
AppwriteIds.collection.task,
|
||||||
ID.unique(),
|
ID.unique(),
|
||||||
task
|
newTask
|
||||||
);
|
);
|
||||||
this.tasks.push(response as Task);
|
this.tasks.push(response as Task);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add task:', error);
|
console.error('Failed to add task:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// TODO: Enhance this store to include offline caching, and subscription notification when items change on the server.
|
async updateTask(task: Task) {
|
||||||
|
const newTask = <Partial<Models.Document>>{
|
||||||
filterTasks(searchQuery: string) {
|
...task,
|
||||||
const result = this.tasks.filter((task) =>
|
id: undefined,
|
||||||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
$databaseId: undefined,
|
||||||
);
|
$collectionId: undefined,
|
||||||
console.log(result);
|
};
|
||||||
return result;
|
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)
|
// Add more actions as needed (e.g., updateTask, deleteTask)
|
||||||
getters: {
|
getters: {
|
||||||
// A getter to reconstruct the hierarchical structure from flat task data
|
getTaskById: (state) => (id: string) => {
|
||||||
taskHierarchy: (state) => {
|
return state.tasks.find((task) => task.$id === id) || null;
|
||||||
function buildHierarchy(
|
},
|
||||||
tasks: Task[],
|
getTaskTagById: (state) => (id: string) => {
|
||||||
parentId: string | null = null
|
return state.taskTags.find((tag) => tag.$id === id) || null;
|
||||||
): Task[] {
|
},
|
||||||
return tasks
|
getSkillById: (state) => (id: string) => {
|
||||||
.filter((task) => task.parentId === parentId)
|
return state.skillTags.find((tag) => tag.$id === id) || null;
|
||||||
.map((task) => ({
|
},
|
||||||
...task,
|
filterTasksByTitle: (state) => (searchQuery: string) => {
|
||||||
subtasks: buildHierarchy(tasks, task.$id), // Assuming $id is the task ID field
|
const result = state.tasks.filter((task) =>
|
||||||
}));
|
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
}
|
);
|
||||||
return buildHierarchy(state.tasks);
|
return result;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
9
src/utils/misc.ts
Normal file
9
src/utils/misc.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function getNewId(): string {
|
||||||
|
return [...Array(20)]
|
||||||
|
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||||
|
.join('');
|
||||||
|
// Trivial placeholder
|
||||||
|
//return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;
|
||||||
90
src/utils/schedule.ts
Normal file
90
src/utils/schedule.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { date } from 'quasar';
|
||||||
|
import { Boat } from 'src/stores/boat';
|
||||||
|
import {
|
||||||
|
Interval,
|
||||||
|
IntervalTemplate,
|
||||||
|
TimeTuple,
|
||||||
|
} from 'src/stores/schedule.types';
|
||||||
|
|
||||||
|
export function arrayToTimeTuples(arr: string[]) {
|
||||||
|
const timeTuples: TimeTuple[] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += 2) {
|
||||||
|
timeTuples.push([arr[i], arr[i + 1]]);
|
||||||
|
}
|
||||||
|
return timeTuples;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||||
|
return intervalsOverlapped(
|
||||||
|
tuples.map((tuples) => {
|
||||||
|
return {
|
||||||
|
resource: '',
|
||||||
|
start: '01/01/2001 ' + tuples[0],
|
||||||
|
end: '01/01/2001 ' + tuples[1],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).map((t) => {
|
||||||
|
return { ...t, start: t.start.split(' ')[1], end: t.end.split(' ')[1] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intervalsOverlapped(
|
||||||
|
blocks: Interval[] | Interval[]
|
||||||
|
): Interval[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
blocks
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
|
||||||
|
.reduce((acc: Interval[], block, i, arr) => {
|
||||||
|
if (i > 0 && block.start < arr[i - 1].end)
|
||||||
|
acc.push(arr[i - 1], block);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
|
||||||
|
return tuples.map((t) => Object.assign([], t));
|
||||||
|
}
|
||||||
|
export function copyIntervalTemplate(
|
||||||
|
template: IntervalTemplate
|
||||||
|
): IntervalTemplate {
|
||||||
|
return {
|
||||||
|
...template,
|
||||||
|
timeTuples: copyTimeTuples(template.timeTuples || []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInterval(
|
||||||
|
resource: Boat,
|
||||||
|
time: TimeTuple,
|
||||||
|
blockDate: string
|
||||||
|
): Interval {
|
||||||
|
/* When the time zone offset is absent, date-only forms are interpreted
|
||||||
|
as a UTC time and date-time forms are interpreted as local time. */
|
||||||
|
const result = {
|
||||||
|
resource: resource.$id,
|
||||||
|
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||||
|
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPast = (itemDate: Date | string): boolean => {
|
||||||
|
if (!(itemDate instanceof Date)) {
|
||||||
|
itemDate = new Date(itemDate);
|
||||||
|
}
|
||||||
|
const currentDate = new Date();
|
||||||
|
return itemDate < currentDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDate(inputDate: string | undefined): string {
|
||||||
|
if (!inputDate) return '';
|
||||||
|
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(inputDate: string | undefined): string {
|
||||||
|
if (!inputDate) return '';
|
||||||
|
return date.formatDate(new Date(inputDate), 'hh:mm A');
|
||||||
|
}
|
||||||
6
tsconfig.vue-tsc.json
Normal file
6
tsconfig.vue-tsc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user